From a5fcb78434102f78a3bf92bcdc011d7271963f84 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:33:18 +0100 Subject: [PATCH] feat: sync user profile to remote instances on federation connect and profile update --- packages/web/src/stores/authStore.ts | 7 +- packages/web/src/stores/instanceStore.ts | 16 +++ packages/web/src/utils/profileSync.ts | 159 +++++++++++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/utils/profileSync.ts diff --git a/packages/web/src/stores/authStore.ts b/packages/web/src/stores/authStore.ts index 540a4e19..e22b4905 100644 --- a/packages/web/src/stores/authStore.ts +++ b/packages/web/src/stores/authStore.ts @@ -6,6 +6,7 @@ import { useSpaceStore } from './spaceStore'; import { useSocialStore } from './socialStore'; import { useVoiceStore } from './voiceStore'; import { useInstanceStore } from './instanceStore'; +import { syncProfileUpdateToRemotes } from '../utils/profileSync'; interface AuthState { token: string | null; @@ -16,7 +17,7 @@ interface AuthState { register: (username: string, password: string, displayName?: string) => Promise; logout: () => void; loadUser: () => Promise; - updateProfile: (data: { displayName?: string; avatar?: string; banner?: string; accentColor?: string; bio?: string; customStatus?: string; status?: UserStatus }) => Promise; + updateProfile: (data: { displayName?: string; avatar?: string; banner?: string; accentColor?: string; avatarColor?: string; bio?: string; customStatus?: string; status?: UserStatus }) => Promise; setUser: (user: User) => void; clearError: () => void; } @@ -84,6 +85,10 @@ export const useAuthStore = create((set, get) => ({ try { const user = await api.users.update(data); set({ user }); + // Push profile changes to all connected remote instances + syncProfileUpdateToRemotes(data).catch((err) => { + console.warn('[ProfileSync] Failed to sync to remotes:', err); + }); } catch (err) { set({ error: err instanceof Error ? err.message : 'Update failed' }); throw err; diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 2334af89..3072aa1b 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -4,6 +4,7 @@ import { BackspaceApiClient, createApiClient, api } from '../api/client'; import { useAuthStore } from './authStore'; import { setApiForOriginResolver, setUserIdForOriginResolver, setOriginFromHostnameResolver, useSpaceStore } from './spaceStore'; import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket'; +import { syncProfileToRemote } from '../utils/profileSync'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -241,6 +242,11 @@ export const useInstanceStore = create((set, get) => ({ // Open WebSocket connection to the remote instance connectInstance(origin, response.token); + // Sync full home profile to new remote (fire-and-forget) + syncProfileToRemote(instance).catch((err) => { + console.warn(`[ProfileSync] Initial sync to ${origin} failed:`, err); + }); + // Sync instance list to all instances (fire-and-forget) get().syncInstanceList().catch(() => {}); } catch (err) { @@ -281,6 +287,11 @@ export const useInstanceStore = create((set, get) => ({ // Open WebSocket connection to the remote instance connectInstance(origin, response.token); + // Sync full home profile to remote (fire-and-forget) + syncProfileToRemote(instance).catch((err) => { + console.warn(`[ProfileSync] Login sync to ${origin} failed:`, err); + }); + // Sync instance list to all instances (fire-and-forget) get().syncInstanceList().catch(() => {}); } catch (err) { @@ -527,6 +538,11 @@ export const useInstanceStore = create((set, get) => ({ // Open WebSocket connection now that we've verified the token connectInstance(origin, cachedEntry.token); + + // Sync home profile to reconnected remote (fire-and-forget) + syncProfileToRemote(connectedInstance).catch((err) => { + console.warn(`[ProfileSync] Reconnect sync to ${origin} failed:`, err); + }); } catch (err) { if (isNetworkError(err)) { // Instance unreachable (NAT hairpinning, DNS, server down) — token may still be valid diff --git a/packages/web/src/utils/profileSync.ts b/packages/web/src/utils/profileSync.ts new file mode 100644 index 00000000..eeccba53 --- /dev/null +++ b/packages/web/src/utils/profileSync.ts @@ -0,0 +1,159 @@ +import type { UpdateUserRequest } from '@backspace/shared'; +import { useAuthStore } from '../stores/authStore'; +import { useInstanceStore, type ConnectedInstance } from '../stores/instanceStore'; + +// ─── Internal helper ──────────────────────────────────────────────────────── + +async function downloadHomeAsset(filename: string): Promise { + if (filename.startsWith('http') || filename.startsWith('blob:')) { + const res = await fetch(filename); + return res.blob(); + } + const res = await fetch(`/api/uploads/${filename}`); + return res.blob(); +} + +// ─── Full profile sync (connect / reconnect) ──────────────────────────────── + +/** + * Push the entire home profile to a single remote instance. + * Called on initial connect, reconnect, and login-to-remote. + */ +export async function syncProfileToRemote(inst: ConnectedInstance): Promise { + try { + const homeUser = useAuthStore.getState().user; + if (!homeUser) return; + + const payload: UpdateUserRequest = { + displayName: homeUser.displayName || undefined, + avatarColor: homeUser.avatarColor || undefined, + accentColor: homeUser.accentColor || undefined, + bio: homeUser.bio || undefined, + customStatus: homeUser.customStatus || undefined, + }; + + // Sync avatar + if (homeUser.avatar) { + try { + const blob = await downloadHomeAsset(homeUser.avatar); + const attachment = await inst.api.uploads.upload(new File([blob], homeUser.avatar)); + payload.avatar = attachment.filename; + } catch (err) { + console.warn('[ProfileSync] Failed to upload avatar to remote:', err); + } + } else { + payload.avatar = ''; + } + + // Sync banner + if (homeUser.banner) { + try { + const blob = await downloadHomeAsset(homeUser.banner); + const attachment = await inst.api.uploads.upload(new File([blob], homeUser.banner)); + payload.banner = attachment.filename; + } catch (err) { + console.warn('[ProfileSync] Failed to upload banner to remote:', err); + } + } else { + payload.banner = ''; + } + + await inst.api.users.update(payload); + } catch (err) { + console.warn(`[ProfileSync] Full sync to ${inst.origin} failed:`, err); + } +} + +// ─── Incremental sync (profile update) ────────────────────────────────────── + +/** Sync-eligible text fields (no status, replicatedInstances, homeUserId). */ +const SYNC_FIELDS = ['displayName', 'avatarColor', 'accentColor', 'bio', 'customStatus'] as const; + +/** + * Push a partial profile update to all connected remote instances. + * Called after a successful home PATCH from authStore.updateProfile. + */ +export async function syncProfileUpdateToRemotes(update: Partial): Promise { + try { + const { instances } = useInstanceStore.getState(); + const connected = instances.filter(i => i.status === 'connected'); + if (connected.length === 0) return; + + // Pick text fields that were actually in the update + const basePayload: UpdateUserRequest = {}; + for (const key of SYNC_FIELDS) { + if (key in update) { + (basePayload as Record)[key] = update[key as keyof UpdateUserRequest]; + } + } + + // Pre-download file assets once (if they changed) + let avatarBlob: Blob | null = null; + let avatarFilename: string | null = null; + let bannerBlob: Blob | null = null; + let bannerFilename: string | null = null; + + if ('avatar' in update) { + if (update.avatar) { + try { + avatarBlob = await downloadHomeAsset(update.avatar); + avatarFilename = update.avatar; + } catch (err) { + console.warn('[ProfileSync] Failed to download avatar for sync:', err); + } + } + } + + if ('banner' in update) { + if (update.banner) { + try { + bannerBlob = await downloadHomeAsset(update.banner); + bannerFilename = update.banner; + } catch (err) { + console.warn('[ProfileSync] Failed to download banner for sync:', err); + } + } + } + + await Promise.allSettled( + connected.map(async (inst) => { + const perInstPayload: UpdateUserRequest = { ...basePayload }; + + // Handle avatar + if ('avatar' in update) { + if (avatarBlob && avatarFilename) { + try { + const attachment = await inst.api.uploads.upload(new File([avatarBlob], avatarFilename)); + perInstPayload.avatar = attachment.filename; + } catch (err) { + console.warn(`[ProfileSync] Failed to upload avatar to ${inst.origin}:`, err); + } + } else { + perInstPayload.avatar = ''; + } + } + + // Handle banner + if ('banner' in update) { + if (bannerBlob && bannerFilename) { + try { + const attachment = await inst.api.uploads.upload(new File([bannerBlob], bannerFilename)); + perInstPayload.banner = attachment.filename; + } catch (err) { + console.warn(`[ProfileSync] Failed to upload banner to ${inst.origin}:`, err); + } + } else { + perInstPayload.banner = ''; + } + } + + // Only PATCH if there's something to sync + if (Object.keys(perInstPayload).length > 0) { + await inst.api.users.update(perInstPayload); + } + }) + ); + } catch (err) { + console.warn('[ProfileSync] Failed to sync update to remotes:', err); + } +}