From 699c5a4365b2d6a0633ca635678dce90cd6ae616 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 27 Mar 2026 01:26:46 +0100 Subject: [PATCH] fix(federation): include profile snapshots in friend relay events Replicated user stubs created by resolveOrCreateReplicatedUser had null avatar/displayName, causing blank profiles in the UI until page refresh. Friend relay events now carry profile snapshots (displayName, avatar, avatarColor, banner, bio) so the receiving instance can hydrate stubs with real data. --- packages/server/src/routes/federation.ts | 43 +++++++++++++++++++++--- packages/server/src/routes/social.ts | 18 +++++++++- packages/shared/src/types.ts | 10 ++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 0fd73f63..a5a7031f 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -12,7 +12,7 @@ import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { computeFederatedId, getDmParticipants } from '../utils/federationOutbox.js'; import { getDmMessageWithUser } from './dm.js'; import { AVATAR_COLORS } from '@backspace/shared'; -import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser } from '@backspace/shared'; +import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, FederationRelayProfileSnapshot } from '@backspace/shared'; /** Fields safe to expose to admin callers (everything except hmacSecret). */ interface SanitizedPeer { @@ -1859,6 +1859,36 @@ function processOwnershipTransferEvent( // ─── Friend Event Processors ───────────────────────────────────────────────── +/** + * Hydrate a replicated user stub with profile data from a relay event. + * Only updates fields that are currently null/empty on the local row, + * so manually-set local values are preserved. + */ +function hydrateReplicatedUserProfile( + user: typeof schema.users.$inferSelect, + profile: FederationRelayProfileSnapshot | undefined, + db: ReturnType, +): typeof schema.users.$inferSelect { + if (!profile) return user; + if (!user.homeInstance) return user; // Don't update native users + + const updates: Record = {}; + if (profile.displayName && !user.displayName) updates.displayName = profile.displayName; + if (profile.avatar && !user.avatar) updates.avatar = profile.avatar; + if (profile.avatarColor && !user.avatarColor) updates.avatarColor = profile.avatarColor; + if (profile.banner && !user.banner) updates.banner = profile.banner; + if (profile.bio && !user.bio) updates.bio = profile.bio; + + if (Object.keys(updates).length === 0) return user; + + db.update(schema.users) + .set(updates) + .where(eq(schema.users.id, user.id)) + .run(); + + return { ...user, ...updates }; +} + function processFriendRequestCreateEvent( event: FederationRelayEvent, sourceInstance: string, @@ -1880,7 +1910,8 @@ function processFriendRequestCreateEvent( } // Resolve the sender (create stub if needed — they're on a remote instance) - const fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db); + let fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db); + fromUser = hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile, db); // Resolve the recipient — must be a local user on this instance const toUser = resolveLocalUser(to.homeUserId, db); @@ -2114,9 +2145,11 @@ function processFriendAddEvent( return; } - // Resolve both users (create stubs if needed) - const fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db); - const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db); + // Resolve both users (create stubs if needed) and hydrate with profile data + let fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db); + fromUser = hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile, db); + let toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db); + toUser = hydrateReplicatedUserProfile(toUser, event.friendship.toProfile, db); // Idempotency: if friendship already exists, accept as no-op const existingFriend = db diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index e2a2e358..27eb593a 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -6,7 +6,7 @@ import { generateSnowflake } from '../utils/snowflake.js'; import { connectionManager } from '../ws/handler.js'; import { appendMutationLog, queueOutboxEvent, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js'; import { getOurOrigin } from '../utils/federationAuth.js'; -import type { FederationRelayEvent } from '@backspace/shared'; +import type { FederationRelayEvent, FederationRelayProfileSnapshot } from '@backspace/shared'; import type { Friend, FriendRequest, @@ -16,6 +16,16 @@ import type { } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; +function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot { + return { + displayName: user.displayName ?? null, + avatar: user.avatar ?? null, + avatarColor: user.avatarColor ?? null, + banner: user.banner ?? null, + bio: user.bio ?? null, + }; +} + export async function socialRoutes(app: FastifyInstance): Promise { // GET /api/social/friends - List all friends app.get('/api/social/friends', { @@ -200,6 +210,8 @@ export async function socialRoutes(app: FastifyInstance): Promise { friendship: { from: fromIdentity, to: toIdentity, + fromProfile: senderUser ? buildProfileSnapshot(senderUser) : undefined, + toProfile: buildProfileSnapshot(targetUser), status: 'pending', createdAt: now, }, @@ -310,6 +322,8 @@ export async function socialRoutes(app: FastifyInstance): Promise { friendship: { from: fromIdentity, to: toIdentity, + fromProfile: buildProfileSnapshot(fromUser), + toProfile: buildProfileSnapshot(toUser), status: status as 'accepted' | 'declined', createdAt: friendRequest.createdAt, }, @@ -330,6 +344,8 @@ export async function socialRoutes(app: FastifyInstance): Promise { friendship: { from: fromIdentity, to: toIdentity, + fromProfile: buildProfileSnapshot(fromUser), + toProfile: buildProfileSnapshot(toUser), createdAt: now2, }, }; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 5fa61018..67ca9279 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -782,9 +782,19 @@ export interface FederationGroupPayload { members: FederationRelayParticipant[]; } +export interface FederationRelayProfileSnapshot { + displayName?: string | null; + avatar?: string | null; + avatarColor?: string | null; + banner?: string | null; + bio?: string | null; +} + export interface FederationFriendshipPayload { from: FederationRelayParticipant; to: FederationRelayParticipant; + fromProfile?: FederationRelayProfileSnapshot; + toProfile?: FederationRelayProfileSnapshot; status?: 'pending' | 'accepted' | 'declined'; createdAt: number; }