diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index d5507f86..261e7d83 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -117,6 +117,12 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'category_id', type: 'TEXT' }, ] + }, + { + name: 'users', + columns: [ + { name: 'profile_updated_at', type: 'INTEGER' }, + ] } ]; @@ -233,6 +239,9 @@ export function runMigrations(db: Database.Database): void { // ─── Convert video channels to voice (video type removed) ───────────────── migrateVideoChannels(db); + // ─── Backfill profile_updated_at from created_at ────────────────────────── + migrateProfileUpdatedAt(db); + // ─── Ensure user_space_layout table exists ──────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS user_space_layout ( @@ -651,6 +660,16 @@ function migrateVideoChannels(db: Database.Database): void { } } +/** Backfill profile_updated_at from created_at for existing users */ +function migrateProfileUpdatedAt(db: Database.Database): void { + const result = db.prepare( + 'UPDATE users SET profile_updated_at = created_at WHERE profile_updated_at IS NULL' + ).run(); + if (result.changes > 0) { + console.log(`Migrating: Backfilled profile_updated_at for ${result.changes} user(s)`); + } +} + function migrateReplicatedUsernames(db: Database.Database): void { const rows = db.prepare( "SELECT id, username, home_instance FROM users WHERE home_instance IS NOT NULL AND username NOT LIKE '%@%'" diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index cf31fe40..2e4eb763 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -17,6 +17,7 @@ export const users = sqliteTable('users', { avatarColor: text('avatar_color'), bio: text('bio'), isDeleted: integer('is_deleted').default(0), + profileUpdatedAt: integer('profile_updated_at'), createdAt: integer('created_at').notNull(), }); diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index c55c6806..2829b821 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -255,7 +255,7 @@ export async function userRoutes(app: FastifyInstance): Promise { }); app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { - const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId } = request.body; + const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt } = request.body; const db = getDb(); const updateData: Record = {}; @@ -370,6 +370,28 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); } + // LWW guard: if the caller provided a profileUpdatedAt and profile fields changed, + // reject stale writes by comparing timestamps + const profileFields = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus']; + const hasProfileChange = profileFields.some(f => f in updateData); + + if (hasProfileChange) { + if (profileUpdatedAt !== undefined && typeof profileUpdatedAt === 'number') { + const currentUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (currentUser) { + const storedTs = currentUser.profileUpdatedAt ?? currentUser.createdAt; + if (profileUpdatedAt < storedTs) { + // Incoming data is older — return current state without updating + return reply.code(200).send(sanitizeUser(currentUser)); + } + } + (updateData as Record).profileUpdatedAt = profileUpdatedAt; + } else { + // No explicit timestamp — stamp with server time (local edits) + (updateData as Record).profileUpdatedAt = Date.now(); + } + } + db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run(); const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); @@ -396,10 +418,7 @@ export async function userRoutes(app: FastifyInstance): Promise { }); } - // Broadcast user_updated for profile field changes - const profileFields = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus']; - const hasProfileChange = profileFields.some(f => f in updateData); - + // Broadcast user_updated for profile field changes (reuse hasProfileChange from LWW guard above) if (hasProfileChange) { const userUpdatedEvent = { type: 'user_updated' as const, user: sanitized }; const targetUserIds = new Set(); @@ -444,9 +463,9 @@ export async function userRoutes(app: FastifyInstance): Promise { }); // PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders) - app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record } }>( + app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record; updatedAt?: number } }>( '/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => { - const { items, folders } = request.body; + const { items, folders, updatedAt: incomingTs } = request.body; const userId = request.userId; if (!Array.isArray(items)) { @@ -472,6 +491,39 @@ export async function userRoutes(app: FastifyInstance): Promise { const db = getDb(); + // LWW guard: reject stale layout writes + if (incomingTs !== undefined && typeof incomingTs === 'number') { + const existingLayout = db.select().from(schema.userSpaceLayout) + .where(eq(schema.userSpaceLayout.userId, userId)).get(); + if (existingLayout && incomingTs < existingLayout.updatedAt) { + // Incoming layout is older — return current state without updating + const currentItems: SpaceLayoutItem[] = JSON.parse(existingLayout.layout); + const currentFolderRows = db.select().from(schema.spaceFolders) + .where(eq(schema.spaceFolders.userId, userId)) + .orderBy(schema.spaceFolders.position) + .all(); + const currentFolders: SpaceFolder[] = currentFolderRows.map(folder => { + const memberRows = db.select() + .from(schema.spaceFolderMembers) + .where(eq(schema.spaceFolderMembers.folderId, folder.id)) + .orderBy(schema.spaceFolderMembers.position) + .all(); + return { + id: folder.id, + userId: folder.userId, + name: folder.name, + color: folder.color, + position: folder.position ?? 0, + spaceIds: memberRows.map(m => m.spaceId), + }; + }); + return reply.code(200).send({ items: currentItems, folders: currentFolders, updatedAt: existingLayout.updatedAt }); + } + } + + // Resolve the effective timestamp for this write + const effectiveTs = (incomingTs !== undefined && typeof incomingTs === 'number') ? incomingTs : Date.now(); + // Map new:* folder keys to server-generated IDs const newIdMap = new Map(); for (const key of Object.keys(folders)) { @@ -553,14 +605,14 @@ export async function userRoutes(app: FastifyInstance): Promise { .where(eq(schema.userSpaceLayout.userId, userId)).get(); if (existing) { tx.update(schema.userSpaceLayout) - .set({ layout: JSON.stringify(finalItems), updatedAt: Date.now() }) + .set({ layout: JSON.stringify(finalItems), updatedAt: effectiveTs }) .where(eq(schema.userSpaceLayout.userId, userId)) .run(); } else { tx.insert(schema.userSpaceLayout).values({ userId, layout: JSON.stringify(finalItems), - updatedAt: Date.now(), + updatedAt: effectiveTs, }).run(); } }); @@ -592,14 +644,17 @@ export async function userRoutes(app: FastifyInstance): Promise { }); } + const layoutUpdatedAt = finalLayout?.updatedAt ?? effectiveTs; + // Broadcast to user's other connections (multi-tab sync) connectionManager.sendToUser(userId, { type: 'space_layout_updated', layout: finalItems, folders: responseFolders, + updatedAt: layoutUpdatedAt, }); - return reply.code(200).send({ items: finalItems, folders: responseFolders }); + return reply.code(200).send({ items: finalItems, folders: responseFolders, updatedAt: layoutUpdatedAt }); }); app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => { diff --git a/packages/server/src/utils/sanitize.ts b/packages/server/src/utils/sanitize.ts index af4dbe05..01bf01f5 100644 --- a/packages/server/src/utils/sanitize.ts +++ b/packages/server/src/utils/sanitize.ts @@ -17,6 +17,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User { customStatus: null, isAdmin: false, isDeleted: true, + profileUpdatedAt: 0, createdAt: row.createdAt, homeInstance: null, homeUserId: null, @@ -45,6 +46,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User { status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, isAdmin: row.isAdmin === 1, + profileUpdatedAt: row.profileUpdatedAt ?? row.createdAt, createdAt: row.createdAt, homeInstance: row.homeInstance ?? null, homeUserId: row.homeUserId ?? null, diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 86652d86..e4738c11 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -654,6 +654,7 @@ function buildReadyPayload(userId: string): { dmChannels: DmChannel[]; folders: SpaceFolder[]; spaceLayout: SpaceLayoutItem[] | null; + layoutUpdatedAt: number | null; voiceStates: Record; voiceUserStates: Record; spaceVoiceStates: Record; @@ -946,6 +947,7 @@ function buildReadyPayload(userId: string): { const layoutRow = db.select().from(schema.userSpaceLayout) .where(eq(schema.userSpaceLayout.userId, userId)).get(); const spaceLayout: SpaceLayoutItem[] | null = layoutRow ? JSON.parse(layoutRow.layout) : null; + const layoutUpdatedAt: number | null = layoutRow?.updatedAt ?? null; // Build voice states — tell the client who is currently in voice channels // across all their spaces @@ -1037,7 +1039,7 @@ function buildReadyPayload(userId: string): { lastReadMessageId: rs.lastReadMessageId, })); - return { user, spaces, dmChannels, folders, spaceLayout, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls }; + return { user, spaces, dmChannels, folders, spaceLayout, layoutUpdatedAt, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls }; } export async function registerWebSocket(app: FastifyInstance): Promise { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 54df6c6d..d3d8e917 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -16,6 +16,7 @@ export interface User { customStatus: string | null; isAdmin: boolean; isDeleted?: boolean; + profileUpdatedAt?: number; createdAt: number; homeInstance: string | null; homeUserId: string | null; @@ -271,7 +272,7 @@ export type ClientEvent = // Server → Client Events export type ServerEvent = - | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record } + | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record } | { type: 'message_created'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser } | { type: 'message_deleted'; messageId: string; channelId: string } @@ -317,7 +318,7 @@ export type ServerEvent = | { type: 'category_updated'; category: ChannelCategory; spaceId: string } | { type: 'category_deleted'; categoryId: string; spaceId: string } | { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] } - | { type: 'space_layout_updated'; layout: SpaceLayoutItem[]; folders: SpaceFolder[] } + | { type: 'space_layout_updated'; layout: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number } | { type: 'pong' } | { type: 'error'; message: string }; @@ -385,6 +386,7 @@ export interface UpdateUserRequest { status?: UserStatus; replicatedInstances?: ReplicatedInstance[]; homeUserId?: string; + profileUpdatedAt?: number; } export interface UpdateMemberRequest { diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 63981fa9..5666c05e 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -67,7 +67,7 @@ export class BackspaceApiClient { }; readonly spaceLayout: { - update: (data: { items: SpaceLayoutItem[]; folders: Record }) => Promise<{ items: SpaceLayoutItem[]; folders: SpaceFolder[] }>; + update: (data: { items: SpaceLayoutItem[]; folders: Record; updatedAt?: number }) => Promise<{ items: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number }>; }; readonly spaces: { @@ -278,7 +278,7 @@ export class BackspaceApiClient { this.spaceLayout = { update: (data) => - request<{ items: SpaceLayoutItem[]; folders: SpaceFolder[] }>('PUT', '/users/@me/space-layout', data), + request<{ items: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number }>('PUT', '/users/@me/space-layout', data), }; this.spaces = { diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 33f6d1fb..905e97d0 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -107,7 +107,7 @@ function handleEvent(origin: string, event: ServerEvent): void { } } - populateFromReady(origin, event.spaces, event.folders, event.dmChannels, event.spaceLayout); + populateFromReady(origin, event.spaces, event.folders, event.dmChannels, event.spaceLayout, event.layoutUpdatedAt); // Cache authoritative identity for this origin (federation-safe) if (!isHome) { @@ -698,15 +698,13 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'space_layout_updated': { - // Accept layout updates from browsing instance OR true home - const layoutUser = useAuthStore.getState().user; - const isLayoutTrueHome = !!layoutUser?.homeInstance && origin !== '' && (() => { - try { return new URL(origin).host === layoutUser.homeInstance; } catch { return false; } - })(); - if (!isHome && !isLayoutTrueHome) break; - const { setSpaceLayout } = useSpaceStore.getState(); - setSpaceLayout(event.layout); - useSpaceStore.setState({ folders: event.folders }); + // LWW: only accept if incoming timestamp >= current + const incomingTs = event.updatedAt ?? 0; + const currentTs = useSpaceStore.getState()._layoutUpdatedAt; + if (incomingTs >= currentTs) { + useSpaceStore.getState().setSpaceLayout(event.layout); + useSpaceStore.setState({ folders: event.folders, _layoutUpdatedAt: incomingTs }); + } break; } diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index b8950984..b18a5d55 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -38,7 +38,7 @@ interface SpaceState { channelPermissions: Map; // channelId → myPermissions decimal string channelOriginMap: Map; // channelId → instance origin ('' = home) categoryOriginMap: Map; // categoryId → instance origin ('' = home) - _layoutFromTrueHome: boolean; + _layoutUpdatedAt: number; setSpaces: (spaces: TaggedSpace[]) => void; setCurrentSpace: (spaceId: string | null) => void; setChannels: (channels: Channel[]) => void; @@ -76,13 +76,40 @@ interface SpaceState { removeMember: (userId: string) => void; setSpaceLayout: (layout: SpaceLayoutItem[] | null) => void; updateSpaceLayout: (items: SpaceLayoutItem[], folders: Record) => Promise; - populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null) => void; + populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null, layoutUpdatedAt?: number) => void; addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void; removeInstanceSpaces: (origin: string) => void; transferOwnership: (spaceId: string, newOwnerId: string) => Promise; findExistingDmForUser: (targetUser: { id: string; homeUserId?: string | null }) => { dm: DmChannel; origin: string } | null; } +/** + * Push the current layout to a specific origin whose layout was older. + * Used when populateFromReady receives a stale layout from an instance. + */ +async function pushLayoutToOrigin( + origin: string, + layout: SpaceLayoutItem[] | null, + folders: SpaceFolder[], + updatedAt: number, +): Promise { + try { + const targetApi = getApiForOrigin(origin); + // Build folder map from SpaceFolder[] + const folderMap: Record = {}; + for (const f of folders) { + folderMap[f.id] = { name: f.name, color: f.color, spaceIds: f.spaceIds }; + } + await targetApi.spaceLayout.update({ + items: layout ?? [], + folders: folderMap, + updatedAt, + }); + } catch (err) { + console.warn(`[SpaceStore] Failed to push layout to ${origin || 'home'}:`, err); + } +} + export const useSpaceStore = create((set, get) => ({ spaces: [], currentSpaceId: null, @@ -99,7 +126,7 @@ export const useSpaceStore = create((set, get) => ({ channelPermissions: new Map(), channelOriginMap: new Map(), categoryOriginMap: new Map(), - _layoutFromTrueHome: false, + _layoutUpdatedAt: 0, setSpaces: (spaces) => set({ spaces }), setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }), @@ -424,33 +451,43 @@ export const useSpaceStore = create((set, get) => ({ setSpaceLayout: (layout) => set({ spaceLayout: layout }), updateSpaceLayout: async (items, folders) => { - // Optimistic: apply the layout immediately - set({ spaceLayout: items }); + const now = Date.now(); + // Optimistic: apply the layout immediately with new timestamp + set({ spaceLayout: items, _layoutUpdatedAt: now }); - const homeOrigin = getLayoutHomeOrigin(); - const homeApi = getApiForOrigin(homeOrigin); + // Push to ALL connected instances in parallel (browsing + remotes) + const targets: { origin: string; apiClient: BackspaceApiClient }[] = [ + { origin: '', apiClient: api }, + ]; + // Dynamically import instanceStore to avoid circular dep try { - const result = await homeApi.spaceLayout.update({ items, folders }); - // Server may have resolved new:* IDs - set({ spaceLayout: result.items, folders: result.folders }); - } catch (err) { - // If true home is remote and unreachable, fall back to browsing instance - if (homeOrigin) { - console.warn(`Layout save to home (${homeOrigin}) failed, falling back to local:`, err); - try { - const result = await api.spaceLayout.update({ items, folders }); - set({ spaceLayout: result.items, folders: result.folders }); - } catch (fallbackErr) { - console.error('Failed to save space layout:', fallbackErr); - } - } else { - console.error('Failed to save space layout:', err); + const { useInstanceStore } = await import('./instanceStore'); + const connected = useInstanceStore.getState().instances.filter(i => i.status === 'connected'); + for (const inst of connected) { + targets.push({ origin: inst.origin, apiClient: inst.api }); + } + } catch { /* instanceStore not available yet */ } + + const results = await Promise.allSettled( + targets.map(t => t.apiClient.spaceLayout.update({ items, folders, updatedAt: now })) + ); + + // Use the first successful response to resolve new:* IDs + for (const result of results) { + if (result.status === 'fulfilled') { + const resolved = result.value; + set({ + spaceLayout: resolved.items, + folders: resolved.folders, + _layoutUpdatedAt: resolved.updatedAt ?? now, + }); + break; } } }, - populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null) => { + populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null, layoutUpdatedAt?: number) => { const isHome = !origin; // Tag all incoming servers with their instance origin @@ -568,26 +605,19 @@ export const useSpaceStore = create((set, get) => ({ categoryOriginMap, }; - // Determine if this origin is the user's true home (federation-aware) - const currentUser = useAuthStore.getState().user; - const isTrueHome = !!currentUser?.homeInstance && origin !== '' && (() => { - try { return new URL(origin).host === currentUser.homeInstance; } catch { return false; } - })(); - - // Accept layout from true home (authoritative) or browsing instance (fallback) - if (isTrueHome) { - // Authoritative: true home always wins - update.folders = folders || []; - if (spaceLayout !== undefined) { - update.spaceLayout = spaceLayout ?? null; - } - update._layoutFromTrueHome = true; - } else if (isHome && !get()._layoutFromTrueHome) { - // Fallback: browsing instance's layout, only until true home connects + // LWW layout merge: accept incoming layout only if its timestamp is >= ours + const incomingTs = layoutUpdatedAt ?? 0; + const currentTs = get()._layoutUpdatedAt; + if (incomingTs >= currentTs) { + // Incoming is same age or newer — accept update.folders = folders || []; if (spaceLayout !== undefined) { update.spaceLayout = spaceLayout ?? null; } + (update as any)._layoutUpdatedAt = incomingTs; + } else { + // Our layout is newer — push back to this instance + pushLayoutToOrigin(origin, get().spaceLayout, get().folders, currentTs); } set(update as any); @@ -706,16 +736,6 @@ export const useSpaceStore = create((set, get) => ({ } } - // If the removed origin was the true home, reset the layout authority flag - // so the browsing instance's layout can serve as fallback again - const currentUser = useAuthStore.getState().user; - let resetLayoutFlag = false; - if (currentUser?.homeInstance && origin !== '') { - try { - resetLayoutFlag = new URL(origin).host === currentUser.homeInstance; - } catch { /* ignore */ } - } - return { spaces: remainingSpaces, channelToSpaceMap, @@ -726,7 +746,6 @@ export const useSpaceStore = create((set, get) => ({ currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId) ? state.currentSpaceId : null, - ...(resetLayoutFlag ? { _layoutFromTrueHome: false } : {}), }; }); }, diff --git a/packages/web/src/utils/profileSync.ts b/packages/web/src/utils/profileSync.ts index 9cb26f4f..9d48669c 100644 --- a/packages/web/src/utils/profileSync.ts +++ b/packages/web/src/utils/profileSync.ts @@ -1,67 +1,145 @@ import type { UpdateUserRequest } from '@backspace/shared'; import { useAuthStore } from '../stores/authStore'; import { useInstanceStore, type ConnectedInstance } from '../stores/instanceStore'; +import { api } from '../api/client'; // ─── Internal helper ──────────────────────────────────────────────────────── -async function downloadHomeAsset(filename: string): Promise { +async function downloadAsset(filename: string, origin?: string): Promise { if (filename.startsWith('http') || filename.startsWith('blob:')) { const res = await fetch(filename); return res.blob(); } - const res = await fetch(`/api/uploads/${filename}`); + const base = origin ? `${origin}/api/uploads/${filename}` : `/api/uploads/${filename}`; + const res = await fetch(base); 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. + * Bidirectional profile sync with a single remote instance using LWW timestamps. + * - If home is newer (or both equal): push home → remote (existing behavior) + * - If remote is newer: pull remote → home, then relay to all other remotes + * - If equal: no-op */ 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, - status: homeUser.status || undefined, - }; + const homeTs = homeUser.profileUpdatedAt ?? 0; + const remoteTs = inst.user?.profileUpdatedAt ?? 0; - // 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); - } + if (homeTs >= remoteTs) { + // Home is newer (or equal) — push to remote + await pushProfileToRemote(inst, homeUser); } else { - payload.avatar = ''; + // Remote is newer — pull from remote to home, then relay + await pullProfileFromRemote(inst); } - - // 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); + console.warn(`[ProfileSync] Full sync with ${inst.origin} failed:`, err); + } +} + +/** + * Push the home profile to a single remote instance. + */ +async function pushProfileToRemote(inst: ConnectedInstance, homeUser: NonNullable['user']>): Promise { + const payload: UpdateUserRequest = { + displayName: homeUser.displayName || undefined, + avatarColor: homeUser.avatarColor || undefined, + accentColor: homeUser.accentColor || undefined, + bio: homeUser.bio || undefined, + customStatus: homeUser.customStatus || undefined, + status: homeUser.status || undefined, + profileUpdatedAt: homeUser.profileUpdatedAt, + }; + + // Sync avatar + if (homeUser.avatar) { + try { + const blob = await downloadAsset(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 downloadAsset(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); +} + +/** + * Pull a remote instance's newer profile to the browsing (home) instance, + * then relay to all other connected remotes. + */ +async function pullProfileFromRemote(inst: ConnectedInstance): Promise { + const remoteUser = inst.user; + if (!remoteUser) return; + + const payload: UpdateUserRequest = { + displayName: remoteUser.displayName || undefined, + avatarColor: remoteUser.avatarColor || undefined, + accentColor: remoteUser.accentColor || undefined, + bio: remoteUser.bio || undefined, + customStatus: remoteUser.customStatus || undefined, + profileUpdatedAt: remoteUser.profileUpdatedAt, + }; + + // Download and re-upload avatar from remote → home + if (remoteUser.avatar) { + try { + const blob = await downloadAsset(remoteUser.avatar, inst.origin); + const attachment = await api.uploads.upload(new File([blob], remoteUser.avatar.split('/').pop() || 'avatar')); + payload.avatar = attachment.filename; + } catch (err) { + console.warn('[ProfileSync] Failed to download/upload avatar from remote:', err); + } + } else { + payload.avatar = ''; + } + + // Download and re-upload banner from remote → home + if (remoteUser.banner) { + try { + const blob = await downloadAsset(remoteUser.banner, inst.origin); + const attachment = await api.uploads.upload(new File([blob], remoteUser.banner.split('/').pop() || 'banner')); + payload.banner = attachment.filename; + } catch (err) { + console.warn('[ProfileSync] Failed to download/upload banner from remote:', err); + } + } else { + payload.banner = ''; + } + + // PATCH browsing instance (home) with the remote's newer data + const updatedUser = await api.users.update(payload); + useAuthStore.getState().setUser(updatedUser); + + // Relay to all OTHER connected remotes (exclude the source) + const { instances } = useInstanceStore.getState(); + const otherConnected = instances.filter(i => i.status === 'connected' && i.origin !== inst.origin); + if (otherConnected.length > 0) { + await Promise.allSettled( + otherConnected.map(other => pushProfileToRemote(other, updatedUser)) + ); } } @@ -88,6 +166,9 @@ export async function syncProfileUpdateToRemotes(update: Partial