From 727c51f6590de38a14da18bfbd828eb1d49f386a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 2 May 2026 16:40:50 +0200 Subject: [PATCH] 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 --- .../web/src/components/auth/RegisterPage.tsx | 7 +++- packages/web/src/stores/transferStore.test.ts | 5 ++- packages/web/src/stores/transferStore.ts | 11 +++--- .../src/stores/transferStore.upload.test.ts | 3 +- packages/web/src/utils/waitForTransfer.ts | 38 +++++++++++++++++++ 5 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 packages/web/src/utils/waitForTransfer.ts diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index d8e7bf45..745e0337 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -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 } diff --git a/packages/web/src/stores/transferStore.test.ts b/packages/web/src/stores/transferStore.test.ts index 533aa87e..02db23bd 100644 --- a/packages/web/src/stores/transferStore.test.ts +++ b/packages/web/src/stores/transferStore.test.ts @@ -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', () => { diff --git a/packages/web/src/stores/transferStore.ts b/packages/web/src/stores/transferStore.ts index b6c638a1..f38d1ea4 100644 --- a/packages/web/src/stores/transferStore.ts +++ b/packages/web/src/stores/transferStore.ts @@ -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; @@ -181,11 +182,11 @@ export const useTransferStore = create()( 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()( 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()( 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'; diff --git a/packages/web/src/stores/transferStore.upload.test.ts b/packages/web/src/stores/transferStore.upload.test.ts index 1d592910..7f863abb 100644 --- a/packages/web/src/stores/transferStore.upload.test.ts +++ b/packages/web/src/stores/transferStore.upload.test.ts @@ -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()); }); diff --git a/packages/web/src/utils/waitForTransfer.ts b/packages/web/src/utils/waitForTransfer.ts new file mode 100644 index 00000000..34022d32 --- /dev/null +++ b/packages/web/src/utils/waitForTransfer.ts @@ -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 { + return new Promise((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(); + }); + }); +}