diff --git a/web/src/core-bridge/worker.ts b/web/src/core-bridge/worker.ts index 0b65d99..c12a39d 100644 --- a/web/src/core-bridge/worker.ts +++ b/web/src/core-bridge/worker.ts @@ -15,6 +15,7 @@ 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 { refreshLoadedWorkspaces } from './worker/workspaceStructure'; import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account'; interface WorkerRequest { requestId: number; operation: string; payload: unknown } @@ -44,7 +45,7 @@ async function dispatch(operation: string, input: unknown): Promise { 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); return accountStatus(); + case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); 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(); diff --git a/web/src/core-bridge/worker/channelEvents.ts b/web/src/core-bridge/worker/channelEvents.ts index 9b632c3..9a9e62b 100644 --- a/web/src/core-bridge/worker/channelEvents.ts +++ b/web/src/core-bridge/worker/channelEvents.ts @@ -3,7 +3,7 @@ import { canDecryptRecord, objectAuthorAccount, objectEventProjection, readDataK import { getResource, cacheResource, type SavedResource } from '../database'; import { channelCache } from './resourceMetadata'; import { cachedPerson, loadWorkspace } from './workspaceStructure'; -import { avatarThumbnail } from './senderSnapshot'; +import { displayAvatar } from './senderSnapshot'; import type { FileItem, Item, SenderInfo, Snapshot } from '../types'; import { previewKindOf } from '@/lib/fileKind'; import { blobs, readers } from './sessions'; @@ -37,7 +37,7 @@ async function authorOf(resourceId: string, itemId: string): Promise { return { username: sender.username, displayName: sender.displayName, ...(avatar ? { avatar } : {}) }; } +/** 本机显示用:动图头像原样保留(会动);其他压成小缩略图。发进消息里的快照仍用 avatarThumbnail,不能把几 MB 的动图塞进每条消息 */ +export function displayAvatar(value: string): Promise { + return value.startsWith('data:image/gif') ? Promise.resolve(value) : avatarThumbnail(value); +} + export function avatarThumbnail(value: string): Promise { let thumbnail = thumbnails.get(value); if (!thumbnail) { diff --git a/web/src/core-bridge/worker/workspaceStructure.ts b/web/src/core-bridge/worker/workspaceStructure.ts index 09f5bf3..2644785 100644 --- a/web/src/core-bridge/worker/workspaceStructure.ts +++ b/web/src/core-bridge/worker/workspaceStructure.ts @@ -4,7 +4,7 @@ import { getResource, cacheResource } from '../database'; import { getSession } from './sessions'; import { apiFetch } from './http'; import { publishWorkspace } from './presentation'; -import { avatarThumbnail } from './senderSnapshot'; +import { displayAvatar } from './senderSnapshot'; const current = new Map(); export function clearWorkspaceStructures(): void { current.clear(); } @@ -19,7 +19,7 @@ async function publish(id: string, body: WorkspaceBody): Promise 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 avatarThumbnail(person.avatar) : undefined }))); + const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await displayAvatar(person.avatar) : undefined }))); if (current.get(id) !== body) return current.get(id) ?? body; publishWorkspace(id, { t: 'structure', state: body.structure, people }); return body; @@ -37,6 +37,10 @@ export async function loadWorkspace(id: string): Promise { const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise); return publish(id, body); } +/** 自己改了头像或名字后立刻重新拉取所有已打开空间的成员资料(其他人靠服务端的设置变更通知) */ +export async function refreshLoadedWorkspaces(): Promise { + await Promise.all([...current.keys()].map((id) => loadWorkspace(id).catch(() => undefined))); +} export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', value?: unknown): Promise { const { session } = await getSession(id); const body = await apiFetch(`/spaces/${id}/${path}`, { diff --git a/web/src/features/home/AvatarEditor.module.css b/web/src/features/home/AvatarEditor.module.css index 750bc14..fadf1e5 100644 --- a/web/src/features/home/AvatarEditor.module.css +++ b/web/src/features/home/AvatarEditor.module.css @@ -135,3 +135,9 @@ color: var(--danger); font-size: 13px; } + +.note { + margin: 0; + color: var(--text-dim); + font-size: 12px; +} diff --git a/web/src/features/home/AvatarEditor.tsx b/web/src/features/home/AvatarEditor.tsx index 0cac1d1..177fee9 100644 --- a/web/src/features/home/AvatarEditor.tsx +++ b/web/src/features/home/AvatarEditor.tsx @@ -1,6 +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 styles from './AvatarEditor.module.css'; const VIEW = 260; @@ -31,7 +32,11 @@ interface Props { /** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */ export function AvatarEditor({ file, name, onCancel, onDone }: Props) { - const [image, setImage] = useState(); + const [still, setStill] = useState(); + // 动图:各帧(已取样)和当前播放到哪一帧;所有帧尺寸相同,裁剪几何只看尺寸 + const [frames, setFrames] = useState([]); + const [tick, setTick] = useState(0); + const image = frames.length ? frames[tick % frames.length]!.bitmap : still; const [failed, setFailed] = useState(false); const [view, setView] = useState(FRESH); const [busy, setBusy] = useState(false); @@ -42,13 +47,29 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) { useEffect(() => { let active = true; let bitmap: ImageBitmap | undefined; - createImageBitmap(file).then((value) => { - if (!active) { value.close(); return; } - bitmap = value; setImage(value); setView(FRESH); - }).catch(() => { if (active) setFailed(true); }); - return () => { active = false; bitmap?.close(); }; + let decoded: GifFrame[] | undefined; + (async () => { + try { + decoded = await decodeGif(file); + if (decoded) { + if (!active) { for (const frame of decoded) frame.bitmap.close(); return; } + setFrames(decoded); setStill(decoded[0]!.bitmap); setTick(0); setView(FRESH); + return; + } + const value = await createImageBitmap(file); + if (!active) { value.close(); return; } + bitmap = value; setFrames([]); setStill(value); setView(FRESH); + } catch { if (active) setFailed(true); } + })(); + return () => { active = false; bitmap?.close(); for (const frame of decoded ?? []) frame.bitmap.close(); }; }, [file]); + useEffect(() => { + if (frames.length < 2) return; + const timer = setTimeout(() => setTick((value) => value + 1), frames[tick % frames.length]!.delayMs); + return () => clearTimeout(timer); + }, [frames, tick]); + const update = useCallback((next: (value: View) => View) => { if (!image) return; setView((value) => { @@ -94,12 +115,12 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) { async function confirm() { if (!image || busy) return; setBusy(true); - try { onDone(await exportAvatar(image, crop, VIEW)); } + try { onDone(frames.length ? await exportGifAvatar(frames, crop, VIEW) : await exportAvatar(image, crop, VIEW)); } catch { setFailed(true); setBusy(false); } } if (failed) return
-

这张图片无法处理,请换一张 PNG、JPEG 或 WebP 图片。

+

这张图片无法处理,请换一张 PNG、JPEG、WebP 或 GIF 图片。

; @@ -112,6 +133,7 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) { onPointerDown={down} onPointerMove={move} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} />