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:
@@ -7,6 +7,8 @@ import { AVATAR_GRADIENT_MAP } from '../../utils/gradients';
|
|||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import type { AvatarColor, CheckInviteResponse, InstanceInfoResponse } from '@backspace/shared';
|
import type { AvatarColor, CheckInviteResponse, InstanceInfoResponse } from '@backspace/shared';
|
||||||
import { api, RateLimitError } from '../../api/client';
|
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.
|
// Single-source regex for extracting a bare invite token from a pasted full URL.
|
||||||
// Token format: 22 chars base64url ([A-Za-z0-9_-]).
|
// Token format: 22 chars base64url ([A-Za-z0-9_-]).
|
||||||
@@ -355,8 +357,9 @@ export function RegisterPage() {
|
|||||||
let finalUser = response.user;
|
let finalUser = response.user;
|
||||||
if (!skip && avatarFile) {
|
if (!skip && avatarFile) {
|
||||||
try {
|
try {
|
||||||
const attachment = await api.uploads.upload(avatarFile);
|
const tid = await useTransferStore.getState().startUpload(avatarFile, { tray: false });
|
||||||
finalUser = await api.users.update({ avatar: attachment.filename });
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
|
finalUser = await api.users.update({ avatar: filename });
|
||||||
} catch {
|
} catch {
|
||||||
// Avatar upload failed — user can set it later in settings
|
// 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 });
|
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({
|
const id = useTransferStore.getState().createTransfer({
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
file: { name: 'a', size: 1, mimetype: 'image/png' },
|
file: { name: 'a', size: 1, mimetype: 'image/png' },
|
||||||
tray: true,
|
tray: true,
|
||||||
});
|
});
|
||||||
useTransferStore.getState().setTusUrl(id, '/api/files/abc', 5_000);
|
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)!;
|
const t = useTransferStore.getState().get(id)!;
|
||||||
expect(t.tusUploadUrl).toBe('/api/files/abc');
|
expect(t.tusUploadUrl).toBe('/api/files/abc');
|
||||||
expect(t.tusExpiresAt).toBe(5_000);
|
expect(t.tusExpiresAt).toBe(5_000);
|
||||||
expect(t.attachmentId).toBe('att-9');
|
expect(t.attachmentId).toBe('att-9');
|
||||||
|
expect(t.attachmentFilename).toBe('server-name.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remove drops a transfer; idempotent on missing id', () => {
|
it('remove drops a transfer; idempotent on missing id', () => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface Transfer {
|
|||||||
tusExpiresAt?: number;
|
tusExpiresAt?: number;
|
||||||
fileHandleId?: string;
|
fileHandleId?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
|
attachmentFilename?: string;
|
||||||
uploaderUserId?: string;
|
uploaderUserId?: string;
|
||||||
|
|
||||||
// Download-specific
|
// Download-specific
|
||||||
@@ -59,7 +60,7 @@ interface TransferStoreActions {
|
|||||||
updateProgress: (id: string, loaded: number) => void;
|
updateProgress: (id: string, loaded: number) => void;
|
||||||
setError: (id: string, error: TransferError) => void;
|
setError: (id: string, error: TransferError) => void;
|
||||||
setTusUrl: (id: string, url: string, expiresAt: number) => 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;
|
remove: (id: string) => void;
|
||||||
|
|
||||||
startUpload: (file: Blob, opts: { channelId?: string; tray?: boolean; origin?: string; fileHandleId?: string }) => Promise<string>;
|
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 };
|
return { transfers: next };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setAttachmentId: (id, attachmentId) => set((s) => {
|
setAttachmentRef: (id, attachmentId, filename) => set((s) => {
|
||||||
const t = s.transfers.get(id);
|
const t = s.transfers.get(id);
|
||||||
if (!t) return s;
|
if (!t) return s;
|
||||||
const next = new Map(s.transfers);
|
const next = new Map(s.transfers);
|
||||||
next.set(id, { ...t, attachmentId });
|
next.set(id, { ...t, attachmentId, attachmentFilename: filename });
|
||||||
return { transfers: next };
|
return { transfers: next };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -245,7 +246,7 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
try {
|
try {
|
||||||
const body = payload.lastResponse?.getBody?.() ?? '';
|
const body = payload.lastResponse?.getBody?.() ?? '';
|
||||||
const att = JSON.parse(body) as Attachment;
|
const att = JSON.parse(body) as Attachment;
|
||||||
get().setAttachmentId(id, att.id);
|
get().setAttachmentRef(id, att.id, att.filename);
|
||||||
get().setState_(id, 'completed');
|
get().setState_(id, 'completed');
|
||||||
get().updateProgress(id, fileLike.size);
|
get().updateProgress(id, fileLike.size);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -346,7 +347,7 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
try {
|
try {
|
||||||
const body = payload.lastResponse?.getBody?.() ?? '';
|
const body = payload.lastResponse?.getBody?.() ?? '';
|
||||||
const att = JSON.parse(body) as Attachment;
|
const att = JSON.parse(body) as Attachment;
|
||||||
get().setAttachmentId(id, att.id);
|
get().setAttachmentRef(id, att.id, att.filename);
|
||||||
get().setState_(id, 'completed');
|
get().setState_(id, 'completed');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : 'Resume completed but parse failed';
|
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();
|
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 file = new File([new Uint8Array(100)], 'a.png', { type: 'image/png' });
|
||||||
const id = await useTransferStore.getState().startUpload(file, { channelId: 'ch-1', tray: true });
|
const id = await useTransferStore.getState().startUpload(file, { channelId: 'ch-1', tray: true });
|
||||||
expect(startMock).toHaveBeenCalledTimes(1);
|
expect(startMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -76,6 +76,7 @@ describe('transferStore.startUpload', () => {
|
|||||||
expect(t).toBeDefined();
|
expect(t).toBeDefined();
|
||||||
expect(t!.state).toBe('completed');
|
expect(t!.state).toBe('completed');
|
||||||
expect(t!.attachmentId).toBe('att-9');
|
expect(t!.attachmentId).toBe('att-9');
|
||||||
|
expect(t!.attachmentFilename).toBe('a.png');
|
||||||
expect(t!.tusUploadUrl).toBe('/api/files/abc-123');
|
expect(t!.tusUploadUrl).toBe('/api/files/abc-123');
|
||||||
expect(t!.tusExpiresAt).toBeGreaterThan(Date.now());
|
expect(t!.tusExpiresAt).toBeGreaterThan(Date.now());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user