feat(web): avatar thumbnails with original preview, registration avatar, pure-JS crypto fallback for http [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-17 14:35:42 +08:00
1 parent 283196938e
commit d66c93fe98
36 files changed
+735 -60

No files matched your search

+42
View File
@@ -9,6 +9,9 @@
"version": "0.0.0",
"dependencies": {
"@material/material-color-utilities": "^0.3.0",
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
"@webdrop/core": "file:../packages/core",
"cap-widget": "^0.1.58",
"gifenc": "^1.0.3",
@@ -311,6 +314,45 @@
"integrity": "sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==",
"license": "Apache-2.0"
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+3
View File
@@ -13,6 +13,9 @@
},
"dependencies": {
"@material/material-color-utilities": "^0.3.0",
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
"@webdrop/core": "file:../packages/core",
"cap-widget": "^0.1.58",
"gifenc": "^1.0.3",
+4 -2
View File
@@ -1,6 +1,6 @@
import { callWorker } from './workerClient';
import type { AccountDeviceBody, InvitationBody } from './generated/api';
import type { PersonalProfile } from './protocol/account';
import type { AvatarChange, PersonalProfile } from './protocol/account';
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
@@ -18,7 +18,9 @@ export const accountApi = {
sync: () => callWorker<AccountStatus>('syncAccount', { apiBase: API_BASE }),
invitations: () => callWorker<AccountInvitation[]>('accountInvitations', { apiBase: API_BASE }),
acceptInvitation: (id: string) => callWorker<void>('acceptInvitation', { id, apiBase: API_BASE }),
updatePersonal: (displayName: string, avatar?: string) => callWorker<AccountStatus>('updatePersonal', { displayName, avatar, apiBase: API_BASE }),
/** 默认是有损的缩略图;full 为 true 取原图(预览时) */
avatar: (username: string, version: number, full = false) => callWorker<string | undefined>('getAvatar', { username, version, full, apiBase: API_BASE }),
updatePersonal: (displayName: string, avatar?: AvatarChange) => callWorker<AccountStatus>('updatePersonal', { displayName, avatar, apiBase: API_BASE }),
devices: () => callWorker<AccountDevice[]>('accountDevices', { apiBase: API_BASE }),
renameDevice: (id: string, name: string) => callWorker<void>('renameAccountDevice', { id, name, apiBase: API_BASE }),
revokeDevice: (id: string) => callWorker<AccountStatus>('revokeAccountDevice', { id, apiBase: API_BASE }),
+2
View File
@@ -12,6 +12,8 @@ export interface WorkspaceApi {
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
/** 使用现有空间 settings 接口更新空间设置。 */
updateChannelSettings(patch: ChannelSettingsPatch): Promise<void>;
/** 空间头像原图(信息里只有缩略图),预览时才下载 */
spaceAvatar(): Promise<string | undefined>;
/** 通过链接加入:先 GET 空间信息预览,再 POST join */
joinChannel(link: ParsedChannelLink, password?: string): Promise<ChannelInfo>;
inviteUser(username: string): Promise<void>;
+5 -1
View File
@@ -2,7 +2,8 @@ import type { AccountProfile } from '../generated/api';
export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
export interface SigningKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
/** Account vault v2: credentials and cached metadata, encrypted before transport. */
export type PersonalProfile = Pick<AccountProfile, 'displayName' | 'modifiedAt'> & { avatar?: string };
/** avatar 只在加密线程里有(发消息要带头像快照);界面这边通过 hasAvatar 判断,头像另取 */
export type PersonalProfile = Pick<AccountProfile, 'displayName' | 'modifiedAt'> & { avatar?: string; hasAvatar?: boolean };
export interface ResourceDelete { id: string; modifiedAt: number }
export interface VaultResource {
domainKeys?: Record<string, string>;
@@ -25,3 +26,6 @@ export interface VaultResource {
state?: 'draft' | 'sealed';
}
export interface VaultProfile { v: 3; keys: InvitationKeyPair & { signing: SigningKeyPair }; resources: VaultResource[]; deletes: ResourceDelete[] }
/** 资料里头像的修改:换成新的(原图 + 缩略图)、保持不变、或移除(不传) */
export type AvatarChange = { avatar: string; thumb: string } | 'keep' | undefined;
+1
View File
@@ -17,6 +17,7 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) =
export const realApi: WorkspaceApi = {
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker<void>('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }),
spaceAvatar: () => callWorker<string | undefined>('getSpaceAvatar', { id: currentChannelId(), apiBase: API_BASE }),
joinChannel: (link: ParsedChannelLink, password?: string) => callWorker<ChannelInfo>('joinChannel', { link, password, apiBase: API_BASE }),
inviteUser: (username) => callWorker<void>('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }),
listInvitations: () => callWorker<AccountInvitation[]>('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }),
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { fallbackSubtle as soft } from './subtleFallback';
const native = globalThis.crypto.subtle;
const text = (value: string) => new TextEncoder().encode(value);
const hex = (data: ArrayBuffer | Uint8Array) => Array.from(data instanceof Uint8Array ? data : new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0')).join('');
describe('crypto.subtle 的纯 JS 替代实现和原生逐字节一致', () => {
it('SHA-256、HMAC', async () => {
expect(hex(await soft.digest('SHA-256', text('hello')))).toBe(hex(await native.digest('SHA-256', text('hello'))));
const a = await soft.importKey('raw', text('k'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const b = await native.importKey('raw', text('k'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
expect(hex(await soft.sign('HMAC', a, text('m')))).toBe(hex(await native.sign('HMAC', b, text('m'))));
});
it('PBKDF2 和 HKDF', async () => {
const salt = text('salty');
const pa = await soft.importKey('raw', text('password'), 'PBKDF2', false, ['deriveBits']);
const pb = await native.importKey('raw', text('password'), 'PBKDF2', false, ['deriveBits']);
expect(hex(await soft.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 5000 }, pa, 256)))
.toBe(hex(await native.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 5000 }, pb, 256)));
const ha = await soft.importKey('raw', text('root'), 'HKDF', false, ['deriveBits']);
const hb = await native.importKey('raw', text('root'), 'HKDF', false, ['deriveBits']);
expect(hex(await soft.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: text('i') }, ha, 256)))
.toBe(hex(await native.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: text('i') }, hb, 256)));
});
it('AES-GCM 互相能解开,带附加数据;篡改会失败', async () => {
const raw = crypto.getRandomValues(new Uint8Array(32)); const iv = crypto.getRandomValues(new Uint8Array(12));
const a = await soft.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
const b = await native.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
const params = { name: 'AES-GCM', iv, additionalData: text('aad') };
const sealed = await soft.encrypt(params, a, text('秘密内容'));
expect(hex(sealed)).toBe(hex(await native.encrypt(params, b, text('秘密内容'))));
expect(new TextDecoder().decode(await native.decrypt(params, b, sealed))).toBe('秘密内容');
const tampered = new Uint8Array(sealed); tampered[0] = tampered[0]! ^ 1;
await expect(soft.decrypt(params, a, tampered)).rejects.toThrow();
});
it('ECDSA:原生签的软件能验,软件签的原生能验', async () => {
const pair = await soft.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) as CryptoKeyPair;
const jwkPrivate = await soft.exportKey('jwk', pair.privateKey) as JsonWebKey;
const jwkPublic = await soft.exportKey('jwk', pair.publicKey) as JsonWebKey;
const nativePrivate = await native.importKey('jwk', jwkPrivate, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
const nativePublic = await native.importKey('jwk', jwkPublic, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const algorithm = { name: 'ECDSA', hash: 'SHA-256' };
const fromSoft = await soft.sign(algorithm, pair.privateKey, text('消息'));
expect(fromSoft.byteLength).toBe(64);
expect(await native.verify(algorithm, nativePublic, fromSoft, text('消息'))).toBe(true);
const fromNative = await native.sign(algorithm, nativePrivate, text('消息'));
expect(await soft.verify(algorithm, pair.publicKey, fromNative, text('消息'))).toBe(true);
expect(await soft.verify(algorithm, pair.publicKey, fromNative, text('别的消息'))).toBe(false);
});
it('ECDH:两边算出同一个共享密钥,JWK 互通', async () => {
const ours = await soft.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']) as CryptoKeyPair;
const theirs = await native.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
const theirPublic = await native.exportKey('jwk', theirs.publicKey);
const ourPublic = await soft.exportKey('jwk', ours.publicKey) as JsonWebKey;
const soft1 = await soft.deriveBits({ name: 'ECDH', public: await soft.importKey('jwk', theirPublic, { name: 'ECDH', namedCurve: 'P-256' }, false, []) } as never, ours.privateKey, 256);
const nativePublic = await native.importKey('jwk', ourPublic, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
const native1 = await native.deriveBits({ name: 'ECDH', public: nativePublic }, theirs.privateKey, 256);
expect(hex(soft1)).toBe(hex(native1));
});
it('不可导出的密钥导出会失败', async () => {
const key = await soft.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt']) as CryptoKey;
await expect(soft.exportKey('raw', key)).rejects.toThrow();
});
});
+161
View File
@@ -0,0 +1,161 @@
import { gcm } from '@noble/ciphers/aes.js';
import { p256 } from '@noble/curves/nist.js';
import { hkdf } from '@noble/hashes/hkdf.js';
import { hmac } from '@noble/hashes/hmac.js';
import { pbkdf2 } from '@noble/hashes/pbkdf2.js';
import { sha256 } from '@noble/hashes/sha2.js';
/**
* 浏览器只在 https 或 localhost 下提供 crypto.subtle。通过局域网 IP 用 http 访问时它不存在,
* 注册、登录(密码派生、保险库加解密、签名)全都会失败。这里用纯 JS 实现这个项目用到的那一小部分接口,
* 只在 crypto.subtle 不存在时才安装;输出和原生实现逐字节一致(见 subtleFallback.test.ts),所以同一个账号在 https 和 http 下可以互通。
*
* 只覆盖用到的算法:SHA-256、HMAC-SHA256、HKDF-SHA256、PBKDF2-SHA256、AES-256-GCM、ECDH P-256、ECDSA P-256(SHA-256)。
* 纯 JS 比原生慢(PBKDF2 的 60 万次迭代要一两秒),而且 http 页面本身可以被网络上的人替换,所以这只是开发和局域网试用的退路。
*/
type Bytes = Uint8Array;
interface FallbackKey {
readonly __fallback: true;
type: 'secret' | 'public' | 'private';
extractable: boolean;
algorithm: { name: string; [key: string]: unknown };
usages: string[];
raw?: Bytes;
/** EC:私钥 d(32 字节)和公钥(65 字节,未压缩) */
d?: Bytes;
point?: Bytes;
}
const toBytes = (data: BufferSource): Bytes => (data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
const fresh = (data: Bytes): ArrayBuffer => data.slice().buffer as ArrayBuffer;
const name = (algorithm: string | { name: string }): string => (typeof algorithm === 'string' ? algorithm : algorithm.name).toUpperCase();
const b64u = (data: Bytes): string => btoa(String.fromCharCode(...data)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
const unb64u = (text: string): Bytes => Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), (char) => char.charCodeAt(0));
const fail = (message: string, kind = 'OperationError'): never => { throw new DOMException(message, kind); };
const asKey = (key: CryptoKey): FallbackKey => ((key as unknown as FallbackKey).__fallback ? (key as unknown as FallbackKey) : fail('密钥不是由这个环境创建的', 'InvalidAccessError'));
const wrap = (key: Omit<FallbackKey, '__fallback'>): CryptoKey => ({ __fallback: true, ...key }) as unknown as CryptoKey;
function ecKey(algorithmName: string, type: 'public' | 'private', point: Bytes, d: Bytes | undefined, extractable: boolean, usages: string[]): CryptoKey {
return wrap({ type, extractable, usages, algorithm: { name: algorithmName, namedCurve: 'P-256' }, point, ...(d ? { d } : {}) });
}
function ecPair(algorithmName: string, extractable: boolean, usages: string[]): CryptoKeyPair {
const d = p256.utils.randomSecretKey();
const point = p256.getPublicKey(d, false);
const publicUsages = algorithmName === 'ECDSA' ? usages.filter((usage) => usage === 'verify') : [];
const privateUsages = usages.filter((usage) => usage !== 'verify');
return { publicKey: ecKey(algorithmName, 'public', point, undefined, true, publicUsages), privateKey: ecKey(algorithmName, 'private', point, d, extractable, privateUsages) };
}
function importJwk(jwk: JsonWebKey, algorithm: { name: string; namedCurve?: string }, extractable: boolean, usages: string[]): CryptoKey {
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || !jwk.x || !jwk.y) fail('只支持 P-256 的 EC 密钥', 'DataError');
const point = new Uint8Array(65);
point[0] = 4; point.set(unb64u(jwk.x!), 1); point.set(unb64u(jwk.y!), 33);
const d = jwk.d ? unb64u(jwk.d) : undefined;
return ecKey(algorithm.name, d ? 'private' : 'public', point, d, extractable, usages);
}
function exportJwk(key: FallbackKey): JsonWebKey {
if (!key.extractable) fail('这个密钥不允许导出', 'InvalidAccessError');
if (!key.point) fail('只能导出 EC 密钥', 'NotSupportedError');
const ops = key.type === 'private' ? key.usages : key.usages;
return { kty: 'EC', crv: 'P-256', ...(key.d ? { d: b64u(key.d) } : {}), x: b64u(key.point!.subarray(1, 33)), y: b64u(key.point!.subarray(33, 65)), ext: true, key_ops: ops };
}
const subtle = {
async digest(algorithm: string | { name: string }, data: BufferSource): Promise<ArrayBuffer> {
if (name(algorithm) !== 'SHA-256') fail('只支持 SHA-256', 'NotSupportedError');
return fresh(sha256(toBytes(data)));
},
async importKey(format: string, keyData: BufferSource | JsonWebKey, algorithm: string | { name: string; namedCurve?: string; hash?: string }, extractable: boolean, usages: string[]): Promise<CryptoKey> {
const algo = name(algorithm);
if (format === 'jwk') return importJwk(keyData as JsonWebKey, typeof algorithm === 'string' ? { name: algorithm } : algorithm, extractable, usages);
if (format !== 'raw') fail('只支持 raw 和 jwk', 'NotSupportedError');
const raw = toBytes(keyData as BufferSource).slice();
if (algo === 'AES-GCM' && ![16, 24, 32].includes(raw.length)) fail('AES 密钥长度不对', 'DataError');
return wrap({ type: 'secret', extractable, usages, algorithm: typeof algorithm === 'string' ? { name: algorithm } : { ...algorithm }, raw });
},
async exportKey(format: string, key: CryptoKey): Promise<JsonWebKey | ArrayBuffer> {
const own = asKey(key);
if (format === 'jwk') return exportJwk(own);
if (!own.extractable) fail('这个密钥不允许导出', 'InvalidAccessError');
return fresh(own.raw ?? own.point ?? new Uint8Array());
},
async generateKey(algorithm: { name: string; length?: number; namedCurve?: string }, extractable: boolean, usages: string[]): Promise<CryptoKey | CryptoKeyPair> {
const algo = name(algorithm);
if (algo === 'AES-GCM') return wrap({ type: 'secret', extractable, usages, algorithm: { name: 'AES-GCM', length: algorithm.length ?? 256 }, raw: crypto.getRandomValues(new Uint8Array((algorithm.length ?? 256) / 8)) });
if ((algo === 'ECDH' || algo === 'ECDSA') && algorithm.namedCurve === 'P-256') return ecPair(algo, extractable, usages);
return fail('不支持的算法', 'NotSupportedError');
},
async sign(algorithm: string | { name: string }, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer> {
const own = asKey(key);
const algo = name(algorithm);
if (algo === 'HMAC') return fresh(hmac(sha256, own.raw!, toBytes(data)));
if (algo === 'ECDSA' && own.d) return fresh(p256.sign(toBytes(data), own.d, { prehash: true, format: 'compact' }));
return fail('不支持的签名', 'NotSupportedError');
},
async verify(algorithm: string | { name: string }, key: CryptoKey, signature: BufferSource, data: BufferSource): Promise<boolean> {
const own = asKey(key);
const algo = name(algorithm);
if (algo === 'HMAC') {
const expected = hmac(sha256, own.raw!, toBytes(data)); const got = toBytes(signature);
return expected.length === got.length && expected.every((byte, index) => byte === got[index]);
}
if (algo === 'ECDSA' && own.point) {
try { return p256.verify(toBytes(signature), toBytes(data), own.point, { prehash: true, format: 'compact', lowS: false }); } catch { return false; }
}
return fail('不支持的验证', 'NotSupportedError');
},
async encrypt(algorithm: { name: string; iv: BufferSource; additionalData?: BufferSource; tagLength?: number }, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer> {
if (name(algorithm) !== 'AES-GCM' || (algorithm.tagLength ?? 128) !== 128) fail('只支持 AES-GCM(128 位标签)', 'NotSupportedError');
return fresh(gcm(asKey(key).raw!, toBytes(algorithm.iv), algorithm.additionalData ? toBytes(algorithm.additionalData) : undefined).encrypt(toBytes(data)));
},
async decrypt(algorithm: { name: string; iv: BufferSource; additionalData?: BufferSource; tagLength?: number }, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer> {
if (name(algorithm) !== 'AES-GCM' || (algorithm.tagLength ?? 128) !== 128) fail('只支持 AES-GCM(128 位标签)', 'NotSupportedError');
try { return fresh(gcm(asKey(key).raw!, toBytes(algorithm.iv), algorithm.additionalData ? toBytes(algorithm.additionalData) : undefined).decrypt(toBytes(data))); }
catch { return fail('解密失败', 'OperationError'); }
},
async deriveBits(algorithm: { name: string; hash?: string; salt?: BufferSource; info?: BufferSource; iterations?: number; public?: CryptoKey }, key: CryptoKey, length: number): Promise<ArrayBuffer> {
const own = asKey(key);
const algo = name(algorithm);
if (algo === 'PBKDF2') return fresh(pbkdf2(sha256, own.raw!, toBytes(algorithm.salt!), { c: algorithm.iterations!, dkLen: length / 8 }));
if (algo === 'HKDF') return fresh(hkdf(sha256, own.raw!, toBytes(algorithm.salt!), toBytes(algorithm.info!), length / 8));
if (algo === 'ECDH' && own.d) return fresh(p256.getSharedSecret(own.d, asKey(algorithm.public!).point!, true).subarray(1, 33).slice(0, length / 8));
return fail('不支持的派生', 'NotSupportedError');
},
async deriveKey(algorithm: { name: string; salt?: BufferSource; info?: BufferSource }, key: CryptoKey, derived: { name: string; length?: number }, extractable: boolean, usages: string[]): Promise<CryptoKey> {
const length = derived.length ?? 256;
const raw = new Uint8Array(await subtle.deriveBits(algorithm as never, key, length));
return wrap({ type: 'secret', extractable, usages, algorithm: { name: derived.name, length }, raw });
},
};
/** randomUUID 同样只在安全上下文里有 */
function randomUuid(): `${string}-${string}-${string}-${string}-${string}` {
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6]! & 0x0f) | 0x40; bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/** crypto.subtle、crypto.randomUUID 不存在时(http 的局域网访问)装上替代实现。已经有原生的就什么也不做 */
/** force:即使有原生实现也用纯 JS 的(排查用,localStorage 里设 webdrop.softcrypto=1) */
export function installSubtleFallback(force = false): boolean {
if (typeof crypto === 'undefined') return false;
if (force) { Object.defineProperty(crypto, 'subtle', { value: subtle, configurable: true }); return true; }
if (!crypto.randomUUID) Object.defineProperty(crypto, 'randomUUID', { value: randomUuid, configurable: true });
if (crypto.subtle) return false;
Object.defineProperty(crypto, 'subtle', { value: subtle, configurable: true });
return true;
}
export { subtle as fallbackSubtle };
+2
View File
@@ -55,6 +55,8 @@ export interface ChannelSettingsPatch {
name?: string;
/** 空字符串表示移除头像 */
avatar?: string;
/** 有损的小图,随头像一起上传 */
avatarThumb?: string;
maxMembers?: number;
membersCanUpload?: boolean;
ttlSeconds?: number;
+16 -2
View File
@@ -1,8 +1,9 @@
import { installSubtleFallback } from './subtleFallback';
import { setApiBase, getServerInfo } from './worker/http';
import type { WorkspaceRoleRequest } from './generated/api';
import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys';
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
import { createChannel, forgetChannel, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
import { createChannel, forgetChannel, getSpaceAvatar, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
import { cancelFileUpload } from './worker/uploadQueue';
import { stopChannel } from './worker/channelTransport';
@@ -15,6 +16,8 @@ import { serial } from './worker/serial';
import { errorMessage } from './errors';
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
import { createFolder, createThread, createGroup, renameGroup, deleteGroup, createRole, updateRole, deleteRole, setRoleMembers, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads';
import { fetchAvatar } from './worker/avatars';
import { servePlaybackRange } from './worker/playbackStream';
import { refreshLoadedWorkspaces } from './worker/workspaceStructure';
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account';
@@ -45,7 +48,9 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
case 'beginPasskeyLogin': return beginPasskeyLogin(String(payload.username ?? ''));
case 'finishPasskeyLogin': return finishPasskeyLogin(String(payload.username ?? ''), id, String(payload.credential ?? ''), payload.prf as ArrayBuffer | undefined, payload.remember !== false);
case 'unlockPasskeyLogin': return unlockPasskeyLogin(String(payload.password ?? ''), payload.remember !== false);
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); void refreshLoadedWorkspaces(); return accountStatus();
case 'getAvatar': return fetchAvatar(String(payload.username ?? ''), Number(payload.version ?? 0), payload.full === true);
case 'getSpaceAvatar': return getSpaceAvatar(id);
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), payload.avatar as Parameters<typeof updatePersonal>[1]); void refreshLoadedWorkspaces(); return accountStatus();
case 'accountDevices': return listAccountDevices();
case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true;
case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus();
@@ -127,6 +132,12 @@ function laneOf(operation: string, payload: Record<string, unknown>): string | u
scope.onmessage = (event) => {
const request = event.data;
// 边下边播的取数请求:数据通过随消息转移来的端口直接回给 Service Worker,不走请求/回复
if (request.operation === 'streamRange') {
const port = event.ports[0];
if (port) void servePlaybackRange(port, request.payload as Parameters<typeof servePlaybackRange>[1]);
return;
}
const lane = laneOf(request.operation, request.payload as Record<string, unknown>);
const run = () => dispatch(request.operation, request.payload);
const task = lane ? serial(lane, run) : run();
@@ -138,3 +149,6 @@ scope.onmessage = (event) => {
}),
);
};
// http 的局域网访问没有 crypto.subtle:装上纯 JS 的替代实现(原生存在时什么也不做)
installSubtleFallback(self.name.includes('softcrypto'));
+15 -5
View File
@@ -1,5 +1,5 @@
import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../database';
import { apiFetch } from './http';
import { apiFetch, jsonBody } from './http';
import { clearResourceSessions, getSession } from './sessions';
import { setAccountIdentity, requireAccount } from './accountSession';
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto';
@@ -11,6 +11,8 @@ import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount,
import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api';
import { loadPersonal, personalProfile } from './accountProfile';
import { primeAvatar } from './avatars';
import type { AvatarChange } from '../protocol/account';
import type { AccountProfile, AccountProfileRequest } from '../generated/api';
import type { PersonalProfile } from '../protocol/account';
@@ -206,7 +208,8 @@ export async function unlockAuthenticatedAccount(identity: AccountSessionBody, k
export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }> {
return { username: account?.username ?? null, revision: account?.revision ?? 0,
remembered: account?.remember ?? false, unlockUsername: passkeyUnlockUsername(),
...(account?.personal ? { personal: account.personal } : {}),
// 界面每隔几秒就读一次状态:不能每次都带着几 MB 的头像过 postMessage,头像由界面另外取
...(account?.personal ? { personal: { displayName: account.personal.displayName, modifiedAt: account.personal.modifiedAt, hasAvatar: account.personal.hasAvatar ?? Boolean(account.personal.avatar) } } : {}),
...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) };
}
export async function logoutAccount(): Promise<void> {
@@ -230,13 +233,20 @@ export async function clearSavedLogin(): Promise<void> {
}
await forgetAccount(); await forgetLoginFields();
}
export async function updatePersonal(displayName: string, avatar?: string): Promise<void> {
export async function updatePersonal(displayName: string, change?: AvatarChange): Promise<void> {
if (!account) throw new Error('请先登录账号');
const session = account;
const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName, avatar } satisfies AccountProfileRequest) }).then(r => r.json() as Promise<AccountProfile>);
const request: AccountProfileRequest = change === 'keep' ? { displayName, keepAvatar: true }
: change ? { displayName, keepAvatar: false, avatar: change.avatar, avatarThumb: change.thumb } : { displayName, keepAvatar: false };
const { body, headers: encoding } = await jsonBody(request);
const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), ...encoding }, body }).then(r => r.json() as Promise<AccountProfile>);
stillActive(session);
const previous = session.personal?.avatar;
session.personal = personalProfile(value);
// 本机刚设置的头像直接用,不用再下载;只改名字时沿用现有的
const shown = change && change !== 'keep' ? change.thumb : change === 'keep' ? previous : undefined;
if (shown) { session.personal.avatar = shown; session.personal.hasAvatar = true; }
primeAvatar(value.username, value.modifiedAt, shown);
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision });
}
+6 -2
View File
@@ -1,12 +1,16 @@
import { apiFetch } from './http';
import { fetchAvatar } from './avatars';
import type { AccountProfile } from '../generated/api';
import type { PersonalProfile } from '../protocol/account';
export function personalProfile(value: AccountProfile): PersonalProfile {
return { displayName: value.displayName, avatar: value.avatar ?? undefined, modifiedAt: value.modifiedAt };
return { displayName: value.displayName, hasAvatar: value.hasAvatar || Boolean(value.avatar), ...(value.avatar ? { avatar: value.avatar } : {}), modifiedAt: value.modifiedAt };
}
export async function loadPersonal(token: string): Promise<PersonalProfile> {
const headers = { 'X-WD-Account': token };
const profile = await apiFetch('/accounts/profile', { headers }).then(r => r.json() as Promise<AccountProfile>);
return personalProfile(profile);
const personal = personalProfile(profile);
// 头像后台取,不挡住登录;取到后补进来,之后发消息的头像快照就有了
if (personal.hasAvatar && !personal.avatar) void fetchAvatar(profile.username, profile.modifiedAt).then((avatar) => { if (avatar) personal.avatar = avatar; });
return personal;
}
+68
View File
@@ -0,0 +1,68 @@
import { apiFetch } from './http';
import { accountToken } from './accountSession';
/**
* 头像不再随成员资料、空间结构一起下发(动图头像有几 MB,每次刷新结构都带上会把打开空间拖得很慢)。
* 资料里只有「有没有头像」和修改时间,头像另取一次并缓存;修改时间变了才重新取,
* 服务端对头像带 ETag,浏览器重新打开时只回 304。
*/
interface Entry { version: number; value?: string; loading?: Promise<string | undefined> }
const cache = new Map<string, Entry>();
const waiting: (() => void)[] = [];
let running = 0;
const PARALLEL = 4;
/** 已经取到的头像(没取到或版本不对返回 undefined) */
export function cachedAvatar(username: string, version: number): string | undefined {
const entry = cache.get(username);
return entry && entry.version === version ? entry.value : undefined;
}
/** 本机刚设置的头像,直接放进缓存,不用再下载一遍 */
export function primeAvatar(username: string, version: number, value: string | undefined): void {
cache.set(username, { version, ...(value ? { value } : {}) });
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
async function slot<T>(task: () => Promise<T>): Promise<T> {
if (running >= PARALLEL) await new Promise<void>((resolve) => waiting.push(resolve));
running += 1;
try { return await task(); }
finally { running -= 1; waiting.shift()?.(); }
}
async function download(username: string, token: string, full: boolean): Promise<string> {
// no-cache:浏览器每次都带 ETag 去验证,没变化就是 304,不用重新下载
const response = await apiFetch(`/accounts/users/${encodeURIComponent(username)}/avatar${full ? '?size=full' : ''}`, { headers: { 'X-WD-Account': token }, cache: 'no-cache' });
return blobToDataUrl(await response.blob());
}
/** 默认取有损的缩略图(列表、聊天用,小,有内存缓存);full 取原图(预览时才用,不放进内存缓存) */
export function fetchAvatar(username: string, version: number, full = false): Promise<string | undefined> {
if (full) {
const token = accountToken();
return token ? download(username, token, true).catch(() => undefined) : Promise.resolve(undefined);
}
const entry = cache.get(username);
if (entry && entry.version === version) return entry.loading ?? Promise.resolve(entry.value);
const token = accountToken();
if (!token) return Promise.resolve(undefined);
const next: Entry = { version };
next.loading = slot(async () => {
try {
const value = await download(username, token, false);
next.value = value;
return value;
} catch { cache.delete(username); return undefined; }
});
cache.set(username, next);
return next.loading;
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { canDecryptRecord, objectAuthorAccount, objectEventProjection, readDataK
import { getResource, cacheResource, type SavedResource } from '../database';
import { channelCache } from './resourceMetadata';
import { cachedPerson, loadWorkspace } from './workspaceStructure';
import { cachedAvatar } from './avatars';
import { displayAvatar } from './senderSnapshot';
import type { FileItem, Item, SenderInfo, Snapshot } from '../types';
import { previewKindOf } from '@/lib/fileKind';
@@ -37,7 +38,8 @@ async function authorOf(resourceId: string, itemId: string): Promise<SenderInfo
const username = objectAuthorAccount(resourceId, itemId);
if (!username) return undefined;
const person = cachedPerson(resourceId, username);
const avatar = person?.avatar ? await displayAvatar(person.avatar) : undefined;
const full = person?.hasAvatar ? cachedAvatar(person.username, person.modifiedAt) : undefined;
const avatar = full ? await displayAvatar(full) : undefined;
return { username, displayName: person?.displayName || username, ...(avatar ? { avatar } : {}) };
}
+15 -3
View File
@@ -3,7 +3,7 @@ import { cacheResource, getResource, listResources, removeResource, saveResource
import type { ParsedChannelLink } from '@/lib/channelLink';
import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types';
import { apiFetch, readJson, getServerInfo } from './http';
import { jsonBody, apiFetch, readJson, getServerInfo } from './http';
import { channelCache, isOfflineFailure } from './resourceMetadata';
import { accountToken } from './accountSession';
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
@@ -153,15 +153,17 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
const body: SettingsRequest = {};
if (patch.name !== undefined) body.name = patch.name.trim();
if (patch.avatar !== undefined) body.avatar = patch.avatar;
if (patch.avatarThumb) body.avatar_thumb = patch.avatarThumb;
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy;
if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword;
const { body: payload, headers: encoding } = await jsonBody(body, Boolean(patch.avatar));
const info = await apiFetch(`/channels/${resourceId}/settings`, {
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
method: 'PATCH', headers: { ...session.authHeaders('owner'), ...encoding },
body: payload,
}).then((response) => response.json() as Promise<ChannelWire>);
session.verifyChannelInfo(info);
const updated = await cacheResource(resource, channelCache(info));
@@ -175,3 +177,13 @@ export async function forgetChannel(id: string): Promise<void> {
await removeResource(id);
}
/** 空间头像原图:信息里只带缩略图,预览时才下载原图 */
export async function getSpaceAvatar(resourceId: string): Promise<string | undefined> {
const { session } = await getSession(resourceId);
try {
const response = await apiFetch(`/channels/${resourceId}/avatar`, { headers: session.authHeaders('read'), cache: 'no-cache' });
const blob = await response.blob();
return await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); });
} catch { return undefined; }
}
+10 -1
View File
@@ -47,7 +47,8 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise<Re
const response = await fetch(apiPath(path), {
...init,
headers,
cache: 'no-store',
// 头像这类带 ETag 的资源可以让浏览器重新验证(304),其余一律不缓存
cache: init.cache ?? 'no-store',
credentials: 'omit',
referrerPolicy: 'no-referrer',
});
@@ -136,3 +137,11 @@ export async function readJson<T>(path: string, headers: Record<string, string>)
return apiFetch(path, { headers }).then((response) => response.json() as Promise<T>);
}
/** JSON 请求体,浏览器支持时用 gzip 压缩(头像是 base64,压缩后能省下约四分之一的上传量) */
export async function jsonBody(value: unknown, compress = true): Promise<{ body: BodyInit; headers: Record<string, string> }> {
const text = JSON.stringify(value);
if (!compress || typeof CompressionStream === 'undefined') return { body: text, headers: { 'Content-Type': 'application/json' } };
const zipped = await new Response(new Blob([text]).stream().pipeThrough(new CompressionStream('gzip'))).blob();
return { body: zipped, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } };
}
@@ -4,6 +4,7 @@ import { getResource, cacheResource } from '../database';
import { getSession } from './sessions';
import { apiFetch } from './http';
import { publishWorkspace } from './presentation';
import { cachedAvatar, fetchAvatar } from './avatars';
import { displayAvatar } from './senderSnapshot';
const current = new Map<string, WorkspaceBody>();
@@ -19,11 +20,31 @@ async function publish(id: string, body: WorkspaceBody): Promise<WorkspaceBody>
current.set(id, body);
const resource = await getResource(id);
if (resource) await cacheResource(resource, { workspaceMetadata: body });
const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await displayAvatar(person.avatar) : undefined })));
await emit(id, body);
if (current.get(id) !== body) return current.get(id) ?? body;
publishWorkspace(id, { t: 'structure', state: body.structure, people });
void loadAvatars(id, body);
return body;
}
/** 先把成员列表发出去(没有头像时结构很小,打开空间立刻可用),头像稍后逐个补上 */
async function emit(id: string, body: WorkspaceBody): Promise<void> {
const people = await Promise.all(body.people.map(async person => {
const full = person.hasAvatar ? cachedAvatar(person.username, person.modifiedAt) : undefined;
return { ...person, avatar: full ? await displayAvatar(full) : undefined };
}));
if (current.get(id) !== body) return;
publishWorkspace(id, { t: 'structure', state: body.structure, people });
}
const republishing = new Set<string>();
async function loadAvatars(id: string, body: WorkspaceBody): Promise<void> {
const missing = body.people.filter(person => person.hasAvatar && cachedAvatar(person.username, person.modifiedAt) === undefined);
if (!missing.length) return;
await Promise.all(missing.map(person => fetchAvatar(person.username, person.modifiedAt)));
const latest = current.get(id);
if (!latest || republishing.has(id)) return;
republishing.add(id);
try { await emit(id, latest); } finally { republishing.delete(id); }
}
/** 结构由流里的 settings 通知保持最新;发消息等热路径直接用缓存,不再每次多等一个往返 */
export async function cachedWorkspace(id: string): Promise<WorkspaceBody> {
return current.get(id) ?? loadWorkspace(id);
+11 -1
View File
@@ -8,8 +8,13 @@ let nextId = 1;
const pending = new Map<number, Pending>();
const listeners = new Map<string, Set<(event: BridgeEvent, blob?: Blob) => void>>();
/** 排查用:localStorage 里设 webdrop.softcrypto=1,强制用纯 JS 的加密实现(模拟 http 局域网访问) */
export function forcedSoftCrypto(): boolean {
try { return localStorage.getItem('webdrop.softcrypto') === '1'; } catch { return false; }
}
function getWorker(): Worker {
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: 'webdrop-crypto' });
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: forcedSoftCrypto() ? 'webdrop-crypto softcrypto' : 'webdrop-crypto' });
worker.onmessage = (message: MessageEvent<WorkerReply>) => {
const reply = message.data;
if ('requestId' in reply) {
@@ -70,3 +75,8 @@ export function subscribeWorker<T extends BridgeEvent>(resourceId: string, liste
}
export type { BridgeEvent };
/** 直接给加密线程发消息(不等回复),可以带 MessagePort 之类要转移的对象 */
export function postToWorker(message: { operation: string; payload: unknown }, transfer: Transferable[]): void {
getWorker().postMessage(message, transfer);
}
@@ -180,3 +180,11 @@
display: flex;
gap: var(--space-2);
}
.avatarButton {
padding: 0;
border: 0;
border-radius: var(--radius-lg);
background: none;
cursor: zoom-in;
}
@@ -3,7 +3,9 @@ import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
import { Button } from '@/ui/Button';
import { Icon, type IconName } from '@/ui/Icon';
import { Sheet } from '@/ui/Sheet';
import { AvatarEditor } from '../home/AvatarEditor';
import { api } from '@/core-bridge/instance';
import { AvatarPreview } from '../common/AvatarPreview';
import { AvatarEditor, type AvatarPick } from '../home/AvatarEditor';
import { checkAvatarSource } from '../home/avatarSource';
import { errorMessage } from '@/core-bridge/errors';
import { useToast } from '@/store/toast';
@@ -46,7 +48,11 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
const [joinPassword, setJoinPassword] = useState('');
const [clearPassword, setClearPassword] = useState(false);
const [tab, setTab] = useState<Tab>('basic');
const [avatar, setAvatar] = useState(channel.avatar);
// 新选的头像(原图 + 缩略图)、是否移除;没动就保持现有的。界面里默认只有有损缩略图,原图预览时才下载
const [pick, setPick] = useState<AvatarPick>();
const [removed, setRemoved] = useState(false);
const [previewing, setPreviewing] = useState(false);
const avatar = pick ? pick.thumb : removed ? undefined : channel.avatar;
const [editing, setEditing] = useState<File>();
const fileInput = useRef<HTMLInputElement>(null);
const spaceName = name.trim() || channel.name || '空间';
@@ -60,7 +66,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
setSaving(true);
setFailed(false);
try {
await onSave({ name: name.trim(), ...(avatar !== channel.avatar ? { avatar: avatar ?? '' } : {}), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
await onSave({ name: name.trim(), ...(pick ? { avatar: pick.avatar, avatarThumb: pick.thumb } : removed ? { avatar: '' } : {}), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
onClose();
} catch {
@@ -76,11 +82,12 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); }
}
const dirty = avatar !== channel.avatar || name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
const dirty = pick !== undefined || removed || name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
|| ttlSeconds !== channel.ttlSeconds || fileMaxDownloads !== channel.maxDownloads || joinPolicy !== channel.joinPolicy || joinPassword !== '' || clearPassword;
const invalid = maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8);
return (
<>{previewing && avatar ? <AvatarPreview title="空间头像" thumb={avatar} load={async () => pick?.avatar ?? api.spaceAvatar()} onClose={() => setPreviewing(false)} /> : null}
<Sheet title="空间设置" onClose={onClose} wide={tab === 'roles' ? 'xl' : true}>
<div className={`${styles.layout} ${tab === 'roles' ? styles.tall : ''}`}>
<div className={styles.tabs} role="tablist" aria-orientation="vertical" aria-label="空间设置分类">
@@ -88,16 +95,16 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
<Icon name={item.icon} size={18} /><span>{item.label}</span></button>)}
</div>
<div className={styles.content}>
{editing ? <AvatarEditor kind="space" file={editing} name={spaceName} onCancel={() => setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} />
{editing ? <AvatarEditor kind="space" file={editing} name={spaceName} onCancel={() => setEditing(undefined)} onDone={(next) => { setPick(next); setRemoved(false); setEditing(undefined); }} />
: <section className={styles.pane} role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}>
{tab === 'basic' ? <>
<div className={styles.avatarRow}>
{avatar ? <img className={styles.avatar} src={avatar} alt="空间头像" /> : <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(spaceName)[0]?.toLocaleUpperCase()}</span>}
{avatar ? <button type="button" className={styles.avatarButton} aria-label="查看空间头像原图" onClick={() => setPreviewing(true)}><img className={styles.avatar} src={avatar} alt="空间头像" /></button> : <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(spaceName)[0]?.toLocaleUpperCase()}</span>}
<div className={styles.avatarSide}>
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择空间头像文件" hidden onChange={(event) => { void chooseAvatar(event.target.files?.[0]); event.target.value = ''; }} />
<div className={styles.avatarButtons}>
<Button icon="image" disabled={!owner} onClick={() => fileInput.current?.click()}>更换空间头像</Button>
{avatar && owner ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
{avatar && owner ? <Button variant="ghost" onClick={() => { setPick(undefined); setRemoved(true); }}>移除</Button> : null}
</div>
<span className={styles.note}>PNG、JPEG、WebP 或 GIF 动图,所有成员可见</span>
</div>
@@ -143,6 +150,6 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
</div>
</div>
</Sheet>
</Sheet></>
);
}
+5 -1
View File
@@ -9,6 +9,8 @@ import { TransferProgress } from '@/features/item/TransferProgress';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
import { accountApi } from '@/core-bridge/account';
import { AvatarPreview } from '../common/AvatarPreview';
import { ChatAttachment, hasInlinePreview } from './ChatAttachment';
import { NameWithBadges } from './RoleBadges';
import styles from './ChatPane.module.css';
@@ -24,6 +26,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
const download = useDownload();
const [downloading, setDownloading] = useState(false);
const [menu, setMenu] = useState<{ x: number; y: number }>();
const [previewing, setPreviewing] = useState(false);
if (!item) return null;
const sender = item.author;
const name = senderLabel(item);
@@ -50,7 +53,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
return <article onClick={tap} className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`} aria-busy={sending}>
{compact ? <time className={styles.gutterTime} dateTime={new Date(item.ts).toISOString()}>{formatTime(item.ts)}</time>
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
: sender?.avatar ? <button type="button" className={styles.avatarButton} aria-label={`查看 ${name} 的头像原图`} onClick={() => setPreviewing(true)}><img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} /></button>
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
<div className={styles.body}>
{!compact ? <header><NameWithBadges username={sender?.username}><strong className={styles.senderName} title={sender?.username}>{name}</strong></NameWithBadges><time>{formatTime(item.ts)}</time></header> : null}
@@ -74,6 +77,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
</div>
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
{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}
</article>;
}
@@ -175,3 +175,13 @@
justify-content: flex-end;
gap: var(--space-2);
}
.avatarButton {
display: block;
flex: none;
padding: 0;
border: 0;
border-radius: var(--radius-avatar);
background: none;
cursor: zoom-in;
}
+8 -3
View File
@@ -7,14 +7,16 @@ import { useToast } from '@/store/toast';
import { useWorkspace } from '@/store/workspace';
import { permissionsOf } from '@/lib/roles';
import { useAccount } from '@/store/account';
import { accountApi } from '@/core-bridge/account';
import { Button } from '@/ui/Button';
import { AvatarPreview } from '../common/AvatarPreview';
import { NameWithBadges } from './RoleBadges';
import styles from './MembersPane.module.css';
interface Props { owner: boolean; /** 空间主,或所在身份组被授予「移除成员」 */ canRemove: boolean; refreshKey: number; onInvite: () => void }
function Avatar({ person, name }: { person?: SenderInfo; name: string }) {
return person?.avatar ? <img className={styles.avatar} src={person.avatar} alt="" />
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>
: <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(name)[0]?.toLocaleUpperCase() ?? '?'}</span>;
}
@@ -36,6 +38,7 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) {
const [members, setMembers] = useState<ChannelMember[]>([]);
const [pending, setPending] = useState<AccountInvitation[]>([]);
const [error, setError] = useState('');
const [viewing, setViewing] = useState<string>();
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
@@ -70,7 +73,7 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) {
const person = people[username];
const name = person?.displayName || username;
return <li className={`${styles.row} ${invited || (!isOnline && !isOwner) ? styles.dim : ''}`} key={`${invited ? 'i' : 'm'}:${username}`}>
<Avatar person={person} name={name} />
<Avatar person={person} name={name} onOpen={() => setViewing(username)} />
<span className={styles.who}><strong><NameWithBadges username={username}>{name}</NameWithBadges></strong>
{detail || name !== username ? <small>{[name !== username ? username : '', detail].filter(Boolean).join(' · ')}</small> : null}</span>
{isOnline ? <span className={styles.online} role="img" aria-label="在线" /> : null}
@@ -83,7 +86,9 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) {
item.devices > 1 || item.online_devices > 1 ? `${item.online_devices}/${item.devices} 台设备在线` : undefined,
false, removable(item), item.role === 'owner', item.online);
const viewed = viewing ? people[viewing] : undefined;
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}
<div className={styles.head}>
<span>成员 {members.length}{maxMembers ? `/${maxMembers}` : ''}</span>
{owner ? <Button variant="primary" icon="plus" onClick={onInvite}>邀请成员</Button> : null}
@@ -0,0 +1,25 @@
.stage {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
.image {
display: block;
width: min(100%, 420px);
max-height: 60dvh;
border-radius: var(--radius-lg);
background: var(--surface-2);
object-fit: contain;
}
.blur {
image-rendering: auto;
}
.note {
margin: 0;
color: var(--text-dim);
font-size: 12px;
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import { Sheet } from '@/ui/Sheet';
import styles from './AvatarPreview.module.css';
interface Props {
title: string;
/** 已经在界面上的有损缩略图:原图下载好之前先显示它 */
thumb: string;
/** 取原图;没有单独的原图(或取不到)时返回 undefined,继续显示缩略图 */
load: () => Promise<string | undefined>;
onClose: () => void;
}
/** 头像预览:默认分发的是有损缩略图,点开才下载无损的原图 */
export function AvatarPreview({ title, thumb, load, onClose }: Props) {
const [full, setFull] = useState<string>();
const [done, setDone] = useState(false);
useEffect(() => {
let active = true;
void load().then((value) => { if (active) { setFull(value); setDone(true); } }).catch(() => { if (active) setDone(true); });
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <Sheet title={title} onClose={onClose}>
<div className={styles.stage}>
<img className={`${styles.image} ${full ? '' : styles.blur}`} src={full ?? thumb} alt={title} />
{!done ? <p className={styles.note} role="status">正在加载原图…</p> : full ? null : <p className={styles.note}>没有更清晰的原图了</p>}
</div>
</Sheet>;
}
@@ -214,3 +214,49 @@
}
}
.avatarRow {
display: flex;
align-items: center;
gap: var(--space-3);
}
.avatarPick {
display: grid;
flex: 0 0 56px;
place-items: center;
width: 56px;
height: 56px;
padding: 0;
overflow: hidden;
border: 1px dashed var(--line-strong);
border-radius: var(--radius-avatar);
background: var(--surface-2);
color: var(--text-dim);
font-size: 20px;
cursor: pointer;
}
.avatarPick img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarText {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-dim);
font-size: 13px;
}
.avatarClear {
padding: 0;
border: 0;
background: none;
color: var(--text-dim);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
+33 -8
View File
@@ -7,6 +7,10 @@ import { Button } from '@/ui/Button';
import styles from './AccountPanel.module.css';
import { navigate, channelPath } from '@/app/navigation';
import { passkeysAvailable } from '@/core-bridge/passkeys';
import { useToast } from '@/store/toast';
import { errorMessage } from '@/core-bridge/errors';
import { AvatarEditor, type AvatarPick } from './AvatarEditor';
import { checkAvatarSource } from './avatarSource';
/** 未登录时的登录 / 注册 / 解锁。登录后的账号设置见 AccountSettings */
export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'auth' }) {
@@ -32,6 +36,12 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
const [remember, setRemember] = useState(true);
const [saved, setSaved] = useState(false);
const edited = useRef(false);
// 浏览器只在 https 或 localhost 下开放 crypto.subtle;用局域网 IP 走 http 访问时改用纯 JS 的替代实现(见 subtleFallback)
const insecure = typeof window !== 'undefined' && !window.isSecureContext;
// 注册时可以顺便选个头像(可选):在这里裁剪好,账号建好后一起设置
const [pick, setPick] = useState<AvatarPick>();
const [editing, setEditing] = useState<File>();
const fileInput = useRef<HTMLInputElement>(null);
useEffect(() => {
if (account) return;
let active = true;
@@ -46,12 +56,17 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
const mismatch = creating && confirm !== '' && password !== confirm;
// 与服务端一致:ID 3–32 位,仅字母、数字、_、-
const rules = [
{ text: 'ID 为 3–32 个字符', met: username.length >= 3 && username.length <= 32 },
{ text: 'ID 只能包含字母、数字、下划线 _ 和连字符 -', met: username !== '' && /^[A-Za-z0-9_-]+$/.test(username) },
{ text: '密码至少 12 个字符', met: password.length >= 12 },
{ text: '两次输入的密码一致', met: confirm !== '' && password === confirm },
{ text: 'ID 3–32 位', met: username.length >= 3 && username.length <= 32 },
{ text: 'ID 仅限字母、数字、_ 和 -', met: username !== '' && /^[A-Za-z0-9_-]+$/.test(username) },
{ text: '密码至少 12 位', met: password.length >= 12 },
{ text: '两次密码一致', met: confirm !== '' && password === confirm },
];
const unmet = creating && rules.some((rule) => !rule.met);
async function chooseAvatar(file?: File) {
if (!file) return;
try { await checkAvatarSource(file); setEditing(file); }
catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); }
}
async function clearSaved() {
edited.current = true;
try { await clearSavedLogin(); setSaved(false); setRemember(false); setUsername(''); setPassword(''); setMessage(account ? '本机保存已清除,当前页面仍可使用;刷新后需要重新登录。' : '已清除本机保存的账号和密码。'); }
@@ -64,8 +79,8 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
if (creating && password !== confirm) { setMessage('两次输入的密码不一致'); return; }
setMessage('');
try {
if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember, displayName);
setPassword(''); setConfirm(''); setDisplayName('');
if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember, displayName, pick);
setPassword(''); setConfirm(''); setDisplayName(''); setPick(undefined);
await Promise.all([refreshChannels(), refreshShares()]);
setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。');
const pending = useChannels.getState().pendingLink;
@@ -83,14 +98,24 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
</div>
{account ? <span className={styles.user}>{account}</span> : null}
</div>
{account ? null : (
{account ? null : editing ? (
<AvatarEditor file={editing} name={displayName.trim() || username || '我'} onCancel={() => setEditing(undefined)} onDone={(next) => { setPick(next); setEditing(undefined); }} />
) : (
<form className={styles.form} onSubmit={(event) => { void submit(event); }}>
{variant === 'auth' && !unlockUsername ? <div className={styles.authMode} role="group" aria-label="选择登录或注册">
<button type="button" aria-pressed={!register} onClick={() => { edited.current = true; setRegister(false); setConfirm(''); setMessage(''); }}>登录</button>
<button type="button" aria-pressed={register} onClick={() => { edited.current = true; setRegister(true); setMessage(''); }}>创建账号</button>
</div> : null}
{insecure ? <p className={styles.note}>当前是 http 连接,使用纯 JS 加密(登录和同步会慢一点)。仅建议在局域网试用,正式使用请用 https。</p> : null}
{unlockUsername ? <p className={styles.note}>已用Passkey确认账号,请输入账号密码解锁资源。</p> : null}
<label>账号 ID<input name="username" autoComplete="username" minLength={3} maxLength={32} value={unlockUsername ?? username} disabled={Boolean(unlockUsername)} onChange={(event) => { edited.current = true; setUsername(event.target.value); }} required /></label>
{creating ? <div className={styles.avatarRow}>
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择头像文件" hidden onChange={(event) => { void chooseAvatar(event.target.files?.[0]); event.target.value = ''; }} />
<button type="button" className={styles.avatarPick} aria-label={pick ? '更换头像' : '选择头像'} onClick={() => fileInput.current?.click()}>
{pick ? <img src={pick.thumb} alt="头像预览" /> : <span aria-hidden="true">+</span>}
</button>
<span className={styles.avatarText}>头像(可选){pick ? <button type="button" className={styles.avatarClear} onClick={() => setPick(undefined)}>移除</button> : null}</span>
</div> : null}
{creating ? <label>个人名称(可选)<input name="display-name" autoComplete="nickname" maxLength={48} value={displayName} placeholder={username || '显示给其他成员看的名字'} onChange={(event) => { edited.current = true; setDisplayName(event.target.value); }} /></label> : null}
<label>密码<input name="password" autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => { edited.current = true; setPassword(event.target.value); }} required /></label>
{creating ? <label>确认密码<input name="confirm-password" autoComplete="new-password" type="password" minLength={12} value={confirm} aria-invalid={mismatch} onChange={(event) => { edited.current = true; setConfirm(event.target.value); }} required />
@@ -98,7 +123,7 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
{creating ? <ul className={styles.rules} aria-label="注册要求">
{rules.map((rule) => <li key={rule.text} data-met={rule.met}><span aria-hidden="true">{rule.met ? '✓' : '○'}</span>{rule.text}<span className={styles.srOnly}>{rule.met ? ',已满足' : ',未满足'}</span></li>)}
</ul> : null}
{creating ? <p className={styles.note}>账号 ID 是登录用的唯一标识,注册后不能改;个人名称是其他成员看到的名字,留空就显示账号 ID,之后可在个人资料里修改。密码用于在本机加密你的资源保险库,<strong>忘记后无法找回</strong>,请妥善保管。</p> : null}
{creating ? <p className={styles.note}>账号 ID 注册后不能改,头像和个人名称之后都可以改。<strong>密码忘记后无法找回</strong>。</p> : null}
<label className={styles.remember}><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />记住账号和密码(本机)</label>
<div className={styles.actions}>
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady || unmet}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : unlockUsername ? '解锁资源' : register ? '创建账号' : '登录并同步'}</Button>
@@ -344,3 +344,11 @@
gap: var(--space-2);
}
}
.avatarButton {
padding: 0;
border: 0;
border-radius: var(--radius-avatar);
background: none;
cursor: zoom-in;
}
+11 -4
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { Button } from '@/ui/Button';
import { clampCrop, exportAvatar, paintCrop, type Crop } from './avatarCrop';
import { canDecodeGif, decodeGif, exportGifAvatar, type GifFrame } from './avatarGif';
import { clampCrop, exportAvatar, exportAvatarThumb, paintCrop, type Crop } from './avatarCrop';
import { canDecodeGif, decodeGif, exportGifAvatar, exportGifThumb, type GifFrame } from './avatarGif';
import styles from './AvatarEditor.module.css';
const VIEW = 260;
@@ -25,6 +25,8 @@ function Preview({ image, crop, size, rounded }: { image: ImageBitmap; crop: Cro
/** 空间头像的动图上限(data URL 字符数),和服务端 AvatarData.MaxSpaceGifChars 一致并留余量 */
export const SPACE_GIF_LIMIT = 1_900_000;
export interface AvatarPick { avatar: string; thumb: string }
interface Props {
/** 个人头像是圆的;空间头像是圆角方块 */
kind?: 'person' | 'space';
@@ -32,7 +34,8 @@ interface Props {
/** 预览里显示的名称 */
name: string;
onCancel: () => void;
onDone: (dataUrl: string) => void;
/** avatar 是原图(预览和下载用),thumb 是有损的小图(列表、聊天默认只分发它) */
onDone: (pick: AvatarPick) => void;
}
/** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */
@@ -121,7 +124,11 @@ export function AvatarEditor({ file, name, onCancel, onDone, kind = 'person' }:
async function confirm() {
if (!image || busy) return;
setBusy(true);
try { onDone(frames.length ? await exportGifAvatar(frames, crop, VIEW, space ? SPACE_GIF_LIMIT : undefined) : await exportAvatar(image, crop, VIEW)); }
try {
onDone(frames.length
? { avatar: await exportGifAvatar(frames, crop, VIEW, space ? SPACE_GIF_LIMIT : undefined), thumb: await exportGifThumb(frames, crop, VIEW) }
: { avatar: await exportAvatar(image, crop, VIEW), thumb: await exportAvatarThumb(image, crop, VIEW) });
}
catch { setFailed(true); setBusy(false); }
}
+1
View File
@@ -53,6 +53,7 @@ function CreateForm({ onClose, onCreated, info }: Props & { info: ServerInfoBody
<label className={styles.check}><input type="checkbox" checked={opts.mode === 'enc'} onChange={(event) => set('mode', event.target.checked ? 'enc' : 'raw')} /><span>启用端到端加密</span></label>
<p className={opts.mode === 'enc' ? styles.secure : styles.warn}>{opts.mode === 'enc' ? '内容和文件名在设备端加密。' : '未加密的内容、文件名和频道信息对服务端可见。'}创建后不能改变加密方式。</p>
{opts.mode === 'enc' && !window.isSecureContext ? <p className={styles.warn}>当前是 http 连接:浏览器不提供加密接口,这里用的是纯 JS 实现(较慢),而且页面本身可能被网络上的人替换,端到端加密起不到防护作用。正式使用请用 https 访问。</p> : null}
<p className={styles.secure}>空间属于当前账号,并自动创建“常规”频道。默认通过账号邀请加入,成员在自己的设备上自动领取密钥包。</p>
<label className={styles.field}>
+17 -8
View File
@@ -3,7 +3,9 @@ import { errorMessage } from '@/core-bridge/errors';
import { useAccount } from '@/store/account';
import { useToast } from '@/store/toast';
import { Button } from '@/ui/Button';
import { AvatarEditor } from './AvatarEditor';
import { AvatarPreview } from '../common/AvatarPreview';
import { accountApi } from '@/core-bridge/account';
import { AvatarEditor, type AvatarPick } from './AvatarEditor';
import { checkAvatarSource } from './avatarSource';
import styles from './AccountSettings.module.css';
@@ -14,11 +16,15 @@ export function ProfileSettings() {
const update = useAccount((s) => s.updatePersonal);
const fileInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState(personal?.displayName ?? '');
const [avatar, setAvatar] = useState(personal?.avatar);
// 新选的头像(原图 + 缩略图)、是否要移除;都没动就保持服务端现有的
const [pick, setPick] = useState<AvatarPick>();
const [removed, setRemoved] = useState(false);
const [previewing, setPreviewing] = useState(false);
const avatar = pick ? pick.thumb : removed ? undefined : personal?.avatar;
const [editing, setEditing] = useState<File>();
useEffect(() => { setName(personal?.displayName ?? ''); setAvatar(personal?.avatar); }, [personal?.displayName, personal?.avatar]);
useEffect(() => { setName(personal?.displayName ?? ''); setPick(undefined); setRemoved(false); }, [personal?.displayName, personal?.modifiedAt]);
const changed = name.trim() !== (personal?.displayName ?? '') || avatar !== personal?.avatar;
const changed = name.trim() !== (personal?.displayName ?? '') || pick !== undefined || removed;
const shown = name.trim() || username;
async function choose(file?: File) {
@@ -29,25 +35,28 @@ export function ProfileSettings() {
async function submit(event: FormEvent) {
event.preventDefault();
if (!changed || busy) return;
try { await update(name.trim(), avatar); useToast.getState().push({ kind: 'ok', text: '个人资料已保存并同步' }); }
try { await update(name.trim(), pick ?? (removed ? undefined : 'keep')); useToast.getState().push({ kind: 'ok', text: '个人资料已保存并同步' }); }
catch (cause) { useToast.getState().push({ kind: 'error', text: `保存失败:${errorMessage(cause)}` }); }
}
if (editing) return <div className={styles.page}>
<h2>编辑头像</h2>
<AvatarEditor file={editing} name={shown} onCancel={() => setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} />
<AvatarEditor file={editing} name={shown} onCancel={() => setEditing(undefined)} onDone={(next) => { setPick(next); setRemoved(false); setEditing(undefined); }} />
</div>;
const preview = previewing && avatar ? <AvatarPreview title="我的头像" thumb={avatar} load={async () => pick?.avatar ?? accountApi.avatar(username, personal?.modifiedAt ?? 0, true)} onClose={() => setPreviewing(false)} /> : null;
return <form className={styles.page} onSubmit={(event) => { void submit(event); }} aria-label="个人资料">
<h2>个人资料</h2>
{preview}
<div className={styles.avatarRow}>
{avatar ? <img className={styles.avatarLarge} src={avatar} alt="个人头像" />
{avatar ? <button type="button" className={styles.avatarButton} aria-label="查看头像原图" onClick={() => setPreviewing(true)}><img className={styles.avatarLarge} src={avatar} alt="个人头像" /></button>
: <span className={`${styles.avatarLarge} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(shown)[0]?.toLocaleUpperCase() ?? '?'}</span>}
<div className={styles.avatarSide}>
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择头像文件" hidden onChange={(event) => { void choose(event.target.files?.[0]); event.target.value = ''; }} />
<div className={styles.row}>
<Button icon="image" onClick={() => fileInput.current?.click()}>更换头像</Button>
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
{avatar ? <Button variant="ghost" onClick={() => { setPick(undefined); setRemoved(true); }}>移除</Button> : null}
</div>
<span className={styles.hint}>PNG、JPEG 或 WebP,可在浏览器里裁剪</span>
</div>
+21
View File
@@ -71,3 +71,24 @@ export async function exportAvatar(image: Source, crop: Crop, from: number): Pro
}
throw new Error('头像压缩后仍然过大,请换一张图片');
}
export const THUMB_SIZE = 96;
/** 默认分发的有损缩略图:96×96 的 WebP(不支持时 PNG),压到 9 万字符以内。列表、聊天里都用它,原图预览时才下载 */
export async function exportAvatarThumb(image: Source, crop: Crop, from: number): Promise<string> {
for (const size of [THUMB_SIZE, 72, 48]) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('当前浏览器不支持图片处理');
paintCrop(ctx, image, crop, from, size);
for (const quality of [0.7, 0.5, 0.3]) {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', quality));
if (!blob) continue;
const url = await blobToDataUrl(blob);
if (url.length <= 90_000) return url;
}
}
throw new Error('头像缩略图生成失败');
}
+12 -2
View File
@@ -54,7 +54,7 @@ function toDataUrl(bytes: Uint8Array): string {
return `data:image/gif;base64,${btoa(binary)}`;
}
function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: number, step: number): string {
function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: number, step: number, colors = 256): string {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
@@ -66,7 +66,7 @@ function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: num
if (at % step !== 0 && at !== frames.length - 1) return;
paintCrop(ctx, frame.bitmap, crop, from, size);
const { data } = ctx.getImageData(0, 0, size, size);
const palette = quantize(data, 256, { format: 'rgba4444', oneBitAlpha: true });
const palette = quantize(data, colors, { format: 'rgba4444', oneBitAlpha: true });
const transparentIndex = palette.findIndex((color) => color.length > 3 && color[3] === 0);
encoder.writeFrame(applyPalette(data, palette, 'rgba4444'), size, size,
{ palette, delay: Math.round(delay), transparent: transparentIndex >= 0, transparentIndex: Math.max(0, transparentIndex) });
@@ -86,3 +86,13 @@ export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, f
}
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
}
/** 动图的有损缩略图:帧数、颜色数和尺寸都压小(最多约 30 帧,64 色,80 像素),控制在 40 万字符以内。列表、聊天里默认用它 */
export async function exportGifThumb(frames: readonly GifFrame[], crop: Crop, from: number): Promise<string> {
for (const [size, maxFrames, colors] of [[80, 30, 64], [64, 24, 32], [48, 16, 32]] as const) {
await new Promise((resolve) => setTimeout(resolve, 0));
const url = encode(frames, crop, from, size, Math.max(1, Math.ceil(frames.length / maxFrames)), colors);
if (url.length <= 400_000) return url;
}
throw new Error('动图缩略图生成失败');
}
+4
View File
@@ -1,3 +1,5 @@
import { installSubtleFallback } from '@/core-bridge/subtleFallback';
import { forcedSoftCrypto } from '@/core-bridge/workerClient';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from '@/app/App';
@@ -19,6 +21,8 @@ if (link?.kind === 'share') useShares.getState().setPendingLink(link.link);
initTheme();
void useServer.getState().load().catch(() => {});
installSubtleFallback(forcedSoftCrypto());
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+23 -5
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { accountApi } from '@/core-bridge/account';
import type { AccountStatus } from '@/core-bridge/account';
import type { AvatarChange } from '@/core-bridge/protocol/account';
import { useChannels } from './channels';
import { useShares } from './shares';
import { useWorkspace } from './workspace';
@@ -12,7 +13,7 @@ import { loginWithPasskey, passkeyApi } from '@/core-bridge/passkeys';
interface AccountState extends AccountStatus {
busy: boolean;
hydrated: boolean;
login: (username: string, password: string, create: boolean, remember?: boolean, displayName?: string) => Promise<void>;
login: (username: string, password: string, create: boolean, remember?: boolean, displayName?: string, avatar?: { avatar: string; thumb: string }) => Promise<void>;
passkeyLogin: (username: string, remember: boolean) => Promise<void>;
unlockPasskey: (password: string, remember: boolean) => Promise<void>;
cancelPasskey: () => Promise<void>;
@@ -20,7 +21,7 @@ interface AccountState extends AccountStatus {
logout: () => Promise<void>;
clearSavedLogin: () => Promise<void>;
sync: () => Promise<void>;
updatePersonal: (displayName: string, avatar?: string) => Promise<void>;
updatePersonal: (displayName: string, avatar?: AvatarChange) => Promise<void>;
}
let lastResourceRefresh = 0;
@@ -46,12 +47,12 @@ export const useAccount = create<AccountState>()((set, get) => ({
catch (error) { set({ busy: false, unlockUsername: undefined, error: error instanceof Error ? error.message : '账号解锁失败' }); throw error; }
},
cancelPasskey: async () => { await passkeyApi.cancel(); set({ unlockUsername: undefined, error: undefined }); },
login: async (username, password, create, remember = true, displayName = '') => {
login: async (username, password, create, remember = true, displayName = '', avatar) => {
set({ busy: true, error: undefined });
try {
let status = create ? await accountApi.register(username, password, remember) : await accountApi.login(username, password, remember);
// 注册时填的个人名称:账号已经建好,这一步失败不算注册失败,之后可在个人资料里补
if (create && displayName.trim()) status = await accountApi.updatePersonal(displayName.trim()).catch(() => status);
// 注册时填的个人名称和头像:账号已经建好,这一步失败不算注册失败,之后可在个人资料里补
if (create && (displayName.trim() || avatar)) status = await accountApi.updatePersonal(displayName.trim() || username, avatar).catch(() => status);
clearViews(); await refreshResources();
set({ ...status, busy: false, hydrated: true });
} catch (error) {
@@ -88,3 +89,20 @@ export const useAccount = create<AccountState>()((set, get) => ({
catch (error) { set({ error: error instanceof Error ? error.message : '资料保存失败', busy: false }); throw error; }
},
}));
// 头像不跟着账号状态一起传(动图有几 MB,状态每隔几秒就刷新一次):状态里只有 hasAvatar,头像在这里另取,
// 并且状态刷新后(personal 被换成没有头像的)按修改时间沿用已经取到的那张
let loadingAvatar = '';
useAccount.subscribe((state, previous) => {
const personal = state.personal;
if (!personal?.hasAvatar || personal.avatar || !state.username) return;
const known = previous.personal;
if (known?.avatar && known.modifiedAt === personal.modifiedAt) { useAccount.setState({ personal: { ...personal, avatar: known.avatar } }); return; }
const key = `${state.username}:${personal.modifiedAt}`;
if (loadingAvatar === key) return;
loadingAvatar = key;
void accountApi.avatar(state.username, personal.modifiedAt).then((avatar) => {
const now = useAccount.getState().personal;
if (avatar && now && now.modifiedAt === personal.modifiedAt && !now.avatar) useAccount.setState({ personal: { ...now, avatar } });
}).catch(() => undefined).finally(() => { if (loadingAvatar === key) loadingAvatar = ''; });
});
+1 -1
View File
@@ -13,7 +13,7 @@ describe('reduce', () => {
groups: [{ id: 'group-a', name: '项目', createdAt: 1 }],
folders: [{ id: 'folder-a', name: '目录', threadId: 'channel-a' }],
locations: [{ itemId: 'a', threadId: 'channel-a', folderId: 'folder-a', authorAccount: 'alice' }] },
people: [{ username: 'alice', displayName: '服务端名称', modifiedAt: 1 }] };
people: [{ username: 'alice', displayName: '服务端名称', hasAvatar: false, modifiedAt: 1 }] };
let state = reduce(initialData, stateEvent);
state = reduce(state, { t: 'item', item: { ...text('a', 1), threadId: 'old', author: { username: 'mallory', displayName: '伪造名称' } } });
expect(state.byId.a?.threadId).toBe('channel-a'); expect(state.byId.a?.folderId).toBe('folder-a');