diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index 1b286b43..e39e79a5 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -830,6 +830,125 @@ export function queueDmCloseRelay( ); } +/** + * Queue a group_metadata_update relay event for cross-instance sync of a + * group DM's name/icon. Owner-authority only — callers must verify the actor + * is the channel owner before invoking this helper. + * + * The icon param is the bare local filename (or null on cleared/owner side). + * It is normalized to an absolute URL for the wire payload so peers can + * download it via the file replication queue. + * + * No-op for channels with no remote participants. + */ +export function queueGroupMetadataRelay( + channelId: string, + payload: { + name: string | null; + icon: string | null; // bare filename or null on the owner's side + metadataUpdatedAt: number; + actor: { userId: string; homeUserId: string; homeInstance: string }; + }, +): void { + if (!isFederationRelayEnabled()) return; + + const db = getDb(); + + // Look up channel to retrieve federatedId. Group DMs created post-federation + // always carry one; if it's missing, log and skip — we have no way to address + // the channel on the wire. + const channel = db + .select({ federatedId: schema.dmChannels.federatedId }) + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, channelId)) + .get(); + + if (!channel) { + console.warn(`[federation-outbox] queueGroupMetadataRelay: channel ${channelId} not found`); + return; + } + if (!channel.federatedId) { + console.warn(`[federation-outbox] queueGroupMetadataRelay: channel ${channelId} has no federatedId; skipping relay`); + return; + } + + const targetOrigins = getGroupDmTargetOrigins(channelId); + if (!targetOrigins || targetOrigins.length === 0) { + // No remote participants — nothing to relay. + return; + } + + const ourOrigin = getOurOrigin(); + const wireIcon = payload.icon + ? (payload.icon.startsWith('http') ? payload.icon : `${ourOrigin}/api/uploads/${payload.icon}`) + : null; + + // Resolve actor's full FederationRelayParticipant. Mirrors the participant + // construction in getDmParticipants() / buildRelayPayload() so receivers + // can hydrate replicated stubs uniformly. + const actorRow = db + .select({ + id: schema.users.id, + homeUserId: schema.users.homeUserId, + homeInstance: schema.users.homeInstance, + username: schema.users.username, + displayName: schema.users.displayName, + avatar: schema.users.avatar, + avatarColor: schema.users.avatarColor, + status: schema.users.status, + }) + .from(schema.users) + .where(eq(schema.users.id, payload.actor.userId)) + .get(); + + if (!actorRow) { + console.warn(`[federation-outbox] queueGroupMetadataRelay: actor user ${payload.actor.userId} not found`); + return; + } + + const actorParticipant: FederationRelayParticipant = { + homeUserId: actorRow.homeUserId || actorRow.id, + homeInstance: actorRow.homeInstance || ourOrigin, + profile: { + username: actorRow.username ?? null, + displayName: actorRow.displayName ?? null, + avatar: actorRow.avatar ?? null, + avatarColor: actorRow.avatarColor ?? null, + // Only carry presence for native participants — replicated stubs hold + // stale status owned by their home; emitting it would flap remote UIs. + status: !actorRow.homeInstance ? (actorRow.status as 'online' | 'idle' | 'dnd' | 'offline' | null) : null, + }, + }; + + const metadataPayload = { + name: payload.name, + icon: wireIcon, + metadataUpdatedAt: payload.metadataUpdatedAt, + actor: actorParticipant, + }; + + // Stable entityId per channel+timestamp so retries coalesce correctly with + // the existing per-peer entityId index in the outbox table. + const entityId = `group_metadata:${channel.federatedId}:${payload.metadataUpdatedAt}`; + + appendMutationLog( + entityId, + channelId, + 'group_metadata_update', + JSON.stringify(metadataPayload), + ); + queueOutboxEvent( + entityId, + channelId, + 'group_metadata_update', + JSON.stringify({ + federatedId: channel.federatedId, + metadata: metadataPayload, + }), + targetOrigins, + ); +} + /** * Send typing indicator events directly to remote peers (bypasses outbox). * Fire-and-forget — typing is ephemeral, lost packets are acceptable. diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index fdd82e7c..807dcfe7 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -219,6 +219,7 @@ export async function processOutboxTick(): Promise { if (parsed.rejectionReason) evt.rejectionReason = parsed.rejectionReason; if (parsed.rejectionLimit != null) evt.rejectionLimit = parsed.rejectionLimit; if (parsed.affectedUserIds) evt.affectedUserIds = parsed.affectedUserIds; + if (parsed.metadata) evt.metadata = parsed.metadata; if (parsed.profileUpdate) evt.profileUpdate = parsed.profileUpdate; if (parsed.presenceUpdate) evt.presenceUpdate = parsed.presenceUpdate; if (parsed.readState) evt.readState = parsed.readState;