feat(web): RegisterPage avatar uses transferStore + waitForTransfer helper

Replaces the legacy /api/uploads call in RegisterPage with the tus-based
transferStore path. Extends Transfer to persist the server-assigned
filename (not just attachmentId) since downstream consumers store
attachment.filename on the user/space record.

- transferStore: rename setAttachmentId -> setAttachmentRef(id, attachmentId, filename)
  and add attachmentFilename field to Transfer
- both startUpload + resumeUpload onSuccess paths now record filename
- new utils/waitForTransfer.ts: waitForTransferAttachment(transferId)
  returns {attachmentId, filename}, with immediate-terminal handling
- RegisterPage: silent (tray:false) upload via transferStore, awaits
  the helper, passes the server filename to api.users.update
This commit is contained in:
Jannis Braun
2026-05-02 16:40:50 +02:00
parent 48d199515a
commit 727c51f659
5 changed files with 54 additions and 10 deletions
@@ -7,6 +7,8 @@ import { AVATAR_GRADIENT_MAP } from '../../utils/gradients';
import { AVATAR_COLORS } from '@backspace/shared';
import type { AvatarColor, CheckInviteResponse, InstanceInfoResponse } from '@backspace/shared';
import { api, RateLimitError } from '../../api/client';
import { useTransferStore } from '../../stores/transferStore';
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
// Single-source regex for extracting a bare invite token from a pasted full URL.
// Token format: 22 chars base64url ([A-Za-z0-9_-]).
@@ -355,8 +357,9 @@ export function RegisterPage() {
let finalUser = response.user;
if (!skip && avatarFile) {
try {
const attachment = await api.uploads.upload(avatarFile);
finalUser = await api.users.update({ avatar: attachment.filename });
const tid = await useTransferStore.getState().startUpload(avatarFile, { tray: false });
const { filename } = await waitForTransferAttachment(tid);
finalUser = await api.users.update({ avatar: filename });
} catch {
// Avatar upload failed — user can set it later in settings
}
@@ -93,18 +93,19 @@ describe('transferStore basics', () => {
expect(t.error).toEqual({ message: 'boom', permanent: true });
});
it('setTusUrl + setAttachmentId stores the metadata', () => {
it('setTusUrl + setAttachmentRef stores the metadata', () => {
const id = useTransferStore.getState().createTransfer({
type: 'upload',
file: { name: 'a', size: 1, mimetype: 'image/png' },
tray: true,
});
useTransferStore.getState().setTusUrl(id, '/api/files/abc', 5_000);
useTransferStore.getState().setAttachmentId(id, 'att-9');
useTransferStore.getState().setAttachmentRef(id, 'att-9', 'server-name.png');
const t = useTransferStore.getState().get(id)!;
expect(t.tusUploadUrl).toBe('/api/files/abc');
expect(t.tusExpiresAt).toBe(5_000);
expect(t.attachmentId).toBe('att-9');
expect(t.attachmentFilename).toBe('server-name.png');
});
it('remove drops a transfer; idempotent on missing id', () => {
+6 -5
View File
@@ -29,6 +29,7 @@ export interface Transfer {
tusExpiresAt?: number;
fileHandleId?: string;
attachmentId?: string;
attachmentFilename?: string;
uploaderUserId?: string;
// Download-specific
@@ -59,7 +60,7 @@ interface TransferStoreActions {
updateProgress: (id: string, loaded: number) => void;
setError: (id: string, error: TransferError) => void;
setTusUrl: (id: string, url: string, expiresAt: number) => void;
setAttachmentId: (id: string, attachmentId: string) => void;
setAttachmentRef: (id: string, attachmentId: string, filename: string) => void;
remove: (id: string) => void;
startUpload: (file: Blob, opts: { channelId?: string; tray?: boolean; origin?: string; fileHandleId?: string }) => Promise<string>;
@@ -181,11 +182,11 @@ export const useTransferStore = create<TransferStore>()(
return { transfers: next };
}),
setAttachmentId: (id, attachmentId) => set((s) => {
setAttachmentRef: (id, attachmentId, filename) => set((s) => {
const t = s.transfers.get(id);
if (!t) return s;
const next = new Map(s.transfers);
next.set(id, { ...t, attachmentId });
next.set(id, { ...t, attachmentId, attachmentFilename: filename });
return { transfers: next };
}),
@@ -245,7 +246,7 @@ export const useTransferStore = create<TransferStore>()(
try {
const body = payload.lastResponse?.getBody?.() ?? '';
const att = JSON.parse(body) as Attachment;
get().setAttachmentId(id, att.id);
get().setAttachmentRef(id, att.id, att.filename);
get().setState_(id, 'completed');
get().updateProgress(id, fileLike.size);
} catch (e) {
@@ -346,7 +347,7 @@ export const useTransferStore = create<TransferStore>()(
try {
const body = payload.lastResponse?.getBody?.() ?? '';
const att = JSON.parse(body) as Attachment;
get().setAttachmentId(id, att.id);
get().setAttachmentRef(id, att.id, att.filename);
get().setState_(id, 'completed');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Resume completed but parse failed';
@@ -68,7 +68,7 @@ describe('transferStore.startUpload', () => {
if (typeof localStorage !== 'undefined') localStorage.clear();
});
it('drives a transfer through tus → completed with attachmentId', async () => {
it('drives a transfer through tus → completed with attachmentId + attachmentFilename', async () => {
const file = new File([new Uint8Array(100)], 'a.png', { type: 'image/png' });
const id = await useTransferStore.getState().startUpload(file, { channelId: 'ch-1', tray: true });
expect(startMock).toHaveBeenCalledTimes(1);
@@ -76,6 +76,7 @@ describe('transferStore.startUpload', () => {
expect(t).toBeDefined();
expect(t!.state).toBe('completed');
expect(t!.attachmentId).toBe('att-9');
expect(t!.attachmentFilename).toBe('a.png');
expect(t!.tusUploadUrl).toBe('/api/files/abc-123');
expect(t!.tusExpiresAt).toBeGreaterThan(Date.now());
});
+38
View File
@@ -0,0 +1,38 @@
import { useTransferStore } from '../stores/transferStore';
export interface TransferAttachmentRef {
attachmentId: string;
filename: string;
}
/**
* Wait for a transferStore upload to reach a terminal state.
* Resolves with the server-assigned attachmentId + filename on success.
* Rejects on failure or abort.
*
* Handles the already-terminal case synchronously (resolves/rejects immediately
* without subscribing) and unsubscribes after the first terminal observation.
*/
export function waitForTransferAttachment(transferId: string): Promise<TransferAttachmentRef> {
return new Promise<TransferAttachmentRef>((resolve, reject) => {
const check = (): boolean => {
const t = useTransferStore.getState().transfers.get(transferId);
if (!t) return false;
if (t.state === 'completed' && t.attachmentId && t.attachmentFilename) {
resolve({ attachmentId: t.attachmentId, filename: t.attachmentFilename });
return true;
}
if (t.state === 'failed' || t.state === 'aborted') {
reject(new Error(t.error?.message ?? 'Upload failed'));
return true;
}
return false;
};
if (check()) return;
const unsub = useTransferStore.subscribe(() => {
if (check()) unsub();
});
});
}