refactor(web): restructure into api/domain/services/worker; capabilities, file domains, video covers

- dissolve core-bridge into api (wire contract), domain (models), services (what the UI
  calls) and worker (everything that runs in the Web Worker, grouped by account, crypto,
  space, files, shares, runtime); regroup features, lib and store by responsibility and
  delete unused components
- the UI only reads backend capabilities (domain/capabilities); one tokenRole picks the
  token, hasCode/isRetryable handle errors; pre-checks that decided permissions on the
  client are gone; tests fail if those rules are reimplemented
- files go straight to the file domain (create, upload, download, delete, commit, cancel);
  the host sees one registration per file; creating a channel allocates a file domain and
  works even when the file service is down
- file panel shows which channel the files belong to and its space usage; unlimited files
  preview in chat; videos get a stored cover; the file service status is shown
- fix: console flooded by Retry-After reads on every upload frame
This commit is contained in:
justme committed 2026-10-19 13:38:51 +08:00
1 parent 17638b6e20
commit 5489407241
260 files changed
+1502 -1245

No files matched your search

File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,5 @@
// Generated from backend DTOs; do not edit. // Generated from backend DTOs; do not edit.
// backend-contract-sha256: 6e4037d39ac5ae791724731810e3df5b791839e8ab29cf54643ec1a294132964 // backend-contract-sha256: 340876061d7b324e4fd16972e276ffb0f238eeac66b214a043b3b4b98d560fcf
export interface AccountChannelBody { export interface AccountChannelBody {
channel_id: string; channel_id: string;
@@ -76,12 +76,21 @@ export interface ChannelHistoryRequest {
} }
export interface ChannelInfo { export interface ChannelInfo {
avatar?: string | null; avatar?: string | null;
can_create_channel: boolean;
can_delete_any: boolean;
can_delete_own: boolean;
can_edit_settings: boolean;
can_invite: boolean;
can_manage_folders: boolean;
can_manage_roles: boolean;
can_manage_structure: boolean; can_manage_structure: boolean;
can_remove_members: boolean;
can_upload: boolean; can_upload: boolean;
created_at: number; created_at: number;
creator_account?: string | null; creator_account?: string | null;
expires_at: number; expires_at: number;
id: string; id: string;
is_owner: boolean;
last_seq: number; last_seq: number;
members_used: number; members_used: number;
mode: ChannelMode; mode: ChannelMode;
@@ -90,6 +99,7 @@ export interface ChannelInfo {
settings: ChannelSettings; settings: ChannelSettings;
} }
export type ChannelMode = 'enc' | 'raw'; export type ChannelMode = 'enc' | 'raw';
export const ChannelMode = { Enc: 'enc', Raw: 'raw' } as const;
export interface ChannelSettings { export interface ChannelSettings {
delete_policy: DeletePolicy; delete_policy: DeletePolicy;
@@ -149,6 +159,7 @@ export interface ChannelAccess {
username: string; username: string;
} }
export type DeletePolicy = 'owner_only' | 'own' | 'anyone'; export type DeletePolicy = 'owner_only' | 'own' | 'anyone';
export const DeletePolicy = { OwnerOnly: 'owner_only', Own: 'own', Anyone: 'anyone' } as const;
export interface DomainEnvelope { export interface DomainEnvelope {
channelId: string; channelId: string;
@@ -158,6 +169,7 @@ export interface DomainEnvelope {
username: string; username: string;
} }
export type CryptoDomain = 'chat' | 'files'; export type CryptoDomain = 'chat' | 'files';
export const CryptoDomain = { Chat: 'chat', Files: 'files' } as const;
export interface DomainPublishRequest { export interface DomainPublishRequest {
channelId: string; channelId: string;
@@ -169,14 +181,15 @@ export interface ErrorBody {
error: string; error: string;
} }
export interface FileGrantBody { export interface FileGrantBody {
domain: string;
expires_at: number; expires_at: number;
mode: string; ticket: string;
ticket?: string | null; url: string;
url?: string | null;
} }
export interface FileGrantRequest { export interface FileGrantRequest {
channel?: string | null;
from: number; from: number;
item: string; item?: string | null;
op: string; op: string;
} }
export interface FinishShareRequest { export interface FinishShareRequest {
@@ -187,6 +200,7 @@ export interface GoneLogBody {
oldest_seq: number; oldest_seq: number;
} }
export interface InvitationBody { export interface InvitationBody {
can_revoke: boolean;
channel_id: string; channel_id: string;
created_at: number; created_at: number;
payload: string; payload: string;
@@ -209,6 +223,7 @@ export interface InvitationRequest {
username: string; username: string;
} }
export type JoinPolicy = 'open' | 'invite'; export type JoinPolicy = 'open' | 'invite';
export const JoinPolicy = { Open: 'open', Invite: 'invite' } as const;
export interface JoinRequest { export interface JoinRequest {
password?: string | null; password?: string | null;
@@ -222,6 +237,7 @@ export interface KeyPackageRequest {
payload: string; payload: string;
} }
export interface MemberBody { export interface MemberBody {
can_remove: boolean;
devices: number; devices: number;
joined_at: number; joined_at: number;
last_seen: number; last_seen: number;
@@ -317,7 +333,6 @@ export interface LimitsBody {
max_ttl_seconds: number; max_ttl_seconds: number;
max_visitors: number; max_visitors: number;
max_workspace_bytes: number; max_workspace_bytes: number;
max_workspace_channel_bytes: number;
min_ttl_seconds: number; min_ttl_seconds: number;
} }
export interface SettingChoicesBody { export interface SettingChoicesBody {
@@ -385,7 +400,7 @@ export interface WorkspaceChannel {
chatHistory: boolean; chatHistory: boolean;
createdAt: number; createdAt: number;
createdBy: string; createdBy: string;
fileBytes: number; fileDomain?: string | null;
filesEpoch: number; filesEpoch: number;
filesHistory: boolean; filesHistory: boolean;
groupId?: string | null; groupId?: string | null;
@@ -393,9 +408,13 @@ export interface WorkspaceChannel {
name: string; name: string;
} }
export interface WorkspaceChannelRequest { export interface WorkspaceChannelRequest {
fileDomain?: string | null;
groupId?: string | null; groupId?: string | null;
name: string; name: string;
} }
export interface WorkspaceFileDomainRequest {
fileDomain: string;
}
export interface WorkspaceFolderRequest { export interface WorkspaceFolderRequest {
name: string; name: string;
parentId?: string | null; parentId?: string | null;
@@ -467,3 +486,18 @@ export interface WorkspaceLocation {
itemId: string; itemId: string;
threadId: string; threadId: string;
} }
/** 错误响应里的 error 取值 */
export const ErrorCode = { BadRequest: 'bad_request', Unauthorized: 'unauthorized', Forbidden: 'forbidden', NotJoined: 'not_joined', MembersFull: 'members_full', ModeMismatch: 'mode_mismatch', NotFound: 'not_found', Conflict: 'conflict', Gone: 'gone', TooLarge: 'too_large', CaptchaRequired: 'captcha_required', CaptchaUnavailable: 'captcha_unavailable', RateLimited: 'rate_limited', VisitorsFull: 'visitors_full', Sealed: 'sealed', AccountRequired: 'account_required', InvitationRequired: 'invitation_required', ChannelPasswordRequired: 'channel_password_required', ChannelPasswordInvalid: 'channel_password_invalid', SpaceNameRequired: 'space_name_required', UseFileApi: 'use_file_api', FilesUnavailable: 'files_unavailable' } as const;
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
/** X-WD-Role 请求头:这次请求出示哪张令牌 */
export const TokenRole = { Read: 'read', Write: 'write', Owner: 'owner' } as const;
export type TokenRole = (typeof TokenRole)[keyof typeof TokenRole];
/** 空间权限(身份组授予) */
export const WorkspacePermission = { RemoveMembers: 'members.remove', ManageStructure: 'structure.manage' } as const;
export type WorkspacePermission = (typeof WorkspacePermission)[keyof typeof WorkspacePermission];
/** 便捷分享的状态 */
export const ShareState = { Draft: 'draft', Sealed: 'sealed' } as const;
export type ShareState = (typeof ShareState)[keyof typeof ShareState];
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
/**
* 前端不做权限和状态判断,只读后端给的能力、按后端的错误码处理。规则只在后端一处,这里守住前端不要重新实现:
* - 角色/权限:界面只通过 domain/capabilities 读后端给的 can_* 字段,不比较角色、不写 isOwner 判断;
* - 选哪张令牌:只有 tokenRole 一处;
* - 服务端为什么拒绝:只通过 hasCode(错误码),不比较 HTTP 状态码数字。
* 允许的例外写在 allowed 里,并说明理由。
*/
const modules = import.meta.glob<string>('/src/**/*.{ts,tsx}', { query: '?raw', import: 'default', eager: true });
const files = Object.entries(modules)
.map(([path, text]) => ({ path: path.replace(/^\/src\//, ''), text }))
.filter(({ path }) => !/\.test\.(ts|tsx)$/.test(path) && !path.startsWith('api/generated'));
interface Rule { name: string; pattern: RegExp; allowed: string[] }
const RULES: Rule[] = [
{ name: '不用 session.can(...) 自己判断权限(选令牌的 tokenRole、必须持有所有者密钥才能做的密码学操作、分享的所有权释放除外)', pattern: /session\.can\('(owner|write)'\)/,
allowed: ['worker/runtime/sessions.ts', 'worker/crypto/channelDataKeys.ts', 'worker/shares/shareState.ts', 'worker/shares/shareSeal.ts', 'worker/space/channelResources.ts', 'worker/space/channelTransport.ts'] },
{ name: '不比较 HTTP 状态码数字(请用 hasCode / isRetryable)', pattern: /\.status\s*(===|!==|>=|<=|<|>)\s*\d{3}\b/, allowed: ['worker/runtime/http.ts'] },
{ name: '不比较角色字符串(成员行的展示、授权包的密码学校验除外;分享的 mine 待后端提供)', pattern: /\.role\s*(===|!==)\s*'(owner|member|write|read)'/,
allowed: ['features/channel/members/MembersPane.tsx', 'features/channel/members/RolesEditor.tsx', 'worker/account/accountChannels.ts', 'worker/crypto/channelGrant.ts', 'worker/runtime/wire.ts'] },
];
describe('前端不重新实现后端的规则', () => {
it('扫描到了源码', () => { expect(files.length).toBeGreaterThan(100); });
for (const rule of RULES) {
it(rule.name, () => {
const offenders = files
.filter(({ path }) => !rule.allowed.includes(path))
.flatMap(({ path, text }) => text.split('\n')
.map((line, index) => ({ path, line: index + 1, text: line.trim() }))
.filter(({ text: line }) => !line.startsWith('//') && !line.startsWith('*') && rule.pattern.test(line))
.map(({ path: p, line, text: source }) => `${p}:${line} ${source}`));
expect(offenders, `这些地方重新实现了后端的规则:\n${offenders.join('\n')}`).toEqual([]);
});
}
});
+4 -4
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { accountApi } from '@/core-bridge/account'; import { accountApi } from '@/services/account';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { useChannels } from '@/store/channels'; import { useChannels } from '@/store/space/channels';
import { AccountScreen } from '@/features/home/AccountScreen'; import { AccountScreen } from '@/features/account/AccountScreen';
import { MissingAccess } from '@/features/scan/JoinScreen'; import { MissingAccess } from '@/features/scan/JoinScreen';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import styles from '@/features/scan/JoinScreen.module.css'; import styles from '@/features/scan/JoinScreen.module.css';
+6 -6
View File
@@ -2,16 +2,16 @@ import { useEffect, useState } from 'react';
import { Home } from '@/features/home/Home'; import { Home } from '@/features/home/Home';
import { JoinScreen } from '@/features/scan/JoinScreen'; import { JoinScreen } from '@/features/scan/JoinScreen';
import { AccountChannelAccess } from './AccountChannelAccess'; import { AccountChannelAccess } from './AccountChannelAccess';
import { useChannels } from '@/store/channels'; import { useChannels } from '@/store/space/channels';
import { useShares } from '@/store/shares'; import { useShares } from '@/store/share/shares';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { ChannelScreen } from './ChannelScreen'; import { ChannelScreen } from './ChannelScreen';
import { ShareScreen } from './ShareScreen'; import { ShareScreen } from './ShareScreen';
import { channelPath, navigate, useRoute } from './navigation'; import { channelPath, navigate, useRoute } from './navigation';
import { Splash } from './Splash'; import { Splash } from './Splash';
import { AccountScreen } from '@/features/home/AccountScreen'; import { AccountScreen } from '@/features/account/AccountScreen';
import { AccountDock } from '@/features/home/AccountDock'; import { AccountDock } from '@/features/account/AccountDock';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { ToastHost } from './ToastHost'; import { ToastHost } from './ToastHost';
import { useInvitationNotices } from './useInvitationNotices'; import { useInvitationNotices } from './useInvitationNotices';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Workspace } from '@/features/channel/Workspace'; import { Workspace } from '@/features/channel/Workspace';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { CreateSheet } from '@/features/home/CreateSheet'; import { CreateSheet } from '@/features/home/CreateSheet';
import { ChannelSkeleton } from './ChannelSkeleton'; import { ChannelSkeleton } from './ChannelSkeleton';
import { channelPath, navigate } from './navigation'; import { channelPath, navigate } from './navigation';
+2 -2
View File
@@ -3,8 +3,8 @@ import { ItemSourceContext, shareSource } from '@/features/item/source';
import { MissingAccess } from '@/features/scan/JoinScreen'; import { MissingAccess } from '@/features/scan/JoinScreen';
import { ShareDraft } from '@/features/share/ShareDraft'; import { ShareDraft } from '@/features/share/ShareDraft';
import { ShareView } from '@/features/share/ShareView'; import { ShareView } from '@/features/share/ShareView';
import { useShares } from '@/store/shares'; import { useShares } from '@/store/share/shares';
import { useShareSession } from '@/store/shareSession'; import { useShareSession } from '@/store/share/shareSession';
import { navigate } from './navigation'; import { navigate } from './navigation';
/** /**
+1 -1
View File
@@ -1,4 +1,4 @@
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import styles from './ToastHost.module.css'; import styles from './ToastHost.module.css';
+2 -2
View File
@@ -1,5 +1,5 @@
import { parseChannelLink, type ParsedChannelLink } from '@/lib/channelLink'; import { parseChannelLink, type ParsedChannelLink } from '@/lib/links/channelLink';
import { parseShareLink, type ParsedShareLink } from '@/lib/shareLink'; import { parseShareLink, type ParsedShareLink } from '@/lib/links/shareLink';
export type EntryLink = export type EntryLink =
| { kind: 'channel'; link: ParsedChannelLink } | { kind: 'channel'; link: ParsedChannelLink }
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSyncExternalStore } from 'react'; import { useSyncExternalStore } from 'react';
import { isSheetEntry } from '@/lib/useBackToClose'; import { isSheetEntry } from '@/lib/hooks/useBackToClose';
export type Route = { name: 'home' } | { name: 'channel'; id: string } | { name: 'share'; id: string }; export type Route = { name: 'home' } | { name: 'channel'; id: string } | { name: 'share'; id: string };
+3 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { accountApi } from '@/core-bridge/account'; import { accountApi } from '@/services/account';
import { acceptInvitation, invitationLabel } from '@/store/invitations'; import { acceptInvitation, invitationLabel } from '@/store/account/invitations';
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
const POLL_MS = 8000; const POLL_MS = 8000;
-69
View File
@@ -1,69 +0,0 @@
import { fromBase64Url, type ChannelSession } from '@webdrop/core';
import type { FileGrantBody } from '../generated/api';
import type { SavedResource } from '../database';
import { apiFetch, apiUpload, externalFetch, getServerInfo } from './http';
/**
* 文件的内容(不分大小)不放主后端,由文件服务(另一台机器)保存。流程:
* 上传:向主后端要上传票据 → 把整帧 PUT 给文件服务,得到回执 → 把 26 字节头部和回执交给主后端登记
* 下载:向主后端要下载票据(按原来的规则计次)→ 带着票据向文件服务取
* 聊天、头像、空间结构、文件目录不受影响,照旧走主后端。服务端没开文件服务时,下面的函数都退回原来的行为。
*/
const toHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
export const channelHex = (resourceId: string): string => toHex(fromBase64Url(resourceId, 16) ?? new Uint8Array(16));
export const itemHex = (itemId: string): string => toHex(fromBase64Url(itemId, 16) ?? new Uint8Array(16));
export const frameItemHex = (bytes: Uint8Array): string => toHex(bytes.subarray(2, 18));
export async function filesEnabled(): Promise<boolean> {
try { return (await getServerInfo()).files?.enabled === true; } catch { return false; }
}
const roleOf = (session: ChannelSession): 'owner' | 'write' | 'read' => (session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read');
async function requestGrant(resource: SavedResource, session: ChannelSession, item: string, op: 'put' | 'get', from: number): Promise<FileGrantBody> {
const response = await apiFetch(`/channels/${resource.id}/files/ticket`, {
method: 'POST', headers: { ...session.authHeaders(roleOf(session)), 'Content-Type': 'application/json' },
body: JSON.stringify({ item, op, from }),
});
return response.json() as Promise<FileGrantBody>;
}
const uploadTickets = new Map<string, FileGrantBody>();
/** 上传票据在有效期内复用(一个条目的所有帧共用),快过期时换新的 */
export async function uploadGrant(resource: SavedResource, session: ChannelSession, item: string, fresh = false): Promise<FileGrantBody> {
const key = `${resource.id}:${item}`;
const known = uploadTickets.get(key);
if (!fresh && known && known.expires_at - Date.now() > 60_000) return known;
const grant = await requestGrant(resource, session, item, 'put', 0);
if (grant.mode === 'remote') uploadTickets.set(key, grant); else uploadTickets.delete(key);
return grant;
}
export const forgetUploadGrant = (resource: SavedResource, item: string): void => { uploadTickets.delete(`${resource.id}:${item}`); };
/** 把一帧整个传给文件服务,返回文件服务签的回执 */
export async function putFrame(
grant: FileGrantBody, resource: SavedResource, bytes: Uint8Array, index: number,
onProgress: (loaded: number, total: number) => void, signal?: AbortSignal, stallMs?: number,
): Promise<string> {
const url = `${grant.url!.replace(/\/$/, '')}/f/${channelHex(resource.id)}/${frameItemHex(bytes)}/${index}`;
const text = await apiUpload('', { 'X-WD-Ticket': grant.ticket! }, new Blob([bytes as BlobPart]), onProgress, signal, stallMs, { url, method: 'PUT' });
const receipt = (JSON.parse(text) as { receipt?: string }).receipt;
if (!receipt) throw new Error('文件服务没有返回回执');
return receipt;
}
/**
* 打开一个条目自第 from 帧起的内容(下载、边下边播共用)。条目的帧在文件服务上就带着票据去那里取,
* 否则走主后端原来的接口(票据接口对本地条目回答 local,不计次,计次由原来的接口负责)。
*/
export async function openItemFrames(resource: SavedResource, session: ChannelSession, itemId: string, from: number, signal?: AbortSignal): Promise<Response> {
if (resource.kind === 'channel' && await filesEnabled()) {
const grant = await requestGrant(resource, session, itemId, 'get', from);
if (grant.mode === 'remote')
return externalFetch(`${grant.url!.replace(/\/$/, '')}/f/${channelHex(resource.id)}/${itemHex(itemId)}?from=${from}`, { 'X-WD-Ticket': grant.ticket! }, signal);
}
const path = resource.kind === 'share' ? `/shares/${resource.id}/items/${itemId}/frames?from=${from}` : `/channels/${resource.id}/items/${itemId}/frames?from=${from}`;
const role = resource.kind === 'share' && resource.state === 'draft' ? 'owner' : session.can('read') ? 'read' : 'owner';
return apiFetch(path, { headers: session.authHeaders(role), ...(signal ? { signal } : {}) });
}
@@ -1,4 +1,4 @@
import type { AccountProfile } from '../generated/api'; import type { AccountProfile } from '@/api/generated';
export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
export interface SigningKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } export interface SigningKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
/** Account vault v2: credentials and cached metadata, encrypted before transport. */ /** Account vault v2: credentials and cached metadata, encrypted before transport. */
+43
View File
@@ -0,0 +1,43 @@
import type { ChannelInfo } from './types';
/**
* 调用者在当前空间里能做什么。取值全部来自后端(ChannelInfo 里的 can_* 字段),这里只负责:
* - 没拿到空间信息时一律不能;
* - 把「删除」这种要结合条目作者的判断拼成一个入口。
* 组件只通过这里读能力,不要再比较角色、写 isOwner 之类的判断。
*/
export interface Capabilities {
/** 只用于展示(例如「所有者」标签),不用来决定能不能做某事 */
readonly isOwner: boolean;
readonly canUpload: boolean;
readonly canManageStructure: boolean;
readonly canCreateChannel: boolean;
readonly canManageFolders: boolean;
readonly canInvite: boolean;
readonly canEditSettings: boolean;
readonly canManageRoles: boolean;
readonly canRemoveMembers: boolean;
/** 能不能删这条内容:作者是不是自己由调用方告诉我们,规则由后端的两个标志决定 */
canDelete(isMine: boolean): boolean;
}
const NONE: Capabilities = {
isOwner: false, canUpload: false, canManageStructure: false, canCreateChannel: false, canManageFolders: false, canInvite: false,
canEditSettings: false, canManageRoles: false, canRemoveMembers: false, canDelete: () => false,
};
export function capabilitiesOf(channel: ChannelInfo | undefined): Capabilities {
if (!channel) return NONE;
return {
isOwner: channel.isOwner,
canUpload: channel.canUpload ?? false,
canManageStructure: channel.canManageStructure ?? false,
canCreateChannel: channel.canCreateChannel,
canManageFolders: channel.canManageFolders,
canInvite: channel.canInvite,
canEditSettings: channel.canEditSettings,
canManageRoles: channel.canManageRoles,
canRemoveMembers: channel.canRemoveMembers,
canDelete: (isMine) => channel.canDeleteAny || (isMine && channel.canDeleteOwn),
};
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { isPoster, POSTER_MAX_CHARS } from './poster';
describe('视频封面格式', () => {
it('只接受小的 JPEG 或 WebP data URL', () => {
expect(isPoster('data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ==')).toBe(true);
expect(isPoster('data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/v89WAAAAA==')).toBe(true);
});
it('拒绝别的格式和不是 data URL 的内容(封面来自别的成员,不能信)', () => {
expect(isPoster('data:image/png;base64,AAAA')).toBe(false);
expect(isPoster('data:image/gif;base64,AAAA')).toBe(false);
expect(isPoster('data:image/svg+xml;base64,AAAA')).toBe(false);
expect(isPoster('https://example.com/x.jpg')).toBe(false);
expect(isPoster('javascript:alert(1)')).toBe(false);
expect(isPoster('data:image/jpeg;base64,AAAA"onerror="x')).toBe(false);
expect(isPoster(undefined)).toBe(false);
expect(isPoster(42)).toBe(false);
});
it('拒绝超过上限的(目录条目是个小控制帧,放不下)', () => {
expect(isPoster(`data:image/jpeg;base64,${'A'.repeat(POSTER_MAX_CHARS)}`)).toBe(false);
});
});
+10
View File
@@ -0,0 +1,10 @@
/**
* 视频封面:上传时在浏览器里截一帧,缩小后以 data URL 的形式随文件目录(已加密)同步。
* 目录条目是个小控制帧(明文上限 16 KB,服务端按档位收),所以封面要尽量清晰、又必须小:
* 优先用 WebP(同样大小画质比 JPEG 好得多),浏览器编不了 WebP 就用 JPEG。
* 收到别人发来的封面要严格校验格式和大小。
*/
export const POSTER_MAX_CHARS = 12_000;
const POSTER_FORMAT = /^data:image\/(jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/;
export const isPoster = (value: unknown): value is string => typeof value === 'string' && value.length <= POSTER_MAX_CHARS && POSTER_FORMAT.test(value);
@@ -1,4 +1,4 @@
import type { DefaultsBody } from './generated/api'; import type { DefaultsBody } from '@/api/generated';
import type { CreateOptions } from './types'; import type { CreateOptions } from './types';
export function creationOptions(defaults: DefaultsBody): CreateOptions { export function creationOptions(defaults: DefaultsBody): CreateOptions {
@@ -1,4 +1,4 @@
import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceRole, WorkspaceFolder, WorkspaceState, AccountProfile } from './generated/api'; import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceRole, WorkspaceFolder, WorkspaceState, AccountProfile } from '@/api/generated';
export type ConnectionState = 'online' | 'reconnecting' | 'offline'; export type ConnectionState = 'online' | 'reconnecting' | 'offline';
export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none'; export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none';
@@ -17,7 +17,16 @@ export interface ChannelInfo {
/** 空间日志的最新序号,用来判断别的空间有没有新动静 */ /** 空间日志的最新序号,用来判断别的空间有没有新动静 */
lastSeq?: number; lastSeq?: number;
mode: ChannelMode; mode: ChannelMode;
role: 'owner' | 'member'; /** 调用者在这个空间里的能力:全部由后端给出,前端只读不算 */
isOwner: boolean;
canCreateChannel: boolean;
canManageFolders: boolean;
canInvite: boolean;
canEditSettings: boolean;
canManageRoles: boolean;
canRemoveMembers: boolean;
canDeleteAny: boolean;
canDeleteOwn: boolean;
maxMembers: number; maxMembers: number;
membersUsed?: number; membersUsed?: number;
membersCanUpload?: boolean; membersCanUpload?: boolean;
@@ -33,6 +42,11 @@ export interface ChannelInfo {
canManageStructure?: boolean; canManageStructure?: boolean;
} }
/** 文件服务公布的选项:创建频道时可以分配的文件空间档位(字节) */
export interface FileDomainOptions { quota_choices: number[]; default_quota: number }
/** 频道文件域的容量和已用字节(由文件服务统计) */
export interface FileDomainUsage { quota: number; used: number }
/** 新建空间的选项(protocol §12 设置的子集) */ /** 新建空间的选项(protocol §12 设置的子集) */
export interface CreateOptions { export interface CreateOptions {
joinPolicy?: 'open' | 'invite'; joinPolicy?: 'open' | 'invite';
@@ -46,8 +60,8 @@ export interface CreateOptions {
} }
export interface FileUploadOptions { export interface FileUploadOptions {
/** 单帧文件可随空间同步,加入者能立即预览;多帧文件仍由空间上限保护。 */ /** 视频封面(很小的 JPEG data URL),浏览器里截的,随文件目录同步 */
directPreview: boolean; poster?: string;
maxDownloads: number; maxDownloads: number;
} }
@@ -131,6 +145,8 @@ export interface FileItem extends ItemBase {
remaining: number | null; remaining: number | null;
preview: PreviewKind; preview: PreviewKind;
gated?: boolean; gated?: boolean;
/** 视频封面,见 domain/poster */
poster?: string;
/** announce-only placeholder; omit thread/folder/name until explicit item read. */ /** announce-only placeholder; omit thread/folder/name until explicit item read. */
metadataHidden?: boolean; metadataHidden?: boolean;
/** /**
@@ -9,6 +9,8 @@ export interface PublishedFile {
size: number; size: number;
ts: number; ts: number;
gated: boolean; gated: boolean;
/** 视频封面(很小的 JPEG data URL),见 domain/poster */
poster?: string;
} }
export type ResourceControl = { op: 'file.published'; item: PublishedFile }; export type ResourceControl = { op: 'file.published'; item: PublishedFile };
export const WORKSPACE_APP = 'webdrop.workspace'; export const WORKSPACE_APP = 'webdrop.workspace';
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet'; import { Sheet } from '@/ui/Sheet';
import { AccountSettings } from './AccountSettings'; import { AccountSettings } from './AccountSettings';
@@ -1,16 +1,16 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'; import { useEffect, useRef, useState, type FormEvent } from 'react';
import { accountApi } from '@/core-bridge/account'; import { accountApi } from '@/services/account';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { useChannels } from '@/store/channels'; import { useChannels } from '@/store/space/channels';
import { useShares } from '@/store/shares'; import { useShares } from '@/store/share/shares';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import styles from './AccountPanel.module.css'; import styles from './AccountPanel.module.css';
import { navigate, channelPath } from '@/app/navigation'; import { navigate, channelPath } from '@/app/navigation';
import { passkeysAvailable } from '@/core-bridge/passkeys'; import { passkeysAvailable } from '@/services/passkeys';
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
import { errorMessage } from '@/core-bridge/errors'; import { errorMessage } from '@/api/errors';
import { AvatarEditor, type AvatarPick } from './AvatarEditor'; import { AvatarEditor, type AvatarPick } from '@/features/avatar/AvatarEditor';
import { checkAvatarSource } from './avatarSource'; import { checkAvatarSource } from '@/features/avatar/avatarSource';
/** 未登录时的登录 / 注册 / 解锁。登录后的账号设置见 AccountSettings */ /** 未登录时的登录 / 注册 / 解锁。登录后的账号设置见 AccountSettings */
export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'auth' }) { export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'auth' }) {
@@ -1,6 +1,6 @@
import { AccountPanel } from './AccountPanel'; import { AccountPanel } from './AccountPanel';
import { ThemeMenu } from '@/ui/ThemeMenu'; import { ThemeMenu } from '@/ui/ThemeMenu';
import styles from './Home.module.css'; import styles from '@/features/home/Home.module.css';
export function AccountScreen() { export function AccountScreen() {
return <div className={styles.page}><div className={styles.appearance}><ThemeMenu /></div><main className={`${styles.main} ${styles.authMain}`}> return <div className={styles.page}><div className={styles.appearance}><ThemeMenu /></div><main className={`${styles.main} ${styles.authMain}`}>
<header className={styles.authIntro}> <header className={styles.authIntro}>
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { accountApi, type AccountInvitation } from '@/core-bridge/account'; import { accountApi, type AccountInvitation } from '@/services/account';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { Icon, type IconName } from '@/ui/Icon'; import { Icon, type IconName } from '@/ui/Icon';
import { DeviceSettings } from './DeviceSettings'; import { DeviceSettings } from './DeviceSettings';
import { InvitationInbox } from './InvitationInbox'; import { InvitationInbox } from './InvitationInbox';
@@ -1,9 +1,9 @@
import { useEffect, useState, type KeyboardEvent } from 'react'; import { useEffect, useState, type KeyboardEvent } from 'react';
import { accountApi, type AccountDevice } from '@/core-bridge/account'; import { accountApi, type AccountDevice } from '@/services/account';
import { errorMessage } from '@/core-bridge/errors'; import { errorMessage } from '@/api/errors';
import { formatSeen } from '@/lib/format'; import { formatSeen } from '@/lib/text/format';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import styles from './AccountSettings.module.css'; import styles from './AccountSettings.module.css';
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { AccountInvitation } from '@/core-bridge/account'; import type { AccountInvitation } from '@/services/account';
import { acceptInvitation, invitationLabel } from '@/store/invitations'; import { acceptInvitation, invitationLabel } from '@/store/account/invitations';
import { formatDate } from '@/lib/format'; import { formatDate } from '@/lib/text/format';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import styles from './AccountSettings.module.css'; import styles from './AccountSettings.module.css';
@@ -1,12 +1,12 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'; import { useEffect, useRef, useState, type FormEvent } from 'react';
import { errorMessage } from '@/core-bridge/errors'; import { errorMessage } from '@/api/errors';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { AvatarPreview } from '../common/AvatarPreview'; import { AvatarPreview } from '@/features/avatar/AvatarPreview';
import { accountApi } from '@/core-bridge/account'; import { accountApi } from '@/services/account';
import { AvatarEditor, type AvatarPick } from './AvatarEditor'; import { AvatarEditor, type AvatarPick } from '@/features/avatar/AvatarEditor';
import { checkAvatarSource } from './avatarSource'; import { checkAvatarSource } from '@/features/avatar/avatarSource';
import styles from './AccountSettings.module.css'; import styles from './AccountSettings.module.css';
export function ProfileSettings() { export function ProfileSettings() {
@@ -1,8 +1,8 @@
import { useEffect, useState, type FormEvent } from 'react'; import { useEffect, useState, type FormEvent } from 'react';
import { bindPasskey, passkeyApi, passkeysAvailable } from '@/core-bridge/passkeys'; import { bindPasskey, passkeyApi, passkeysAvailable } from '@/services/passkeys';
import type { PasskeyInfoBody } from '@/core-bridge/generated/api'; import type { PasskeyInfoBody } from '@/api/generated';
import { formatDate } from '@/lib/format'; import { formatDate } from '@/lib/text/format';
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import styles from './AccountSettings.module.css'; import styles from './AccountSettings.module.css';
@@ -1,7 +1,7 @@
import { navigate } from '@/app/navigation'; import { navigate } from '@/app/navigation';
import { errorMessage } from '@/core-bridge/errors'; import { errorMessage } from '@/api/errors';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import styles from './AccountSettings.module.css'; import styles from './AccountSettings.module.css';
File renamed without changes.
File renamed without changes.
@@ -1,20 +1,21 @@
import { useCapabilities } from '@/store/space/useCapabilities';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types'; import type { ChannelInfo, ChannelSettingsPatch } from '@/domain/types';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon, type IconName } from '@/ui/Icon'; import { Icon, type IconName } from '@/ui/Icon';
import { Sheet } from '@/ui/Sheet'; import { Sheet } from '@/ui/Sheet';
import { api } from '@/core-bridge/instance'; import { api } from '@/services';
import { AvatarPreview } from '../common/AvatarPreview'; import { AvatarPreview } from '@/features/avatar/AvatarPreview';
import { AvatarEditor, type AvatarPick } from '../home/AvatarEditor'; import { AvatarEditor, type AvatarPick } from '@/features/avatar/AvatarEditor';
import { checkAvatarSource } from '../home/avatarSource'; import { checkAvatarSource } from '@/features/avatar/avatarSource';
import { errorMessage } from '@/core-bridge/errors'; import { errorMessage } from '@/api/errors';
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
import { RolesEditor } from './RolesEditor'; import { RolesEditor } from '@/features/channel/members/RolesEditor';
import styles from './ChannelSettingsSheet.module.css'; import styles from './ChannelSettingsSheet.module.css';
import { useServerSettings } from '@/store/server'; import { useServerSettings } from '@/store/app/server';
import type { ServerInfoBody } from '@/core-bridge/generated/api'; import type { ServerInfoBody } from '@/api/generated';
import { ttlLabel, withCurrent } from '@/lib/settingOptions'; import { ttlLabel, withCurrent } from '@/lib/space/settingOptions';
type Tab = 'basic' | 'content' | 'join' | 'roles'; type Tab = 'basic' | 'content' | 'join' | 'roles';
const TABS: { id: Tab; label: string; icon: IconName }[] = [ const TABS: { id: Tab; label: string; icon: IconName }[] = [
@@ -38,7 +39,8 @@ export function ChannelSettingsSheet(props: Props) {
return <SettingsForm {...props} info={info} />; return <SettingsForm {...props} info={info} />;
} }
function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) { function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) {
const owner = channel.role === 'owner'; const caps = useCapabilities();
const owner = caps.canEditSettings;
const [name, setName] = useState(channel.name ?? ''); const [name, setName] = useState(channel.name ?? '');
const [maxMembers, setMaxMembers] = useState(channel.maxMembers); const [maxMembers, setMaxMembers] = useState(channel.maxMembers);
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!); const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!);
@@ -91,7 +93,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
<Sheet title="空间设置" onClose={onClose} wide={tab === 'roles' ? 'xl' : true}> <Sheet title="空间设置" onClose={onClose} wide={tab === 'roles' ? 'xl' : true}>
<div className={`${styles.layout} ${tab === 'roles' ? styles.tall : ''}`}> <div className={`${styles.layout} ${tab === 'roles' ? styles.tall : ''}`}>
<div className={styles.tabs} role="tablist" aria-orientation="vertical" aria-label="空间设置分类"> <div className={styles.tabs} role="tablist" aria-orientation="vertical" aria-label="空间设置分类">
{TABS.filter((item) => owner || item.id !== 'roles').map((item) => <button key={item.id} type="button" role="tab" aria-selected={tab === item.id} onClick={() => setTab(item.id)}> {TABS.filter((item) => caps.canManageRoles || item.id !== 'roles').map((item) => <button key={item.id} type="button" role="tab" aria-selected={tab === item.id} onClick={() => setTab(item.id)}>
<Icon name={item.icon} size={18} /><span>{item.label}</span></button>)} <Icon name={item.icon} size={18} /><span>{item.label}</span></button>)}
</div> </div>
<div className={styles.content}> <div className={styles.content}>
@@ -1,55 +0,0 @@
.bar {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
}
.input {
min-height: 36px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
}
.input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.chips {
display: flex;
gap: var(--space-1);
overflow-x: auto;
scrollbar-width: none;
}
.chip {
flex: none;
min-height: 32px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: transparent;
color: var(--text-dim);
cursor: pointer;
}
.chip[aria-checked="true"] {
border-color: var(--accent);
background: var(--accent);
color: var(--on-accent);
}
.chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (pointer: coarse) {
.chip {
min-height: 40px;
}
}
-49
View File
@@ -1,49 +0,0 @@
import type { ItemFilter, KindFilter } from '@/lib/filter';
import styles from './FilterBar.module.css';
const KINDS: { value: KindFilter; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'file', label: '文件' },
{ value: 'text', label: '文本' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
{ value: 'archive', label: '压缩包' },
];
interface Props {
filter: ItemFilter;
searchOpen: boolean;
onChange: (f: ItemFilter) => void;
}
/** 类型分组常驻;搜索框由顶栏的放大镜展开 */
export function FilterBar({ filter, searchOpen, onChange }: Props) {
return (
<div className={styles.bar}>
{searchOpen ? (
<input
className={styles.input}
type="search"
autoFocus
placeholder="搜索文本内容或文件名"
value={filter.query}
onChange={(e) => onChange({ ...filter, query: e.target.value })}
/>
) : null}
<div className={styles.chips} role="radiogroup" aria-label="类型">
{KINDS.map((k) => (
<button
key={k.value}
type="button"
role="radio"
aria-checked={filter.kind === k.value}
className={styles.chip}
onClick={() => onChange({ ...filter, kind: k.value })}
>
{k.label}
</button>
))}
</div>
</div>
);
}
@@ -1,16 +0,0 @@
.list {
margin: 0;
padding: 0;
list-style: none;
}
.list > li {
content-visibility: auto;
contain-intrinsic-size: auto 96px;
}
.empty {
margin: 0;
padding: var(--space-6) var(--space-4);
color: var(--text-dim);
}
-30
View File
@@ -1,30 +0,0 @@
import { CompactRow } from '@/features/item/CompactRow';
import styles from './ItemList.module.css';
interface Props {
ids: string[];
selectedId?: string;
/** 桌面端才有多选 */
checked?: ReadonlySet<string>;
empty: string;
onSelect: (id: string) => void;
onToggle?: (id: string) => void;
}
export function ItemList({ ids, selectedId, checked, empty, onSelect, onToggle }: Props) {
if (ids.length === 0) return <p className={styles.empty}>{empty}</p>;
return (
<ul className={styles.list}>
{ids.map((id) => (
<CompactRow
key={id}
id={id}
selected={id === selectedId}
checked={checked?.has(id)}
onSelect={onSelect}
onToggle={onToggle}
/>
))}
</ul>
);
}
@@ -1,76 +0,0 @@
.card {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3) var(--space-4) var(--space-4);
border-bottom: 1px solid var(--line);
background: linear-gradient(to bottom, var(--surface-2), transparent);
}
.label {
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
}
.open {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
display: -webkit-box;
overflow: hidden;
font-size: 16px;
font-weight: 600;
overflow-wrap: anywhere;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sub {
color: var(--text-dim);
font-size: 12px;
}
.actions {
display: flex;
gap: var(--space-2);
}
.actions > * {
flex: 1;
min-height: 44px;
}
.bar {
height: 4px;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--busy-soft);
}
.fill {
height: 100%;
background: var(--progress-gradient);
transition: width 0.25s linear;
}
/* 横屏手机高度很小,首屏留给列表 */
@media (height < 500px) {
.card {
display: none;
}
}
-59
View File
@@ -1,59 +0,0 @@
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
import { useDownload } from '@/features/item/download';
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
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';
import { CopyButton } from '@/ui/CopyButton';
import styles from './LatestCard.module.css';
interface Props {
id: string;
onOpen: (id: string) => void;
}
/** 手机首屏:最新一项放大,拇指区一键取走 */
export function LatestCard({ id, onOpen }: Props) {
const item = useWorkspace((s) => s.byId[id]);
const download = useDownload();
if (!item) return null;
const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
const inline = inlineKindOf(item);
const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
return (
<section className={styles.card} aria-label="最新">
<div className={styles.label}>最新</div>
<button type="button" className={styles.open} onClick={() => onOpen(id)}>
{inline ? null : <Tile item={item} size={56} />}
<span className={styles.text}>
{inline === 'text' ? null : <span className={styles.title}>{titleOf(item)}</span>}
<span className={styles.sub}>
{senderLabel(item)} · {formatTime(item.ts)} · {formatSize(size)}
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
</span>
</span>
</button>
{inline ? <InlineContent item={item} onOpen={() => onOpen(id)} /> : null}
{receiving !== null ? (
<div className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
<div className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
</div>
) : (
<div className={styles.actions}>
{item.kind === 'text' ? (
<CopyButton text={item.text} variant="primary" />
) : (
<>
{item.preview !== 'none' ? <Button variant="primary" icon="eye" onClick={() => onOpen(id)}>预览</Button> : null}
<Button variant="primary" icon="download" onClick={() => void download(item)}>下载</Button>
</>
)}
</div>
)}
</section>
);
}
@@ -1,35 +0,0 @@
.bar {
position: fixed;
bottom: calc(88px + env(safe-area-inset-bottom));
left: 50%;
z-index: 30;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-1) var(--space-2) var(--space-1) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-pop);
translate: -50% 0;
animation: bar-in 0.15s ease-out;
}
@media (width >= 900px) {
.bar {
bottom: var(--space-6);
}
}
@keyframes bar-in {
from {
opacity: 0;
translate: -50% 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bar {
animation: none;
}
}
-12
View File
@@ -1,12 +0,0 @@
import { Button } from '@/ui/Button';
import styles from './UndoBar.module.css';
/** 删除后 5 秒内可撤销(ui-design §8) */
export function UndoBar({ onUndo }: { onUndo: () => void }) {
return (
<div className={styles.bar} role="status">
<span>已删除</span>
<Button variant="ghost" icon="undo" onClick={onUndo}>撤销</Button>
</div>
);
}
+44 -43
View File
@@ -1,45 +1,43 @@
import { useCapabilities } from '@/store/space/useCapabilities';
import { lazy, Suspense, useCallback, useEffect, useState, type CSSProperties, type DragEvent } from 'react'; import { lazy, Suspense, useCallback, useEffect, useState, type CSSProperties, type DragEvent } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { channelPath, navigate, sharePath } from '@/app/navigation'; import { channelPath, navigate, sharePath } from '@/app/navigation';
import { useDownload } from '@/features/item/download'; import { useDownload } from '@/features/item/download';
import { matchesFilter, isFiltering, NO_FILTER } from '@/lib/filter'; import { matchesFilter, isFiltering, NO_FILTER } from '@/lib/files/filter';
import { useGlobalPaste } from '@/lib/useGlobalPaste'; import { useGlobalPaste } from '@/lib/hooks/useGlobalPaste';
import { useMediaQuery } from '@/lib/useMediaQuery'; import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import type { FileUploadOptions } from '@/core-bridge/types'; import type { FileUploadOptions } from '@/domain/types';
import type { ClipboardPayload } from '@/lib/clipboard'; import type { ClipboardPayload } from '@/lib/platform/clipboard';
import { Sheet } from '@/ui/Sheet'; import { Sheet } from '@/ui/Sheet';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Drawer } from '@/ui/Drawer'; import { Drawer } from '@/ui/Drawer';
import { Icon, type IconName } from '@/ui/Icon'; import { Icon, type IconName } from '@/ui/Icon';
import { SpaceHeader, SpaceRail } from './SpaceRail'; import { SpaceHeader, SpaceRail } from '@/features/channel/navigation/SpaceRail';
import { MobileDrawer } from './MobileDrawer'; import { MobileDrawer } from '@/features/channel/navigation/MobileDrawer';
import { MobileTopBar } from './MobileTopBar'; import { MobileTopBar } from '@/features/channel/navigation/MobileTopBar';
import { useAccount } from '@/store/account'; import { ThreadSidebar } from '@/features/channel/navigation/ThreadSidebar';
import { permissionsOf } from '@/lib/roles'; import { ChatPane } from '@/features/channel/chat/ChatPane';
import { ThreadSidebar } from './ThreadSidebar'; import { useThreadReads } from '@/features/channel/navigation/useThreadReads';
import { ChatPane } from './ChatPane'; import { useFileDomainUsage, useFileService } from '@/features/channel/explorer/useFileDomain';
import { useThreadReads } from './useThreadReads'; import { FileExplorer } from '@/features/channel/explorer/FileExplorer';
import { FileExplorer } from './FileExplorer'; import { FilePaneResize } from '@/features/channel/explorer/FilePaneResize';
import { FilePaneResize } from './FilePaneResize'; import { InviteSheet } from '@/features/channel/members/InviteSheet';
import { InviteSheet } from './InviteSheet'; import { MembersPane } from '@/features/channel/members/MembersPane';
import { MembersPane } from './MembersPane'; import { PaneTabs, type SideTab } from '@/features/channel/navigation/PaneTabs';
import { PaneTabs, type SideTab } from './PaneTabs'; import { TopBar } from '@/features/channel/navigation/TopBar';
import { TopBar } from './TopBar'; import { FilterBar } from '@/features/channel/explorer/FilterBar';
import { FilterBar } from './FilterBar'; import { Notepad } from '@/features/channel/navigation/Notepad';
import { Notepad } from './Notepad'; import { SharePanel } from '@/features/channel/members/SharePanel';
import { SharePanel } from './SharePanel';
import { ChannelSettingsSheet } from './ChannelSettingsSheet'; import { ChannelSettingsSheet } from './ChannelSettingsSheet';
import { UploadOptionsSheet } from './UploadOptionsSheet'; import { UploadOptionsSheet } from '@/features/channel/explorer/UploadOptionsSheet';
import styles from './Workspace.module.css'; import styles from './Workspace.module.css';
import { useServerSettings } from '@/store/server';
const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet')); const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet'));
const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet }))); const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) { export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) {
const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)'); const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)');
const { info: serverInfo } = useServerSettings();
const channel = useWorkspace((s) => s.channel); const channel = useWorkspace((s) => s.channel);
const threads = useWorkspace((s) => s.threads); const threads = useWorkspace((s) => s.threads);
const groups = useWorkspace((s) => s.groups); const groups = useWorkspace((s) => s.groups);
@@ -80,21 +78,25 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const [fileWidth, setFileWidth] = useState<number>(); const [fileWidth, setFileWidth] = useState<number>();
const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>(); const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>();
const locked = useWorkspace((s) => s.lockedThreads); const locked = useWorkspace((s) => s.lockedThreads);
// 没有密钥的频道不能打开:优先用选中的,其次「常规」,再其次最新的可用频道 const defaultThreadId = useWorkspace((s) => s.defaultThreadId);
// 没有密钥的频道不能打开:优先用选中的,其次空间的默认频道(后端指定),再其次最新的可用频道
const usable = threads.filter((thread) => !locked.includes(thread.id)); const usable = threads.filter((thread) => !locked.includes(thread.id));
const activeThread = usable.find((thread) => thread.id === threadId) ?? usable.find((thread) => thread.name === '常规') ?? usable[usable.length - 1]; const activeThread = usable.find((thread) => thread.id === threadId) ?? usable.find((thread) => thread.id === defaultThreadId) ?? usable[usable.length - 1];
const activeThreadId = activeThread?.id; const activeThreadId = activeThread?.id;
const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId); const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId);
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined; const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
const enabled = Boolean(activeThreadId && channel?.canUpload); // 文件服务现在能不能用、创建频道时可选的文件空间档位(文件服务公布)
const owner = channel?.role === 'owner'; const { status: fileService, options: fileOptions } = useFileService(channel?.id);
const roles = useWorkspace((s) => s.roles); const caps = useCapabilities();
const self = useAccount((s) => s.username); // 服务端没有开启文件服务时不能上传(连不上只是暂时的,上传会排队,恢复后继续)
const perms = permissionsOf(roles, self ?? undefined, owner); const enabled = Boolean(activeThreadId && caps.canUpload && fileService !== 'disabled');
const files = useWorkspace(useShallow((s) => s.order.filter((id) => { const files = useWorkspace(useShallow((s) => s.order.filter((id) => {
const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId
&& (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter); && (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter);
}))); })));
// 文件域的选项和用量由文件服务提供;传完或删除一个文件后(完成的文件数变化)重新读取用量
const finishedFiles = useWorkspace((s) => s.order.filter((id) => { const item = s.byId[id]; return item?.kind === 'file' && item.threadId === activeThreadId && item.state.kind === 'complete'; }).length);
const fileUsage = useFileDomainUsage(activeThreadId, finishedFiles);
const messages = useWorkspace(useShallow((s) => s.order.filter((id) => { const messages = useWorkspace(useShallow((s) => s.order.filter((id) => {
const item = s.byId[id]; return Boolean(item && item.threadId === activeThreadId && !(item.kind === 'file' && item.metadataHidden)); const item = s.byId[id]; return Boolean(item && item.threadId === activeThreadId && !(item.kind === 'file' && item.metadataHidden));
}))); })));
@@ -137,36 +139,35 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const channelId = channel?.id; const channelId = channel?.id;
useEffect(() => { setDrawerOpen(false); }, [channelId]); useEffect(() => { setDrawerOpen(false); }, [channelId]);
const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />; const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} unread={unread} locked={locked} enabled={Boolean(channel?.canManageStructure) || perms.has('structure.manage')} canCreate={owner} owner={owner} onHistory={setChannelHistory} const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} unread={unread} locked={locked} enabled={caps.canManageStructure} canCreate={caps.canCreateChannel} canManageHistory={caps.canEditSettings} onHistory={setChannelHistory}
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread} onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} quotaChoices={fileOptions?.quota_choices ?? []} defaultQuota={fileOptions?.default_quota} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />; onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />;
const navigation = <><SpaceHeader />{threadSidebar}{desktop ? <Notepad /> : null}</>; const navigation = <><SpaceHeader />{threadSidebar}{desktop ? <Notepad /> : null}</>;
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} lastSeen={marker} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />; const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} lastSeen={marker} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
const explorer = <> const explorer = <>
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} /> <FileExplorer key={activeThreadId} fileService={fileService} filterBar={<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'}
<FileExplorer key={activeThreadId} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'} fileBytes={fileUsage?.used ?? 0} maxFileBytes={fileUsage?.quota}
fileBytes={activeThread?.fileBytes ?? 0} maxFileBytes={serverInfo?.limits.max_workspace_channel_bytes}
folders={activeFolders} folderId={activeFolderId} ids={files} folders={activeFolders} folderId={activeFolderId} ids={files}
selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} structureEnabled={Boolean(activeThreadId && channel?.canManageStructure)} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked} selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} structureEnabled={Boolean(activeThreadId && caps.canManageFolders)} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked}
onFiles={(incoming, target) => requestFiles(incoming, { folderId: target })} onFiles={(incoming, target) => requestFiles(incoming, { folderId: target })}
onCreateFolder={(name) => activeThreadId ? createFolder(activeThreadId, name, activeFolderId) : Promise.reject(new Error('请先选择频道'))} onCreateFolder={(name) => activeThreadId ? createFolder(activeThreadId, name, activeFolderId) : Promise.reject(new Error('请先选择频道'))}
onRenameFolder={(id, name) => activeThreadId ? renameFolder(id, activeThreadId, name) : Promise.reject(new Error('请先选择频道'))} onRenameFolder={(id, name) => activeThreadId ? renameFolder(id, activeThreadId, name) : Promise.reject(new Error('请先选择频道'))}
onDownloadItems={downloadItems} onDeleteItems={deleteFiles} onMoveItems={async (ids, target) => { if (activeThreadId) { await moveItems(ids, activeThreadId, target); setChecked(new Set()); setSelectedId(undefined); } }} /> 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} {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} canRemove={perms.has('members.remove')} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />; const members = <MembersPane refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
function goMobile(view: 'chat' | 'files' | 'members') { setMobileView(view); setDrawerOpen(false); } function goMobile(view: 'chat' | 'files' | 'members') { setMobileView(view); setDrawerOpen(false); }
const moreRows: { icon: IconName; label: string; run: () => void }[] = [ const moreRows: { icon: IconName; label: string; run: () => void }[] = [
{ icon: 'settings', label: '空间设置', run: () => setSettingsOpen(true) }, { icon: 'settings', label: '空间设置', run: () => setSettingsOpen(true) },
{ icon: 'share', label: '分享', run: () => setPanelOpen(true) }, { icon: 'share', label: '分享', run: () => setPanelOpen(true) },
...(owner ? [{ icon: 'plus' as const, label: '邀请成员', run: () => setInviteOpen(true) }] : []), ...(caps.canInvite ? [{ icon: 'plus' as const, label: '邀请成员', run: () => setInviteOpen(true) }] : []),
]; ];
const mobileTitle = mobileView === 'files' ? '文件' : mobileView === 'members' ? '成员' : `# ${activeThread?.name ?? '选择频道'}`; const mobileTitle = mobileView === 'files' ? '文件' : mobileView === 'members' ? '成员' : `# ${activeThread?.name ?? '选择频道'}`;
function openMembers() { if (desktop) setSideTab('members'); else setMobileView('members'); } function openMembers() { if (desktop) setSideTab('members'); else setMobileView('members'); }
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined} return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)} onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}> onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}>
{desktop ? <TopBar onBack={() => navigate('/')} onOpenMembers={openMembers} onInvite={owner ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} /> : <MobileTopBar view={mobileView} title={mobileTitle} searchOpen={searchOpen} filtering={isFiltering(filter)} onMenu={() => setDrawerOpen(true)} onBack={() => setMobileView('chat')} {desktop ? <TopBar onBack={() => navigate('/')} onOpenMembers={openMembers} onInvite={caps.canInvite ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} /> : <MobileTopBar view={mobileView} title={mobileTitle} searchOpen={searchOpen} filtering={isFiltering(filter)} onMenu={() => setDrawerOpen(true)} onBack={() => setMobileView('chat')}
onToggleSearch={() => { setSearchOpen((value) => !value); setMobileView('files'); }} onMore={() => setMoreOpen(true)} />} onToggleSearch={() => { setSearchOpen((value) => !value); setMobileView('files'); }} onMore={() => setMoreOpen(true)} />}
{desktop ? <><aside className={styles.rail}>{rail}</aside><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane><PaneTabs tab={sideTab} memberCount={channel?.membersUsed} onTab={setSideTab} />{sideTab === 'files' ? explorer : members}</aside></> {desktop ? <><aside className={styles.rail}>{rail}</aside><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane><PaneTabs tab={sideTab} memberCount={channel?.membersUsed} onTab={setSideTab} />{sideTab === 'files' ? explorer : members}</aside></>
: <main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main>} : <main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main>}
@@ -174,7 +175,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
{!desktop && drawerOpen ? <Drawer label="侧边栏" onClose={() => setDrawerOpen(false)}> {!desktop && drawerOpen ? <Drawer label="侧边栏" onClose={() => setDrawerOpen(false)}>
<MobileDrawer rail={{ id: channel?.id, onCreate: onCreateChannel, onJoin: () => setScanOpen(true) }} <MobileDrawer rail={{ id: channel?.id, onCreate: onCreateChannel, onJoin: () => setScanOpen(true) }}
threads={threadSidebar} onFiles={() => goMobile('files')} onMembers={() => goMobile('members')} threads={threadSidebar} onFiles={() => goMobile('files')} onMembers={() => goMobile('members')}
onInvite={owner ? () => setInviteOpen(true) : undefined} onShare={() => setPanelOpen(true)} onHome={() => navigate('/')} /> onInvite={caps.canInvite ? () => setInviteOpen(true) : undefined} onShare={() => setPanelOpen(true)} onHome={() => navigate('/')} />
</Drawer> : null} </Drawer> : null}
{!desktop && moreOpen ? <Sheet title="更多" onClose={() => setMoreOpen(false)}>{moreRows.map((row) => <Button key={row.label} variant="ghost" onClick={() => { setMoreOpen(false); row.run(); }}><Icon name={row.icon} size={18} />{row.label}</Button>)}</Sheet> : null} {!desktop && moreOpen ? <Sheet title="更多" onClose={() => setMoreOpen(false)}>{moreRows.map((row) => <Button key={row.label} variant="ghost" onClick={() => { setMoreOpen(false); row.run(); }}><Icon name={row.icon} size={18} />{row.label}</Button>)}</Sheet> : null}
{error ? <div className={styles.error} role="alert">{error}</div> : null} {error ? <div className={styles.error} role="alert">{error}</div> : null}
@@ -24,6 +24,44 @@
cursor: pointer; cursor: pointer;
} }
/* 有封面的播放入口:封面铺满,标签压在底部 */
.cover {
position: relative;
display: block;
width: 100%;
padding: 0;
overflow: hidden;
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface-2);
cursor: pointer;
}
.coverImage {
display: block;
width: 100%;
max-height: 320px;
object-fit: cover;
}
.coverLabel {
position: absolute;
inset: auto 0 0;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--scrim);
color: var(--on-accent);
font-size: 0.8125rem;
}
.posterLabel {
display: flex;
align-items: center;
gap: var(--space-2);
}
.picture { .picture {
display: block; display: block;
padding: 0; padding: 0;
@@ -1,18 +1,18 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { FileItem } from '@/core-bridge/types'; import type { FileItem } from '@/domain/types';
import { formatSize } from '@/lib/format'; import { formatSize } from '@/lib/text/format';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { ArchivePreview } from '../item/previews/ArchivePreview'; import { ArchivePreview } from '@/features/item/previews/ArchivePreview';
import { useBlobUrl } from '../item/useBlobUrl'; import { useBlobUrl } from '@/features/item/hooks/useBlobUrl';
import { useMediaUrl } from '../item/useMediaUrl'; import { useMediaUrl } from '@/features/item/hooks/useMediaUrl';
import styles from './ChatAttachment.module.css'; import styles from './ChatAttachment.module.css';
/** 聊天里自动展开的大小上限:再大的只显示文件卡片,点开预览 */ /** 聊天里自动展开的大小上限:再大的只显示文件卡片,点开预览 */
const MB = 1024 * 1024; const MB = 1024 * 1024;
/** 不限制次数、不超过这个大小的,滚动到附近就自动加载;更大的显示占位,点一下才加载(整个文件要先解密到内存) */ /** 不限制次数、不超过这个大小的,滚动到附近就自动加载;更大的显示占位,点一下才加载(整个文件要先解密到内存) */
const AUTO = { image: 30 * MB, video: 25 * MB, audio: 25 * MB, archive: 50 * MB, text: 128 * 1024 } as const; const AUTO = { image: 30 * MB, video: 25 * MB, audio: 25 * MB, archive: 50 * MB, text: 128 * 1024 } as const;
/** 再大就不在聊天里展开了,只留文件卡片 */ /** 再大就不在聊天里展开了,只留文件卡片。视频和音频是边下边播(Range),大小不是问题,不设上限;自动加载仍只限小文件(AUTO),大的点一下再播 */
const MAX = { image: 100 * MB, video: 500 * MB, audio: 500 * MB, archive: 200 * MB, text: 128 * 1024 } as const; const MAX = { image: 100 * MB, video: Number.POSITIVE_INFINITY, audio: Number.POSITIVE_INFINITY, archive: 200 * MB, text: 128 * 1024 } as const;
/** 滚动到附近才加载,一屏几十张图时不会一下子全部解密 */ /** 滚动到附近才加载,一屏几十张图时不会一下子全部解密 */
function useNearViewport<T extends HTMLElement>(): [React.RefObject<T | null>, boolean] { function useNearViewport<T extends HTMLElement>(): [React.RefObject<T | null>, boolean] {
@@ -40,7 +40,9 @@ function Gate({ item, label, children }: { item: FileItem; label: string; childr
return <div ref={ref} className={styles.slot}> return <div ref={ref} className={styles.slot}>
{free && auto ? (near ? children() : <div className={styles.placeholder} />) {free && auto ? (near ? children() : <div className={styles.placeholder} />)
: asked ? children() : asked ? children()
: <button type="button" className={styles.poster} onClick={() => setAsked(true)}><Icon name="eye" size={20} /><span>{label} · {formatSize(item.size)}{free ? '' : ` · 消耗 1 次${typeof item.remaining === 'number' ? `(剩 ${item.remaining} 次)` : ''}`}</span></button>} : <button type="button" className={item.poster ? styles.cover : styles.poster} onClick={() => setAsked(true)}>
{item.poster ? <img className={styles.coverImage} src={item.poster} alt="" /> : null}
<span className={item.poster ? styles.coverLabel : styles.posterLabel}><Icon name="eye" size={20} /><span>{label} · {formatSize(item.size)}{free ? '' : ` · 消耗 1 次${typeof item.remaining === 'number' ? `(剩 ${item.remaining} 次)` : ''}`}</span></span></button>}
</div>; </div>;
} }
@@ -1,25 +1,26 @@
import { useCapabilities } from '@/store/space/useCapabilities';
import { useState, type MouseEvent } from 'react'; import { useState, type MouseEvent } from 'react';
import { isLocalId, useWorkspace } from '@/store/workspace'; import { isLocalId, useWorkspace } from '@/store/space/workspace';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { useDownload } from '@/features/item/download'; import { useDownload } from '@/features/item/download';
import { iconOf } from '@/features/item/summary'; import { iconOf } from '@/features/item/summary';
import { senderLabel } from '@/lib/sender'; import { senderLabel } from '@/lib/space/sender';
import { formatSize, formatTime } from '@/lib/format'; import { formatSize, formatTime } from '@/lib/text/format';
import { TransferProgress } from '@/features/item/TransferProgress'; import { TransferProgress } from '@/features/item/TransferProgress';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu'; import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
import { accountApi } from '@/core-bridge/account'; import { accountApi } from '@/services/account';
import { AvatarPreview } from '../common/AvatarPreview'; import { AvatarPreview } from '@/features/avatar/AvatarPreview';
import { ChatAttachment, hasInlinePreview } from './ChatAttachment'; import { ChatAttachment, hasInlinePreview } from './ChatAttachment';
import { NameWithBadges } from './RoleBadges'; import { NameWithBadges } from '@/features/channel/members/RoleBadges';
import styles from './ChatPane.module.css'; import styles from './ChatPane.module.css';
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void } interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props) { export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props) {
const item = useWorkspace((s) => s.byId[id]); const item = useWorkspace((s) => s.byId[id]);
const owner = useWorkspace((s) => s.channel?.role === 'owner'); const caps = useCapabilities();
const retry = useWorkspace((s) => s.retrySend); const retry = useWorkspace((s) => s.retrySend);
const cancelUpload = useWorkspace((s) => s.cancelUpload); const cancelUpload = useWorkspace((s) => s.cancelUpload);
const username = useAccount((s) => s.username); const username = useAccount((s) => s.username);
@@ -31,6 +32,8 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
const sender = item.author; const sender = item.author;
const name = senderLabel(item); const name = senderLabel(item);
const self = sender?.username !== undefined && sender.username === username; const self = sender?.username !== undefined && sender.username === username;
// 能不能删由后端的策略决定(所有者 / 删除策略),前端只按它显示;作者是自己才适用「只能删自己的」
const canDelete = caps.canDelete(self);
const local = isLocalId(id); const local = isLocalId(id);
const sending = local && item.state.kind === 'receiving'; const sending = local && item.state.kind === 'receiving';
const failed = local && item.state.kind === 'incomplete'; const failed = local && item.state.kind === 'incomplete';
@@ -43,7 +46,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
// 触屏没有悬停:点一下消息弹出操作菜单(桌面仍是悬停按钮) // 触屏没有悬停:点一下消息弹出操作菜单(桌面仍是悬停按钮)
const actions: MenuAction[] = [ const actions: MenuAction[] = [
...(item.kind === 'text' ? [{ label: '复制', icon: 'copy' as const, action: () => { void navigator.clipboard?.writeText(item.text).catch(() => undefined); } }] : []), ...(item.kind === 'text' ? [{ label: '复制', icon: 'copy' as const, action: () => { void navigator.clipboard?.writeText(item.text).catch(() => undefined); } }] : []),
...(owner || self ? [{ label: '删除', icon: 'trash' as const, action: () => onDelete(id) }] : []), ...(canDelete ? [{ label: '删除', icon: 'trash' as const, action: () => onDelete(id) }] : []),
]; ];
function tap(event: MouseEvent<HTMLElement>) { function tap(event: MouseEvent<HTMLElement>) {
if (!matchMedia('(hover: none)').matches || !actions.length || sending) return; if (!matchMedia('(hover: none)').matches || !actions.length || sending) return;
@@ -76,7 +79,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span> : 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} : item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
</div> </div>
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null} {canDelete ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
{previewing && sender?.avatar ? <AvatarPreview title={`${name} 的头像`} thumb={sender.avatar} load={() => accountApi.avatar(sender.username, 0, true)} onClose={() => setPreviewing(false)} /> : null} {previewing && sender?.avatar ? <AvatarPreview title={`${name} 的头像`} thumb={sender.avatar} load={() => accountApi.avatar(sender.username, 0, true)} onClose={() => setPreviewing(false)} /> : null}
{menu ? <ContextMenu point={menu} actions={actions} onClose={() => setMenu(undefined)} /> : null} {menu ? <ContextMenu point={menu} actions={actions} onClose={() => setMenu(undefined)} /> : null}
</article>; </article>;
@@ -1,9 +1,9 @@
import { Fragment, useEffect, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react'; import { Fragment, useEffect, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import type { Item } from '@/core-bridge/types'; import type { Item } from '@/domain/types';
import { formatDate } from '@/lib/format'; import { formatDate } from '@/lib/text/format';
import { ChatMessage } from './ChatMessage'; import { ChatMessage } from './ChatMessage';
import styles from './ChatPane.module.css'; import styles from './ChatPane.module.css';
@@ -1,6 +1,6 @@
import type { DragEvent, MouseEvent } from 'react'; import type { DragEvent, MouseEvent } from 'react';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format'; import { formatSize, formatTime, formatTransferProgress } from '@/lib/text/format';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { iconOf } from '@/features/item/summary'; import { iconOf } from '@/features/item/summary';
@@ -1,5 +1,5 @@
import type { DragEvent } from 'react'; import type { DragEvent } from 'react';
import type { FolderInfo } from '@/core-bridge/types'; import type { FolderInfo } from '@/domain/types';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import styles from './FileExplorer.module.css'; import styles from './FileExplorer.module.css';
@@ -1,10 +1,10 @@
import { senderLabel } from '@/lib/sender'; import { senderLabel } from '@/lib/space/sender';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { useBlobUrl } from '@/features/item/useBlobUrl'; import { useBlobUrl } from '@/features/item/hooks/useBlobUrl';
import { formatSize, formatTime } from '@/lib/format'; import { formatSize, formatTime } from '@/lib/text/format';
import { fitFloatingPoint } from '@/lib/explorer'; import { fitFloatingPoint } from '@/lib/files/explorer';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { iconOf } from '@/features/item/summary'; import { iconOf } from '@/features/item/summary';
import styles from './FileExplorer.module.css'; import styles from './FileExplorer.module.css';
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import type { FolderInfo } from '@/core-bridge/types'; import type { FolderInfo } from '@/domain/types';
import { Sheet } from '@/ui/Sheet'; import { Sheet } from '@/ui/Sheet';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import styles from './FileExplorer.module.css'; import styles from './FileExplorer.module.css';
@@ -7,8 +7,6 @@
background: var(--surface); background: var(--surface);
} }
.heading,
.toolbar,
.breadcrumb, .breadcrumb,
.batch, .batch,
.status, .status,
@@ -20,20 +18,99 @@
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.heading { /* 频道文件的头:一眼看出这是哪个频道的资源,以及用了多少文件空间 */
min-height: var(--bar-h); .channelHeader {
justify-content: space-between; display: flex;
font-size: 13px; align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border-bottom: 1px solid var(--line);
border-inline-start: 3px solid var(--accent);
background: var(--surface-2);
}
.channelBadge {
display: grid;
flex-shrink: 0;
place-items: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
background: var(--accent-soft);
color: var(--accent);
font-size: 16px;
font-weight: 700;
}
.channelTitle {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.channelTitle strong {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.channelTitle small {
color: var(--text-dim);
font-size: 11px;
}
.notice {
margin: 0;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line);
background: var(--warn-soft);
color: var(--text);
font-size: 12px;
}
.meter {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: flex-end;
gap: var(--space-1);
font-family: var(--font-mono);
font-size: 12px;
}
.meter em {
color: var(--text-dim);
font-style: normal;
}
.meterTrack {
width: 96px;
height: 4px;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--line);
}
.meterTrack span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--accent);
}
.meterTrack .meterFull {
background: var(--danger);
} }
.heading span,
.status, .status,
.empty span { .empty span {
color: var(--text-dim); color: var(--text-dim);
font-size: 12px; font-size: 12px;
} }
.toolbar,
.batch { .batch {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -215,7 +292,10 @@
} }
.empty { .empty {
margin: var(--space-4);
padding: var(--space-6); padding: var(--space-6);
border: 1px dashed var(--line-strong);
border-radius: var(--radius-md);
color: var(--text-dim); color: var(--text-dim);
text-align: center; text-align: center;
} }
@@ -229,44 +309,6 @@
margin-inline-start: auto; margin-inline-start: auto;
} }
.quota {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--line);
}
.quotaLabel,
.quotaRemaining {
display: flex;
justify-content: space-between;
color: var(--text-dim);
font-size: 11px;
}
.quotaTrack {
height: 4px;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--line);
}
.quotaTrack span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--accent);
}
.quotaTrack .quotaFull {
background: var(--danger);
}
.quotaRemaining {
justify-content: flex-end;
}
.edit input, .edit input,
.batch select { .batch select {
min-width: 0; min-width: 0;
@@ -1,6 +1,7 @@
import { useRef, useState, type FormEvent } from 'react'; import { useRef, useState, type FormEvent, type ReactNode } from 'react';
import type { FolderInfo } from '@/core-bridge/types'; import type { FileServiceStatus } from './useFileDomain';
import { useWorkspace } from '@/store/workspace'; import type { FolderInfo } from '@/domain/types';
import { useWorkspace } from '@/store/space/workspace';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { ExplorerFileRow } from './ExplorerFileRow'; import { ExplorerFileRow } from './ExplorerFileRow';
@@ -10,12 +11,16 @@ import { ExplorerMoveDialog } from './ExplorerMoveDialog';
import { useExplorerInteractions } from './useExplorerInteractions'; import { useExplorerInteractions } from './useExplorerInteractions';
import { ContextMenu } from '@/ui/ContextMenu'; import { ContextMenu } from '@/ui/ContextMenu';
import styles from './FileExplorer.module.css'; import styles from './FileExplorer.module.css';
import { formatSize } from '@/lib/format'; import { formatSize } from '@/lib/text/format';
interface Props { interface Props {
scope: string; scope: string;
threadId?: string; threadId?: string;
threadName: string; threadName: string;
/** 文件服务的状态:不可用时在头部下面说明 */
fileService?: FileServiceStatus;
/** 过滤条(类型筛选、搜索),放在工具栏下面 */
filterBar?: ReactNode;
fileBytes: number; fileBytes: number;
maxFileBytes?: number; maxFileBytes?: number;
folders: FolderInfo[]; folders: FolderInfo[];
@@ -73,15 +78,25 @@ export function FileExplorer(props: Props) {
finally { setBusy(false); } finally { setBusy(false); }
} }
return <section className={styles.explorer} aria-label="频道文件管理器" tabIndex={0} onKeyDown={interaction.keyDown}> return <section className={styles.explorer} aria-label="频道文件管理器" tabIndex={0} onKeyDown={interaction.keyDown}>
<header className={styles.heading}><strong>频道文件</strong><span>{props.threadName}</span></header> <header className={styles.channelHeader}>
<div className={styles.toolbar}> <span className={styles.channelBadge} aria-hidden="true">#</span>
<input type="file" multiple hidden ref={input} onChange={(event) => { if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} /> <div className={styles.channelTitle}>
<Button icon="share" disabled={!props.enabled} onClick={() => input.current?.click()}>上传文件</Button> <strong>{props.threadName}</strong>
<Button variant="ghost" icon="folder" disabled={!props.structureEnabled || busy} onClick={() => beginFolder()}>新建文件夹</Button> <small>频道文件 · {children.length} 个文件夹 · {ids.length} 个文件</small>
<Button variant="ghost" icon="eye" disabled={!props.selectedId} onClick={() => { if (props.selectedId) props.onOpen(props.selectedId); }}>预览</Button>
</div> </div>
<div className={styles.meter} role="group" aria-label="当前频道文件空间">
<span>{formatSize(props.fileBytes)}<em> / {maxFileBytes ? formatSize(maxFileBytes) : '…'}</em></span>
<div className={styles.meterTrack} role="progressbar" aria-label="文件空间使用量" aria-valuemin={0} aria-valuemax={maxFileBytes ?? 0} aria-valuenow={Math.min(props.fileBytes, maxFileBytes ?? 0)}>
<span className={props.fileBytes >= (maxFileBytes ?? Number.POSITIVE_INFINITY) ? styles.meterFull : undefined} style={{ width: `${usedRatio * 100}%` }} />
</div>
</div>
</header>
{props.fileService === 'disabled' ? <p className={styles.notice} role="status">服务端没有开启文件服务,暂时不能上传和下载文件。聊天不受影响。</p> : null}
{props.fileService === 'down' ? <p className={styles.notice} role="status">文件服务暂时连不上(可能还在部署或所在机器离线)。聊天不受影响;上传会排队,恢复后继续。</p> : null}
<input type="file" multiple hidden ref={input} onChange={(event) => { if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} />
{props.filterBar}
<nav className={styles.breadcrumb} aria-label="文件夹路径"><Button variant="ghost" icon="chevronLeft" label="返回上级文件夹" disabled={!props.folderId} onClick={() => navigateFolder(path[path.length - 1]?.parentId)} /> <nav className={styles.breadcrumb} aria-label="文件夹路径"><Button variant="ghost" icon="chevronLeft" label="返回上级文件夹" disabled={!props.folderId} onClick={() => navigateFolder(path[path.length - 1]?.parentId)} />
<button type="button" data-drop-folder="root" className={interaction.dropFolder === '__root' ? styles.dropTarget : ''} {...interaction.dropHandlers()} onClick={() => navigateFolder()}><Icon name="folder" size={16} />根目录</button> <button type="button" data-drop-folder="root" className={interaction.dropFolder === '__root' ? styles.dropTarget : ''} {...interaction.dropHandlers()} onClick={() => navigateFolder()}><Icon name="folder" size={16} />{props.threadName}</button>
{path.map((folder) => <span key={folder.id}><Icon name="chevronRight" size={12} /><button type="button" className={interaction.dropFolder === folder.id ? styles.dropTarget : ''} {...interaction.dropHandlers(folder.id)} onClick={() => navigateFolder(folder.id)}>{folder.name}</button></span>)} {path.map((folder) => <span key={folder.id}><Icon name="chevronRight" size={12} /><button type="button" className={interaction.dropFolder === folder.id ? styles.dropTarget : ''} {...interaction.dropHandlers(folder.id)} onClick={() => navigateFolder(folder.id)}>{folder.name}</button></span>)}
</nav> </nav>
{edit ? <form className={styles.edit} onSubmit={(event) => { void submit(event); }}><input autoFocus maxLength={80} aria-label="文件夹名称" value={name} onChange={(event) => setName(event.target.value)} /> {edit ? <form className={styles.edit} onSubmit={(event) => { void submit(event); }}><input autoFocus maxLength={80} aria-label="文件夹名称" value={name} onChange={(event) => setName(event.target.value)} />
@@ -97,23 +112,14 @@ export function FileExplorer(props: Props) {
{ids.map((id) => <ExplorerFileRow key={id} id={id} selected={id === props.selectedId} checked={props.checked.has(id)} enabled={props.enabled} {ids.map((id) => <ExplorerFileRow key={id} id={id} selected={id === props.selectedId} checked={props.checked.has(id)} enabled={props.enabled}
onSelect={(id, event) => { setSelectedFolder(undefined); interaction.select(id, event); }} onOpen={props.onOpen} onToggle={props.onToggle} onMenu={(id, x, y) => { setSelectedFolder(undefined); interaction.showFileMenu(id, x, y); }} onSelect={(id, event) => { setSelectedFolder(undefined); interaction.select(id, event); }} onOpen={props.onOpen} onToggle={props.onToggle} onMenu={(id, x, y) => { setSelectedFolder(undefined); interaction.showFileMenu(id, x, y); }}
onHover={interaction.hoverFile} onLeave={interaction.leave} onDrag={interaction.drag} onDragEnd={interaction.dismiss} />)}</tbody> onHover={interaction.hoverFile} onLeave={interaction.leave} onDrag={interaction.drag} onDragEnd={interaction.dismiss} />)}</tbody>
</table>{ids.length === 0 && children.length === 0 ? <div className={styles.empty}><Icon name="folder" size={28} /><p>此文件夹还没有文件</p><span>上传文件或将文件拖到窗口中。</span></div> : null}</div> </table>{ids.length === 0 && children.length === 0 ? <div className={styles.empty}><Icon name="folder" size={28} /><p>此频道内的文件夹还没有文件</p><span>上传文件或将文件拖到窗口中。</span></div> : null}</div>
{props.checked.size > 0 ? <div className={styles.batch}><span>已选 {props.checked.size} 项</span><Button variant="ghost" icon="download" onClick={() => { void props.onDownloadItems([...props.checked]); }}>下载所选</Button> {props.checked.size > 0 ? <div className={styles.batch}><span>已选 {props.checked.size} 项</span><Button variant="ghost" icon="download" onClick={() => { void props.onDownloadItems([...props.checked]); }}>下载所选</Button>
{props.enabled ? <select aria-label="移动所选文件到文件夹" value="__choose" onChange={(event) => { {props.enabled ? <select aria-label="移动所选文件到文件夹" value="__choose" onChange={(event) => {
const target = event.target.value; if (target !== '__choose') void props.onMoveItems([...props.checked], target === '__root' ? undefined : target).catch((cause: unknown) => setError(cause instanceof Error ? cause.message : '移动失败')); const target = event.target.value; if (target !== '__choose') void props.onMoveItems([...props.checked], target === '__root' ? undefined : target).catch((cause: unknown) => setError(cause instanceof Error ? cause.message : '移动失败'));
}}><option value="__choose">移动到…</option><option value="__root">根目录</option>{props.folders.map((folder) => <option value={folder.id} key={folder.id}>{folder.name}</option>)}</select> : null} }}><option value="__choose">移动到…</option><option value="__root">根目录</option>{props.folders.map((folder) => <option value={folder.id} key={folder.id}>{folder.name}</option>)}</select> : null}
</div> : null} </div> : null}
{error || interaction.error ? <p className={styles.error} role="alert">{error || interaction.error}</p> : null} {error || interaction.error ? <p className={styles.error} role="alert">{error || interaction.error}</p> : null}
<footer className={styles.status}>{children.length} 个文件夹 · {ids.length} 个文件<span>双击打开 · 右键操作 · 拖动移动</span></footer> <footer className={styles.status}><span>双击打开 · 右键操作 · 拖动移动</span></footer>
<div className={styles.quota} role="group" aria-label="当前频道文件空间">
<div className={styles.quotaLabel}><span>频道文件空间</span><span>{formatSize(props.fileBytes)} / {maxFileBytes ? formatSize(maxFileBytes) : '读取中'}</span></div>
<div className={styles.quotaTrack} role="progressbar" aria-label="文件空间使用量" aria-valuemin={0}
aria-valuemax={maxFileBytes ?? 0} aria-valuenow={Math.min(props.fileBytes, maxFileBytes ?? 0)}>
<span className={props.fileBytes >= (maxFileBytes ?? Number.POSITIVE_INFINITY) ? styles.quotaFull : undefined}
style={{ width: `${usedRatio * 100}%` }} />
</div>
{maxFileBytes ? <span className={styles.quotaRemaining}>剩余 {formatSize(Math.max(0, maxFileBytes - props.fileBytes))}</span> : null}
</div>
{interaction.menu ? <ContextMenu point={interaction.menu} actions={interaction.actions} onClose={interaction.dismiss} /> : null} {interaction.menu ? <ContextMenu point={interaction.menu} actions={interaction.actions} onClose={interaction.dismiss} /> : null}
{interaction.hover && ids.includes(interaction.hover.id) && !interaction.menu ? <ExplorerHover key={interaction.hover.id} {...interaction.hover} /> : null} {interaction.hover && ids.includes(interaction.hover.id) && !interaction.menu ? <ExplorerHover key={interaction.hover.id} {...interaction.hover} /> : null}
{interaction.moveIds ? <ExplorerMoveDialog folders={props.folders} count={interaction.moveIds.length} currentFolderId={props.folderId} onClose={() => interaction.setMoveIds(undefined)} {interaction.moveIds ? <ExplorerMoveDialog folders={props.folders} count={interaction.moveIds.length} currentFolderId={props.folderId} onClose={() => interaction.setMoveIds(undefined)}
@@ -1,5 +1,5 @@
import { useRef } from 'react'; import { useRef } from 'react';
import styles from './Workspace.module.css'; import styles from '@/features/channel/Workspace.module.css';
export function FilePaneResize({ width, onWidth }: { width?: number; onWidth: (width: number) => void }) { export function FilePaneResize({ width, onWidth }: { width?: number; onWidth: (width: number) => void }) {
const drag = useRef<{ right: number; maximum: number } | undefined>(undefined); const drag = useRef<{ right: number; maximum: number } | undefined>(undefined);
@@ -0,0 +1,20 @@
.bar {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
}
.input {
min-height: 36px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
}
.input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
@@ -0,0 +1,25 @@
import type { ItemFilter } from '@/lib/files/filter';
import styles from './FilterBar.module.css';
interface Props {
filter: ItemFilter;
searchOpen: boolean;
onChange: (f: ItemFilter) => void;
}
/** 搜索框,由顶栏的放大镜展开;没展开时什么都不显示 */
export function FilterBar({ filter, searchOpen, onChange }: Props) {
if (!searchOpen) return null;
return (
<div className={styles.bar}>
<input
className={styles.input}
type="search"
autoFocus
placeholder="搜索文本内容或文件名"
value={filter.query}
onChange={(e) => onChange({ ...filter, query: e.target.value })}
/>
</div>
);
}
@@ -1,11 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import type { FileUploadOptions } from '@/core-bridge/types'; import type { FileUploadOptions } from '@/domain/types';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet'; import { Sheet } from '@/ui/Sheet';
import styles from './UploadOptionsSheet.module.css'; import styles from './UploadOptionsSheet.module.css';
import { useServerSettings } from '@/store/server'; import { useServerSettings } from '@/store/app/server';
import { withCurrent } from '@/lib/settingOptions'; import { withCurrent } from '@/lib/space/settingOptions';
interface Props { interface Props {
encrypted?: boolean; encrypted?: boolean;
@@ -22,7 +22,7 @@ export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUplo
const [times, setTimes] = useState(defaultMaxDownloads > 0 ? defaultMaxDownloads : 10); const [times, setTimes] = useState(defaultMaxDownloads > 0 ? defaultMaxDownloads : 10);
// 不限制:聊天里直接预览,不消耗次数。限制:预览、下载每次都消耗一次 // 不限制:聊天里直接预览,不消耗次数。限制:预览、下载每次都消耗一次
const options: FileUploadOptions = limited ? { directPreview: false, maxDownloads: times } : { directPreview: true, maxDownloads: 0 }; const options: FileUploadOptions = limited ? { maxDownloads: times } : { maxDownloads: 0 };
return ( return (
<Sheet <Sheet
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent, type MouseEvent } from 'react'; import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent, type MouseEvent } from 'react';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { FILE_DRAG_TYPE, readFileDrag, selectFiles } from '@/lib/explorer'; import { FILE_DRAG_TYPE, readFileDrag, selectFiles } from '@/lib/files/explorer';
import type { MenuAction } from '@/ui/ContextMenu'; import type { MenuAction } from '@/ui/ContextMenu';
interface Options { interface Options {
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import { api } from '@/services';
import type { FileDomainOptions, FileDomainUsage } from '@/domain/types';
/**
* 文件服务现在能不能用:
* disabled 服务端没有开启文件服务(不能上传文件);
* checking 还在确认;
* down 开启了但连不上(还没部署好、放文件的机器离线)。聊天不受影响,会隔一会儿再试;
* ok 可以用,options 是创建频道时可选的文件空间档位。
*/
export type FileServiceStatus = 'disabled' | 'checking' | 'down' | 'ok';
const RETRY_MS = 15_000;
export function useFileService(spaceId: string | undefined): { status: FileServiceStatus; options: FileDomainOptions | undefined } {
const [state, setState] = useState<{ status: FileServiceStatus; options?: FileDomainOptions }>({ status: 'checking' });
useEffect(() => {
if (!spaceId) return;
let cancelled = false; let timer: number | undefined;
const check = () => {
api.fileOptions().then((options) => {
if (cancelled) return;
setState(options ? { status: 'ok', options } : { status: 'disabled' });
// 连上了就偶尔再确认一下(文件服务中途挂了界面也能知道);没连上就更勤地重试
timer = window.setTimeout(check, options ? RETRY_MS * 4 : RETRY_MS);
}, () => {
if (cancelled) return;
setState((previous) => ({ status: 'down', ...(previous.options ? { options: previous.options } : {}) }));
timer = window.setTimeout(check, RETRY_MS);
});
};
check();
return () => { cancelled = true; window.clearTimeout(timer); };
}, [spaceId]);
return { status: state.status, options: state.options };
}
/** 当前频道文件域的容量和已用字节;换频道或文件数变化(上传、删除)后重新读取 */
export function useFileDomainUsage(threadId: string | undefined, version: number): FileDomainUsage | undefined {
const [usage, setUsage] = useState<{ threadId: string; value: FileDomainUsage }>();
useEffect(() => {
let cancelled = false;
if (!threadId) return;
api.fileUsage(threadId).then((value) => { if (!cancelled && value) setUsage({ threadId, value }); }, () => undefined);
return () => { cancelled = true; };
}, [threadId, version]);
return usage && usage.threadId === threadId ? usage.value : undefined;
}
@@ -1,7 +1,7 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { errorMessage } from '@/core-bridge/errors'; import { errorMessage } from '@/api/errors';
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet'; import { Sheet } from '@/ui/Sheet';
import styles from './MembersPane.module.css'; import styles from './MembersPane.module.css';
@@ -1,20 +1,20 @@
import { useCapabilities } from '@/store/space/useCapabilities';
import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess'; import { channelAccessApi, type ChannelMember } from '@/services/channelAccess';
import type { AccountInvitation } from '@/core-bridge/account'; import type { AccountInvitation } from '@/services/account';
import { errorMessage } from '@/core-bridge/errors'; import { errorMessage } from '@/api/errors';
import type { SenderInfo } from '@/core-bridge/types'; import type { SenderInfo } from '@/domain/types';
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { formatSeen } from '@/lib/format'; import { formatSeen } from '@/lib/text/format';
import { OWNER_ROLE_ID, permissionsOf } from '@/lib/roles'; import { OWNER_ROLE_ID } from '@/lib/space/roles';
import { useAccount } from '@/store/account'; import { accountApi } from '@/services/account';
import { accountApi } from '@/core-bridge/account';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { AvatarPreview } from '../common/AvatarPreview'; import { AvatarPreview } from '@/features/avatar/AvatarPreview';
import { NameWithBadges } from './RoleBadges'; import { NameWithBadges } from './RoleBadges';
import styles from './MembersPane.module.css'; import styles from './MembersPane.module.css';
interface Props { owner: boolean; /** 空间主,或所在身份组被授予「移除成员」 */ canRemove: boolean; refreshKey: number; onInvite: () => void } interface Props { refreshKey: number; onInvite: () => void }
function Avatar({ person, name, onOpen }: { person?: SenderInfo; name: string; onOpen: () => void }) { function Avatar({ person, name, onOpen }: { person?: SenderInfo; name: string; onOpen: () => void }) {
return person?.avatar ? <button type="button" className={styles.avatarButton} aria-label={`查看 ${name} 的头像原图`} onClick={onOpen}><img className={styles.avatar} src={person.avatar} alt="" /></button> return person?.avatar ? <button type="button" className={styles.avatarButton} aria-label={`查看 ${name} 的头像原图`} onClick={onOpen}><img className={styles.avatar} src={person.avatar} alt="" /></button>
@@ -28,13 +28,13 @@ function Section({ title, count, children }: { title: string; count: number; chi
/** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */ /** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */
const PRESENCE_POLL_MS = 8000; const PRESENCE_POLL_MS = 8000;
export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) { export function MembersPane({ refreshKey, onInvite }: Props) {
const { canInvite, canRemoveMembers: canRemove } = useCapabilities();
const channelId = useWorkspace((s) => s.channel?.id); const channelId = useWorkspace((s) => s.channel?.id);
const maxMembers = useWorkspace((s) => s.channel?.maxMembers); const maxMembers = useWorkspace((s) => s.channel?.maxMembers);
const people = useWorkspace((s) => s.people); const people = useWorkspace((s) => s.people);
const roles = useWorkspace((s) => s.roles); const roles = useWorkspace((s) => s.roles);
const creator = useWorkspace((s) => s.channel?.creatorAccount); const creator = useWorkspace((s) => s.channel?.creatorAccount);
const self = useAccount((s) => s.username);
const listInvitations = useWorkspace((s) => s.listInvitations); const listInvitations = useWorkspace((s) => s.listInvitations);
const revoke = useWorkspace((s) => s.revokeInvitation); const revoke = useWorkspace((s) => s.revokeInvitation);
const [members, setMembers] = useState<ChannelMember[]>([]); const [members, setMembers] = useState<ChannelMember[]>([]);
@@ -85,26 +85,24 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) {
{removable ? <Button className={styles.remove} variant="ghost" disabled={busy} onClick={() => { void remove(username, invited); }}>{invited ? '撤销' : '移除'}</Button> : null} {removable ? <Button className={styles.remove} variant="ghost" disabled={busy} onClick={() => { void remove(username, invited); }}>{invited ? '撤销' : '移除'}</Button> : null}
</li>; </li>;
}; };
// 被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人
const removable = (item: ChannelMember) => item.role !== 'owner' && (owner || (canRemove && item.username !== self && !permissionsOf(roles, item.username, false).has('members.remove')));
// 在线的不用写时间;离线的写最后一次活跃(没登录过设备的老账号没有记录) // 在线的不用写时间;离线的写最后一次活跃(没登录过设备的老账号没有记录)
const member = (item: ChannelMember) => row(item.username, const member = (item: ChannelMember) => row(item.username,
!item.online && item.last_seen > 0 ? `最后在线 ${formatSeen(item.last_seen)}` : undefined, !item.online && item.last_seen > 0 ? `最后在线 ${formatSeen(item.last_seen)}` : undefined,
false, removable(item), item.role === 'owner', item.online); false, item.can_remove, item.role === 'owner', item.online);
const viewed = viewing ? people[viewing] : undefined; const viewed = viewing ? people[viewing] : undefined;
return <div className={styles.pane}> return <div className={styles.pane}>
{viewing && viewed?.avatar ? <AvatarPreview title={`${viewed.displayName || viewing} 的头像`} thumb={viewed.avatar} load={() => accountApi.avatar(viewing, 0, true)} onClose={() => setViewing(undefined)} /> : null} {viewing && viewed?.avatar ? <AvatarPreview title={`${viewed.displayName || viewing} 的头像`} thumb={viewed.avatar} load={() => accountApi.avatar(viewing, 0, true)} onClose={() => setViewing(undefined)} /> : null}
<div className={styles.head}> <div className={styles.head}>
<span>成员 {members.length}{maxMembers ? `/${maxMembers}` : ''}</span> <span>成员 {members.length}{maxMembers ? `/${maxMembers}` : ''}</span>
{owner ? <Button variant="primary" icon="plus" onClick={onInvite}>邀请成员</Button> : null} {canInvite ? <Button variant="primary" icon="plus" onClick={onInvite}>邀请成员</Button> : null}
</div> </div>
<div className={styles.scroll}> <div className={styles.scroll}>
<Section title="在线" count={online.length}>{online.map(member)}</Section> <Section title="在线" count={online.length}>{online.map(member)}</Section>
<Section title="离线" count={offline.length}>{offline.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> <Section title="待接受" count={pending.length}>{pending.map((invitation) => row(invitation.username, undefined, true, invitation.can_revoke))}</Section>
{error ? <p className={styles.error} role="alert">{error}</p> : null} {error ? <p className={styles.error} role="alert">{error}</p> : null}
{!owner ? <p className={styles.note}>{canRemove ? '你可以移除成员;邀请成员只有空间主可以。' : '只有空间主可以邀请或移除成员。'}</p> : null} {!canInvite ? <p className={styles.note}>{canRemove ? '你可以移除成员;邀请成员只有空间主可以。' : '只有空间主可以邀请或移除成员。'}</p> : null}
</div> </div>
</div>; </div>;
} }
@@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { InlineMarkdown } from '@/lib/inlineMarkdown'; import { InlineMarkdown } from '@/lib/text/inlineMarkdown';
import { badgesOf, customColorStyle, effectOf, isGradient, type Badge } from '@/lib/roles'; import { badgesOf, customColorStyle, effectOf, isGradient, type Badge } from '@/lib/space/roles';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import styles from './RoleBadges.module.css'; import styles from './RoleBadges.module.css';
function List({ items }: { items: Badge[] }) { function List({ items }: { items: Badge[] }) {
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { extractColors, hexToHsv, hsvToHex, parseHex, type Hsv } from '@/lib/colorMath'; import { extractColors, hexToHsv, hsvToHex, parseHex, type Hsv } from '@/lib/color/colorMath';
import { drawColors, type DrawnColor } from '@/lib/colorDraw'; import { drawColors, type DrawnColor } from '@/lib/color/colorDraw';
import { EFFECTS, gradientOf, isGradient, joinColor, splitColor, stopsOf } from '@/lib/roles'; import { EFFECTS, gradientOf, isGradient, joinColor, splitColor, stopsOf } from '@/lib/space/roles';
import styles from './RoleColorPicker.module.css'; import styles from './RoleColorPicker.module.css';
const MAX_STOPS = 5; const MAX_STOPS = 5;
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess'; import { channelAccessApi, type ChannelMember } from '@/services/channelAccess';
import { errorMessage } from '@/core-bridge/errors'; import { errorMessage } from '@/api/errors';
import type { RoleInfo } from '@/core-bridge/types'; import type { RoleInfo } from '@/domain/types';
import { customColorStyle, effectOf, isGradient, normalizeColor, OWNER_ROLE_ID, PERMISSIONS, toCustomColor } from '@/lib/roles'; import { customColorStyle, effectOf, isGradient, normalizeColor, OWNER_ROLE_ID, PERMISSIONS, toCustomColor } from '@/lib/space/roles';
import { InlineMarkdown } from '@/lib/inlineMarkdown'; import { InlineMarkdown } from '@/lib/text/inlineMarkdown';
import { useToast } from '@/store/toast'; import { useToast } from '@/store/app/toast';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import badgeStyles from './RoleBadges.module.css'; import badgeStyles from './RoleBadges.module.css';
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton'; import { CopyButton } from '@/ui/CopyButton';
import { QrCode } from '@/ui/QrCode'; import { QrCode } from '@/ui/QrCode';
@@ -1,6 +1,6 @@
import { useChannels } from '@/store/channels'; import { useChannels } from '@/store/space/channels';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { AccountDock } from '@/features/home/AccountDock'; import { AccountDock } from '@/features/account/AccountDock';
import { Icon, type IconName } from '@/ui/Icon'; import { Icon, type IconName } from '@/ui/Icon';
import { ThemeMenu } from '@/ui/ThemeMenu'; import { ThemeMenu } from '@/ui/ThemeMenu';
import { SpaceHeader, SpaceRail } from './SpaceRail'; import { SpaceHeader, SpaceRail } from './SpaceRail';
@@ -1,4 +1,4 @@
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import styles from './MobileTopBar.module.css'; import styles from './MobileTopBar.module.css';
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import styles from './Notepad.module.css'; import styles from './Notepad.module.css';
@@ -1,8 +1,8 @@
import { useChannels } from '@/store/channels'; import { useChannels } from '@/store/space/channels';
import { spaceSeqKey, useReadState } from '@/store/readState'; import { spaceSeqKey, useReadState } from '@/store/space/readState';
import { useSpaceReads } from './useThreadReads'; import { useSpaceReads } from './useThreadReads';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { channelPath, navigate } from '@/app/navigation'; import { channelPath, navigate } from '@/app/navigation';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import styles from './SpaceRail.module.css'; import styles from './SpaceRail.module.css';
@@ -1,8 +1,9 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import type { ChannelGroupInfo, ThreadInfo } from '@/core-bridge/types'; import type { ChannelGroupInfo, ThreadInfo } from '@/domain/types';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Sheet } from '@/ui/Sheet'; import { Sheet } from '@/ui/Sheet';
import { formatSize } from '@/lib/text/format';
import styles from './ThreadSidebar.module.css'; import styles from './ThreadSidebar.module.css';
interface Props { interface Props {
@@ -17,10 +18,14 @@ interface Props {
enabled: boolean; enabled: boolean;
/** 新建频道涉及密钥,只有空间主 */ /** 新建频道涉及密钥,只有空间主 */
canCreate: boolean; canCreate: boolean;
owner: boolean; /** 能改频道的历史可读策略 */
canManageHistory: boolean;
/** 创建频道时可以分配的文件空间档位(字节) */
quotaChoices: number[];
defaultQuota?: number | undefined;
onHistory: (id: string, chatHistory: boolean, filesHistory: boolean) => Promise<void>; onHistory: (id: string, chatHistory: boolean, filesHistory: boolean) => Promise<void>;
onSelectThread: (id: string) => void; onSelectThread: (id: string) => void;
onCreateThread: (name: string, groupId?: string) => Promise<string>; onCreateThread: (name: string, groupId?: string, fileQuota?: number) => Promise<string>;
onRenameThread: (id: string, name: string) => Promise<void>; onRenameThread: (id: string, name: string) => Promise<void>;
onCreateGroup: (name: string) => Promise<string>; onCreateGroup: (name: string) => Promise<string>;
onRenameGroup: (id: string, name: string) => Promise<void>; onRenameGroup: (id: string, name: string) => Promise<void>;
@@ -34,6 +39,7 @@ export function ThreadSidebar(props: Props) {
const [edit, setEdit] = useState<Editor>(); const [edit, setEdit] = useState<Editor>();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [groupId, setGroupId] = useState(''); const [groupId, setGroupId] = useState('');
const [quota, setQuota] = useState(0);
const [chatHistory, setChatHistory] = useState(true); const [chatHistory, setChatHistory] = useState(true);
const [filesHistory, setFilesHistory] = useState(true); const [filesHistory, setFilesHistory] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -43,7 +49,7 @@ export function ThreadSidebar(props: Props) {
const knownGroups = new Set(props.groups.map((group) => group.id)); const knownGroups = new Set(props.groups.map((group) => group.id));
const ungrouped = props.threads.filter((thread) => !thread.groupId || !knownGroups.has(thread.groupId)); const ungrouped = props.threads.filter((thread) => !thread.groupId || !knownGroups.has(thread.groupId));
function begin(target: Editor, initial = '') { function begin(target: Editor, initial = '') {
setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError(''); setConfirmDelete(false); setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setQuota(props.defaultQuota ?? 0); setError(''); setConfirmDelete(false);
const channel = props.threads.find(t => t.id === target.id); setChatHistory(channel?.chatHistory ?? true); setFilesHistory(channel?.filesHistory ?? true); const channel = props.threads.find(t => t.id === target.id); setChatHistory(channel?.chatHistory ?? true); setFilesHistory(channel?.filesHistory ?? true);
} }
function toggle(id: string) { setCollapsed((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function toggle(id: string) { setCollapsed((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
@@ -65,8 +71,8 @@ export function ThreadSidebar(props: Props) {
const original = props.threads.find((thread) => thread.id === edit.id); const original = props.threads.find((thread) => thread.id === edit.id);
if (original?.name !== name.trim()) await props.onRenameThread(edit.id, name); if (original?.name !== name.trim()) await props.onRenameThread(edit.id, name);
if ((original?.groupId ?? '') !== groupId) await props.onGroupThread(edit.id, groupId || undefined); if ((original?.groupId ?? '') !== groupId) await props.onGroupThread(edit.id, groupId || undefined);
if (props.owner && (original?.chatHistory !== chatHistory || original?.filesHistory !== filesHistory)) await props.onHistory(edit.id, chatHistory, filesHistory); if (props.canManageHistory && (original?.chatHistory !== chatHistory || original?.filesHistory !== filesHistory)) await props.onHistory(edit.id, chatHistory, filesHistory);
} else props.onSelectThread(await props.onCreateThread(name, groupId || undefined)); } else props.onSelectThread(await props.onCreateThread(name, groupId || undefined, quota || undefined));
if (groupId) setCollapsed((previous) => { const next = new Set(previous); next.delete(groupId); return next; }); if (groupId) setCollapsed((previous) => { const next = new Set(previous); next.delete(groupId); return next; });
setEdit(undefined); setEdit(undefined);
} catch (cause) { setError(cause instanceof Error ? cause.message : '操作失败'); } } catch (cause) { setError(cause instanceof Error ? cause.message : '操作失败'); }
@@ -106,7 +112,10 @@ export function ThreadSidebar(props: Props) {
{edit.kind === 'channel' ? <label>所属分组<select aria-label="所属分组" disabled={busy} value={groupId} onChange={(event) => setGroupId(event.target.value)}> {edit.kind === 'channel' ? <label>所属分组<select aria-label="所属分组" disabled={busy} value={groupId} onChange={(event) => setGroupId(event.target.value)}>
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)} <option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</select></label> : null} </select></label> : null}
{edit.kind === 'channel' && edit.id && props.owner ? <> {edit.kind === 'channel' && !edit.id && props.quotaChoices.length ? <label>文件空间<select aria-label="文件空间" disabled={busy} value={quota} onChange={(event) => setQuota(Number(event.target.value))}>
{props.quotaChoices.map((size) => <option key={size} value={size}>{formatSize(size)}</option>)}
</select><small className={styles.hint}>频道专属的文件容量,用满后不能再上传;分配后暂不能调整。</small></label> : null}
{edit.kind === 'channel' && edit.id && props.canManageHistory ? <>
<label className={styles.check}><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label> <label className={styles.check}><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
<label className={styles.check}><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label> <label className={styles.check}><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
</> : null} </> : null}
@@ -1,5 +1,5 @@
import { formatRemaining } from '@/lib/format'; import { formatRemaining } from '@/lib/text/format';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { ThemeMenu } from '@/ui/ThemeMenu'; import { ThemeMenu } from '@/ui/ThemeMenu';
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import type { Item, SpaceEvent } from '@/core-bridge/types'; import type { Item, SpaceEvent } from '@/domain/types';
import { callWorker, subscribeWorker } from '@/core-bridge/workerClient'; import { callWorker, subscribeWorker } from '@/services/workerClient';
import { useAccount } from '@/store/account'; import { useAccount } from '@/store/account/account';
import { useChannels } from '@/store/channels'; import { useChannels } from '@/store/space/channels';
import { readKey, spaceSeqKey, useReadState } from '@/store/readState'; import { readKey, spaceSeqKey, useReadState } from '@/store/space/readState';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/space/workspace';
const isMine = (item: Item, self: string | null | undefined) => item.localId !== undefined || Boolean(self && item.author?.username === self); const isMine = (item: Item, self: string | null | undefined) => item.localId !== undefined || Boolean(self && item.author?.username === self);
const countable = (item: Item) => !(item.kind === 'file' && item.metadataHidden); const countable = (item: Item) => !(item.kind === 'file' && item.metadataHidden);
+5 -5
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import type { ChannelInfo, Share } from '@/core-bridge/types'; import type { ChannelInfo, Share } from '@/domain/types';
import { formatAgo, formatRemaining } from '@/lib/format'; import { formatAgo, formatRemaining } from '@/lib/text/format';
import { useChannels } from '@/store/channels'; import { useChannels } from '@/store/space/channels';
import { useShares } from '@/store/shares'; import { useShares } from '@/store/share/shares';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import styles from './ChannelList.module.css'; import styles from './ChannelList.module.css';
@@ -47,7 +47,7 @@ export function ChannelList({ kind, title, onOpen, quietTitle = false }: Props)
<span className={c.mode === 'enc' ? styles.enc : styles.raw}> <span className={c.mode === 'enc' ? styles.enc : styles.raw}>
{c.mode === 'enc' ? <><Icon name="lock" size={12} /> 加密</> : '明文'} {c.mode === 'enc' ? <><Icon name="lock" size={12} /> 加密</> : '明文'}
</span> </span>
<span>{c.role === 'owner' ? '所有者' : '成员'}</span> <span>{c.isOwner ? '所有者' : '成员'}</span>
<span>最多 {c.maxMembers} 人</span> <span>最多 {c.maxMembers} 人</span>
<span>{formatAgo(c.createdAt)}创建</span> <span>{formatAgo(c.createdAt)}创建</span>
</> </>
+8 -8
View File
@@ -1,16 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import type { ChannelInfo, CreateOptions } from '@/core-bridge/types'; import type { ChannelInfo, CreateOptions } from '@/domain/types';
import { useChannels } from '@/store/channels'; import { useChannels } from '@/store/space/channels';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet'; import { Sheet } from '@/ui/Sheet';
import { solveCreationCaptcha } from '@/core-bridge/captcha'; import { solveCreationCaptcha } from '@/services/captcha';
import styles from './CreateSheet.module.css'; import styles from './CreateSheet.module.css';
import { useServerSettings } from '@/store/server'; import { useServerSettings } from '@/store/app/server';
import { creationOptions } from '@/core-bridge/settings'; import { creationOptions } from '@/domain/settings';
import type { ServerInfoBody } from '@/core-bridge/generated/api'; import type { ServerInfoBody } from '@/api/generated';
import { ttlLabel } from '@/lib/settingOptions'; import { ttlLabel } from '@/lib/space/settingOptions';
import { errorMessage } from '@/core-bridge/errors'; import { errorMessage } from '@/api/errors';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
onCreated: (c: ChannelInfo) => void; onCreated: (c: ChannelInfo) => void;
+8 -8
View File
@@ -1,13 +1,13 @@
import { lazy, Suspense, useCallback, useState } from 'react'; import { lazy, Suspense, useCallback, useState } from 'react';
import { channelPath, navigate, sharePath } from '@/app/navigation'; import { channelPath, navigate, sharePath } from '@/app/navigation';
import { buildInfo } from '@/lib/buildInfo'; import { buildInfo } from '@/lib/platform/buildInfo';
import type { ChannelInfo, Share } from '@/core-bridge/types'; import type { ChannelInfo, Share } from '@/domain/types';
import type { ClipboardPayload } from '@/lib/clipboard'; import type { ClipboardPayload } from '@/lib/platform/clipboard';
import { useGlobalPaste } from '@/lib/useGlobalPaste'; import { useGlobalPaste } from '@/lib/hooks/useGlobalPaste';
import { useChannels } from '@/store/channels'; import { useChannels } from '@/store/space/channels';
import { useShares } from '@/store/shares'; import { useShares } from '@/store/share/shares';
import { solveCreationCaptcha } from '@/core-bridge/captcha'; import { solveCreationCaptcha } from '@/services/captcha';
import { errorMessage } from '@/core-bridge/errors'; import { errorMessage } from '@/api/errors';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { ThemeMenu } from '@/ui/ThemeMenu'; import { ThemeMenu } from '@/ui/ThemeMenu';
+3 -3
View File
@@ -1,7 +1,7 @@
import { useRef, useState, type DragEvent } from 'react'; import { useRef, useState, type DragEvent } from 'react';
import type { ClipboardPayload } from '@/lib/clipboard'; import type { ClipboardPayload } from '@/lib/platform/clipboard';
import { readClipboard } from '@/lib/clipboard'; import { readClipboard } from '@/lib/platform/clipboard';
import { useMediaQuery } from '@/lib/useMediaQuery'; import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
import { Icon, type IconName } from '@/ui/Icon'; import { Icon, type IconName } from '@/ui/Icon';
import styles from './QuickShare.module.css'; import styles from './QuickShare.module.css';
+2 -2
View File
@@ -1,11 +1,11 @@
import { memo } from 'react'; import { memo } from 'react';
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format'; import { formatSize, formatTime, formatTransferProgress } from '@/lib/text/format';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton'; import { CopyButton } from '@/ui/CopyButton';
import { useDownload } from './download'; import { useDownload } from './download';
import { InlineContent, inlineKindOf } from './InlineContent'; import { InlineContent, inlineKindOf } from './InlineContent';
import { useItemSource } from './source'; import { useItemSource } from './source';
import { senderLabel } from '@/lib/sender'; import { senderLabel } from '@/lib/space/sender';
import { titleOf, versionOf } from './summary'; import { titleOf, versionOf } from './summary';
import { Tile } from './Tile'; import { Tile } from './Tile';
import styles from './CompactRow.module.css'; import styles from './CompactRow.module.css';
+4 -4
View File
@@ -1,9 +1,9 @@
import type { FileItem } from '@/core-bridge/types'; import type { FileItem } from '@/domain/types';
import { formatSize, formatTime } from '@/lib/format'; import { formatSize, formatTime } from '@/lib/text/format';
import { saveBlob } from '@/lib/download'; import { saveBlob } from '@/lib/files/download';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { useItemSource } from './source'; import { useItemSource } from './source';
import { senderLabel } from '@/lib/sender'; import { senderLabel } from '@/lib/space/sender';
import styles from './HistoryList.module.css'; import styles from './HistoryList.module.css';
/** 文件的修改历史:当前版本在最上,旧版本可单独下载 */ /** 文件的修改历史:当前版本在最上,旧版本可单独下载 */
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more