From 0d6bb9dbd355ce7a9cff5c1510f4692edcf1207e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 27 Mar 2026 03:43:45 +0100 Subject: [PATCH] fix(federation): auto-create and hydrate user profiles during DM relay Federated 1-on-1 DMs showed the raw snowflake ID as the display name and no avatar when the remote user had no pre-existing local record. processCreateEvent used resolveLocalUser (find-only) instead of resolveOrCreateReplicatedUser, and relay events carried no profile data for participants. - Add profile snapshot (displayName, avatar, avatarColor) to FederationRelayParticipant and populate it in getDmParticipants - Change processCreateEvent to auto-create replicated user stubs and hydrate them with profile data from the relay event - Fix hydrateReplicatedUserProfile URL resolution for homeInstance values without protocol prefix - Fix WelcomeHeader: return null while DM data is loading (eliminates "unknown" flash on reload), use displayName for @mention text --- packages/server/src/routes/federation.ts | 15 ++++++++++----- packages/server/src/utils/federationOutbox.ts | 8 ++++++++ packages/shared/src/types.ts | 1 + packages/web/src/components/chat/MessageList.tsx | 11 ++++++----- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 05ebbe08..93d21a3c 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1119,17 +1119,21 @@ function processCreateEvent( return; } - // Resolve ALL participants to local users + // Resolve ALL participants to local users, auto-creating replicated stubs + // for remote users that don't have a local record yet. This ensures 1-on-1 + // federated DMs work even when the remote user hasn't connected or friended. const resolvedParticipants: Array<{ localUser: typeof schema.users.$inferSelect; homeUserId: string; }> = []; for (const p of event.participants) { - const localUser = resolveLocalUser(p.homeUserId, db); - if (localUser) { - resolvedParticipants.push({ localUser, homeUserId: p.homeUserId }); + let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db); + // Hydrate with profile data from the relay event (displayName, avatar, etc.) + if (p.profile) { + localUser = hydrateReplicatedUserProfile(localUser, p.profile, db); } + resolvedParticipants.push({ localUser, homeUserId: p.homeUserId }); } if (resolvedParticipants.length < 2) { @@ -1875,10 +1879,11 @@ function hydrateReplicatedUserProfile( // Resolve bare filenames to absolute URLs pointing to the home instance. // The home WS doesn't run normalizeUserAssets on replicated users' avatars, // so they must be stored as absolute URLs to render correctly. + const baseUrl = user.homeInstance!.startsWith('http') ? user.homeInstance! : `https://${user.homeInstance}`; const resolveUrl = (filename: string | null | undefined): string | null => { if (!filename) return null; if (filename.startsWith('http')) return filename; - return `${user.homeInstance}/api/uploads/${filename}`; + return `${baseUrl}/api/uploads/${filename}`; }; const updates: Record = {}; diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index 8f8b4919..1f178a59 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -245,6 +245,9 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa homeUserId: schema.users.homeUserId, homeInstance: schema.users.homeInstance, id: schema.users.id, + displayName: schema.users.displayName, + avatar: schema.users.avatar, + avatarColor: schema.users.avatarColor, }) .from(schema.dmMembers) .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id)) @@ -256,6 +259,11 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa return members.map(m => ({ homeUserId: m.homeUserId || m.id, homeInstance: m.homeInstance || domainOrigin, + profile: { + displayName: m.displayName ?? null, + avatar: m.avatar ?? null, + avatarColor: m.avatarColor ?? null, + }, })); } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index dee85c72..d3fedc4f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -734,6 +734,7 @@ export interface AdminResetPasswordResponse { export interface FederationRelayParticipant { homeUserId: string; homeInstance: string; + profile?: FederationRelayProfileSnapshot; } export interface FederationRelayEvent { diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index f74731ce..fd7bcb8d 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -373,10 +373,11 @@ function WelcomeHeader({ channelId }: { channelId: string }) { if (isDm) { const dm = dmChannels.find(d => d.id === channelId); - const otherUser = dm?.members.find(m => !isSelf(m, authUser)); - const { baseName } = parseFederatedUsername(otherUser?.username ?? 'unknown'); - const displayName = otherUser?.displayName ?? baseName; - const username = baseName; + if (!dm) return null; // DM data not yet loaded (WebSocket ready pending) + const otherUser = dm.members.find(m => !isSelf(m, authUser)); + const { baseName } = parseFederatedUsername(otherUser?.username ?? ''); + const displayName = otherUser?.displayName ?? (baseName || 'Direct Message'); + const mentionName = otherUser?.displayName ?? baseName; const isFriend = otherUser ? friends.some(f => f.id === otherUser.id) : false; return ( @@ -386,7 +387,7 @@ function WelcomeHeader({ channelId }: { channelId: string }) {

{displayName}

- This is the beginning of your direct message history with @{username}. + This is the beginning of your direct message history with @{mentionName}.

{otherUser?.homeInstance && (