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.
This commit is contained in:
@@ -12,7 +12,7 @@ import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
|||||||
import { computeFederatedId, getDmParticipants } from '../utils/federationOutbox.js';
|
import { computeFederatedId, getDmParticipants } from '../utils/federationOutbox.js';
|
||||||
import { getDmMessageWithUser } from './dm.js';
|
import { getDmMessageWithUser } from './dm.js';
|
||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
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). */
|
/** Fields safe to expose to admin callers (everything except hmacSecret). */
|
||||||
interface SanitizedPeer {
|
interface SanitizedPeer {
|
||||||
@@ -1859,6 +1859,36 @@ function processOwnershipTransferEvent(
|
|||||||
|
|
||||||
// ─── Friend Event Processors ─────────────────────────────────────────────────
|
// ─── 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 getDb>,
|
||||||
|
): typeof schema.users.$inferSelect {
|
||||||
|
if (!profile) return user;
|
||||||
|
if (!user.homeInstance) return user; // Don't update native users
|
||||||
|
|
||||||
|
const updates: Record<string, string | null> = {};
|
||||||
|
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(
|
function processFriendRequestCreateEvent(
|
||||||
event: FederationRelayEvent,
|
event: FederationRelayEvent,
|
||||||
sourceInstance: string,
|
sourceInstance: string,
|
||||||
@@ -1880,7 +1910,8 @@ function processFriendRequestCreateEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the sender (create stub if needed — they're on a remote instance)
|
// 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
|
// Resolve the recipient — must be a local user on this instance
|
||||||
const toUser = resolveLocalUser(to.homeUserId, db);
|
const toUser = resolveLocalUser(to.homeUserId, db);
|
||||||
@@ -2114,9 +2145,11 @@ function processFriendAddEvent(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve both users (create stubs if needed)
|
// Resolve both users (create stubs if needed) and hydrate with profile data
|
||||||
const fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db);
|
let fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db);
|
||||||
const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.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
|
// Idempotency: if friendship already exists, accept as no-op
|
||||||
const existingFriend = db
|
const existingFriend = db
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { generateSnowflake } from '../utils/snowflake.js';
|
|||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import { appendMutationLog, queueOutboxEvent, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js';
|
import { appendMutationLog, queueOutboxEvent, buildFriendContextId, getFriendEventTargets } from '../utils/federationOutbox.js';
|
||||||
import { getOurOrigin } from '../utils/federationAuth.js';
|
import { getOurOrigin } from '../utils/federationAuth.js';
|
||||||
import type { FederationRelayEvent } from '@backspace/shared';
|
import type { FederationRelayEvent, FederationRelayProfileSnapshot } from '@backspace/shared';
|
||||||
import type {
|
import type {
|
||||||
Friend,
|
Friend,
|
||||||
FriendRequest,
|
FriendRequest,
|
||||||
@@ -16,6 +16,16 @@ import type {
|
|||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
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<void> {
|
export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// GET /api/social/friends - List all friends
|
// GET /api/social/friends - List all friends
|
||||||
app.get('/api/social/friends', {
|
app.get('/api/social/friends', {
|
||||||
@@ -200,6 +210,8 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
friendship: {
|
friendship: {
|
||||||
from: fromIdentity,
|
from: fromIdentity,
|
||||||
to: toIdentity,
|
to: toIdentity,
|
||||||
|
fromProfile: senderUser ? buildProfileSnapshot(senderUser) : undefined,
|
||||||
|
toProfile: buildProfileSnapshot(targetUser),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
},
|
},
|
||||||
@@ -310,6 +322,8 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
friendship: {
|
friendship: {
|
||||||
from: fromIdentity,
|
from: fromIdentity,
|
||||||
to: toIdentity,
|
to: toIdentity,
|
||||||
|
fromProfile: buildProfileSnapshot(fromUser),
|
||||||
|
toProfile: buildProfileSnapshot(toUser),
|
||||||
status: status as 'accepted' | 'declined',
|
status: status as 'accepted' | 'declined',
|
||||||
createdAt: friendRequest.createdAt,
|
createdAt: friendRequest.createdAt,
|
||||||
},
|
},
|
||||||
@@ -330,6 +344,8 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
friendship: {
|
friendship: {
|
||||||
from: fromIdentity,
|
from: fromIdentity,
|
||||||
to: toIdentity,
|
to: toIdentity,
|
||||||
|
fromProfile: buildProfileSnapshot(fromUser),
|
||||||
|
toProfile: buildProfileSnapshot(toUser),
|
||||||
createdAt: now2,
|
createdAt: now2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -782,9 +782,19 @@ export interface FederationGroupPayload {
|
|||||||
members: FederationRelayParticipant[];
|
members: FederationRelayParticipant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FederationRelayProfileSnapshot {
|
||||||
|
displayName?: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
avatarColor?: string | null;
|
||||||
|
banner?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FederationFriendshipPayload {
|
export interface FederationFriendshipPayload {
|
||||||
from: FederationRelayParticipant;
|
from: FederationRelayParticipant;
|
||||||
to: FederationRelayParticipant;
|
to: FederationRelayParticipant;
|
||||||
|
fromProfile?: FederationRelayProfileSnapshot;
|
||||||
|
toProfile?: FederationRelayProfileSnapshot;
|
||||||
status?: 'pending' | 'accepted' | 'declined';
|
status?: 'pending' | 'accepted' | 'declined';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user