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}`, {