feat(web): animated GIF avatars that refresh across chat and member list [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/dev-image unknown status

This commit is contained in:
justme committed 2026-10-17 13:25:14 +08:00
1 parent 3b062687cd
commit a08aa7568f
10 files changed
+163 -19

No files matched your search

+2 -1
View File
@@ -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<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); 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();
+2 -2
View File
@@ -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<SenderInfo
const username = objectAuthorAccount(resourceId, itemId);
if (!username) return undefined;
const person = cachedPerson(resourceId, username);
const avatar = person?.avatar ? await avatarThumbnail(person.avatar) : undefined;
const avatar = person?.avatar ? await displayAvatar(person.avatar) : undefined;
return { username, displayName: person?.displayName || username, ...(avatar ? { avatar } : {}) };
}
@@ -26,6 +26,11 @@ export async function senderSnapshot(): Promise<SenderInfo | undefined> {
return { username: sender.username, displayName: sender.displayName, ...(avatar ? { avatar } : {}) };
}
/** 本机显示用:动图头像原样保留(会动);其他压成小缩略图。发进消息里的快照仍用 avatarThumbnail,不能把几 MB 的动图塞进每条消息 */
export function displayAvatar(value: string): Promise<string | undefined> {
return value.startsWith('data:image/gif') ? Promise.resolve(value) : avatarThumbnail(value);
}
export function avatarThumbnail(value: string): Promise<string | undefined> {
let thumbnail = thumbnails.get(value);
if (!thumbnail) {
@@ -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<string, WorkspaceBody>();
export function clearWorkspaceStructures(): void { current.clear(); }
@@ -19,7 +19,7 @@ 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 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<WorkspaceBody> {
const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>);
return publish(id, body);
}
/** 自己改了头像或名字后立刻重新拉取所有已打开空间的成员资料(其他人靠服务端的设置变更通知) */
export async function refreshLoadedWorkspaces(): Promise<void> {
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<WorkspaceBody> {
const { session } = await getSession(id);
const body = await apiFetch(`/spaces/${id}/${path}`, {
@@ -135,3 +135,9 @@
color: var(--danger);
font-size: 13px;
}
.note {
margin: 0;
color: var(--text-dim);
font-size: 12px;
}
+30 -8
View File
@@ -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<ImageBitmap>();
const [still, setStill] = useState<ImageBitmap>();
// 动图:各帧(已取样)和当前播放到哪一帧;所有帧尺寸相同,裁剪几何只看尺寸
const [frames, setFrames] = useState<GifFrame[]>([]);
const [tick, setTick] = useState(0);
const image = frames.length ? frames[tick % frames.length]!.bitmap : still;
const [failed, setFailed] = useState(false);
const [view, setView] = useState<View>(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 <div className={styles.editor}>
<p className={styles.error} role="alert">这张图片无法处理,请换一张 PNG、JPEG 或 WebP 图片。</p>
<p className={styles.error} role="alert">这张图片无法处理,请换一张 PNG、JPEG、WebP 或 GIF 图片。</p>
<div className={styles.actions}><Button onClick={onCancel}>返回</Button></div>
</div>;
@@ -112,6 +133,7 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
onPointerDown={down} onPointerMove={move} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} />
<span className={styles.mask} aria-hidden="true" />
</div>
{file.type === 'image/gif' && !canDecodeGif() ? <p className={styles.note}>当前浏览器不支持动图头像,只会使用第一帧。</p> : null}
<label className={styles.slider}><span>缩放</span>
<input type="range" min={1} max={4} step={0.01} value={view.zoom} disabled={!ready}
onChange={(event) => update((value) => ({ ...value, zoom: Number(event.target.value) }))} /></label>
+2 -2
View File
@@ -10,7 +10,7 @@ import styles from './AccountSettings.module.css';
const MAX_SOURCE_BYTES = 20_000_000;
async function checkAvatarSource(file: File): Promise<void> {
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG 或 WebP 图片');
if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG、WebP 或 GIF 图片');
const bitmap = await createImageBitmap(file);
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192;
bitmap.close();
@@ -54,7 +54,7 @@ export function ProfileSettings() {
{avatar ? <img className={styles.avatarLarge} src={avatar} alt="个人头像" />
: <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" aria-label="选择头像文件" hidden onChange={(event) => { void choose(event.target.files?.[0]); event.target.value = ''; }} />
<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}
+88
View File
@@ -0,0 +1,88 @@
import { applyPalette, GIFEncoder, quantize } from 'gifenc';
import { paintCrop, type Crop } from './avatarCrop';
export interface GifFrame { bitmap: ImageBitmap; delayMs: number }
/** 编辑器里保留的帧:尽量保留全部。太长的动图才隔帧取样(时长不变),避免把几百张大图都放进内存 */
const MAX_FRAMES = 400;
const MAX_PIXELS = 120_000_000;
const MIN_DELAY_MS = 20;
/** 服务端对头像 data URL 的上限(字符数),留一点余量 */
export const MAX_GIF_DATA_URL = 5_900_000;
/** 浏览器是否能把 GIF 拆成帧(WebCodecs ImageDecoder)。不能就退回成静态头像 */
export const canDecodeGif = (): boolean => typeof ImageDecoder !== 'undefined';
/** 解出 GIF 的各帧;不是动图、不支持或解码失败时返回 undefined(调用方按静态图处理) */
export async function decodeGif(file: File): Promise<GifFrame[] | undefined> {
if (file.type !== 'image/gif' || !canDecodeGif()) return undefined;
const frames: GifFrame[] = [];
const decoder = new ImageDecoder({ data: await file.arrayBuffer(), type: 'image/gif' });
try {
await decoder.tracks.ready;
const total = decoder.tracks.selectedTrack?.frameCount ?? 1;
const { displayWidth, displayHeight } = decoder.tracks.selectedTrack?.frameCount ? await sizeOf(decoder) : { displayWidth: 0, displayHeight: 0 };
const stride = Math.max(1, Math.ceil(total / MAX_FRAMES), Math.ceil((total * displayWidth * displayHeight) / MAX_PIXELS));
let pending = 0;
for (let index = 0; index < total; index += 1) {
const { image } = await decoder.decode({ frameIndex: index });
pending += Math.max(MIN_DELAY_MS, (image.duration ?? 100_000) / 1000);
if (index % stride === 0 || index === total - 1) {
frames.push({ bitmap: await createImageBitmap(image), delayMs: pending }); pending = 0;
}
image.close();
}
} catch {
for (const frame of frames) frame.bitmap.close();
return undefined;
} finally { decoder.close(); }
if (frames.length > 1) return frames;
for (const frame of frames) frame.bitmap.close();
return undefined;
}
async function sizeOf(decoder: ImageDecoder): Promise<{ displayWidth: number; displayHeight: number }> {
const { image } = await decoder.decode({ frameIndex: 0 });
const size = { displayWidth: image.displayWidth, displayHeight: image.displayHeight };
image.close();
return size;
}
function toDataUrl(bytes: Uint8Array): string {
let binary = '';
for (let at = 0; at < bytes.length; at += 0x8000) binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
return `data:image/gif;base64,${btoa(binary)}`;
}
function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: number, step: number): string {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('当前浏览器不支持图片处理');
const encoder = GIFEncoder();
let delay = 0;
frames.forEach((frame, at) => {
delay += frame.delayMs;
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 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) });
delay = 0;
});
encoder.finish();
return toDataUrl(encoder.bytes());
}
/** 把每一帧按同样的裁剪导出成动图;默认 256 像素、保留每一帧;只有超过上限(约 4.5 MB)才逐步缩小尺寸、再隔帧 */
export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, from: number): Promise<string> {
for (const [size, step] of [[256, 1], [192, 1], [128, 1], [192, 2], [128, 2], [96, 2]] as const) {
// 让出线程,编辑器的「处理中」能先画出来
await new Promise((resolve) => setTimeout(resolve, 0));
const url = encode(frames, crop, from, size, step);
if (url.length <= MAX_GIF_DATA_URL) return url;
}
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
}
+13
View File
@@ -0,0 +1,13 @@
declare module 'gifenc' {
export type Palette = number[][];
export interface QuantizeOptions { format?: 'rgb565' | 'rgb444' | 'rgba4444'; oneBitAlpha?: boolean | number; clearAlpha?: boolean }
export interface FrameOptions { palette?: Palette; delay?: number; repeat?: number; transparent?: boolean; transparentIndex?: number }
export interface Encoder {
writeFrame(index: Uint8Array, width: number, height: number, options?: FrameOptions): void;
finish(): void;
bytes(): Uint8Array;
}
export function GIFEncoder(): Encoder;
export function quantize(rgba: Uint8Array | Uint8ClampedArray, maxColors: number, options?: QuantizeOptions): Palette;
export function applyPalette(rgba: Uint8Array | Uint8ClampedArray, palette: Palette, format?: 'rgb565' | 'rgb444' | 'rgba4444'): Uint8Array;
}
+9 -4
View File
@@ -70,10 +70,14 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
const person = location.authorAccount ? people.get(location.authorAccount) : undefined;
if (person) authors[location.itemId] = { username: person.username, displayName: person.displayName || person.username, avatar: person.avatar ?? undefined };
}
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => [id, {
...item, ...(locations[id] ?? {}), ...(authors[id] ? { author: authors[id] } : {}),
}]));
const profiles = Object.fromEntries(e.people.map(person => [person.username, { username: person.username, displayName: person.displayName || person.username, ...(person.avatar ? { avatar: person.avatar } : {}) }]));
// 资料改了(头像、名字),已经显示的消息也要跟着换:作者按账号取最新的资料
const fresh = (author: SenderInfo | undefined) => (author && profiles[author.username]) || author;
for (const id of Object.keys(authors)) authors[id] = fresh(authors[id])!;
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => {
const author = fresh(authors[id] ?? item.author);
return [id, { ...item, ...(locations[id] ?? {}), ...(author ? { author } : {}) }];
}));
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups, roles: e.state.roles ?? [],
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
}
@@ -96,7 +100,8 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
const known = e.item.id in s.byId;
let item = e.item;
const current = s.byId[item.id];
const author = s.authors[item.id] ?? item.author ?? current?.author;
const known0 = s.authors[item.id] ?? item.author ?? current?.author;
const author = (known0 && s.people[known0.username]) || known0;
if (author) item = { ...item, author };
if (e.t === 'item' && item.kind === 'file' && item.metadataHidden && current?.kind === 'file' && !current.metadataHidden) return s;
const location = s.locations[item.id];