diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index c13077bd..1801ad00 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -645,15 +645,25 @@ export async function dmRoutes(app: FastifyInstance): Promise { lastMessage: null, }; - // Broadcast dm_channel_created to ALL members (including caller) + // Broadcast dm_channel_created only to LOCAL members. + // Remote members will receive the channel via federation relay → bootstrap + // on their home instance, preventing duplicate channels in their sidebar. + const domainOriginForBroadcast = isFederationRelayEnabled() ? getOurOrigin() : null; + const isLocalMember = (u: { homeInstance?: string | null }) => + !u.homeInstance || !domainOriginForBroadcast || + u.homeInstance === domainOriginForBroadcast || + `https://${u.homeInstance}` === domainOriginForBroadcast; + for (const member of allMembers) { + if (!isLocalMember(member)) continue; connectionManager.sendToUser(member.id, { type: 'dm_channel_created', dmChannel: result, }); } - // Insert & broadcast system messages for each added member + // Insert system messages (DB) for all members, but only broadcast to local members. + // Remote instances create their own system messages via federation event handlers. for (const targetUser of targetUsers) { if (!targetUser) continue; const baseName = targetUser.username.includes('@') ? targetUser.username.split('@')[0] : targetUser.username; @@ -683,10 +693,14 @@ export async function dmRoutes(app: FastifyInstance): Promise { createdAt: sysMsg.createdAt, }).run(); - connectionManager.sendToDmMembers(dmChannelId, { - type: 'dm_message_created', - message: sysMsg as any, - }); + // Only broadcast to local members — remote instances handle their own + for (const member of allMembers) { + if (!isLocalMember(member)) continue; + connectionManager.sendToUser(member.id, { + type: 'dm_message_created', + message: sysMsg as any, + }); + } } // Federation: relay member_add for each remote member @@ -725,8 +739,10 @@ export async function dmRoutes(app: FastifyInstance): Promise { const targetOrigins = getGroupDmTargetOrigins(dmChannelId); let finalTargets = targetOrigins; - if (finalTargets && targetUser.homeInstance !== domainOrigin && !finalTargets.includes(targetUser.homeInstance)) { - finalTargets = [...finalTargets, targetUser.homeInstance]; + // Normalize homeInstance to full URL to match peer origin format + const targetHomeOrigin = targetUser.homeInstance?.startsWith('http') ? targetUser.homeInstance : `https://${targetUser.homeInstance}`; + if (finalTargets && targetHomeOrigin !== domainOrigin && !finalTargets.includes(targetHomeOrigin)) { + finalTargets = [...finalTargets, targetHomeOrigin]; } appendMutationLog( @@ -1010,7 +1026,9 @@ export async function dmRoutes(app: FastifyInstance): Promise { // Include the new member's instance in targets even if not previously in the group const targetOrigins = getGroupDmTargetOrigins(id); - const newMemberInstance = addedUser?.homeInstance || domainOrigin; + // Normalize homeInstance to full URL to match peer origin format + const rawNewMemberInstance = addedUser?.homeInstance || domainOrigin; + const newMemberInstance = rawNewMemberInstance.startsWith('http') ? rawNewMemberInstance : `https://${rawNewMemberInstance}`; let finalTargets = targetOrigins; if (finalTargets && newMemberInstance !== domainOrigin && !finalTargets.includes(newMemberInstance)) { finalTargets = [...finalTargets, newMemberInstance]; diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 78d14605..32117cd9 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1705,8 +1705,15 @@ function processMemberAddEvent( lastMessage: null, }; - // Send dm_channel_created to all local WebSocket-connected members + // Send dm_channel_created only to members whose home is THIS instance. + // Remote replicas will get the channel from their own home instance's + // federation bootstrap — prevents duplicate channels in their sidebar. + const bootstrapOrigin = getOurOrigin(); for (const mu of memberUsers) { + const muHome = mu.homeInstance + ? (mu.homeInstance.startsWith('http') ? mu.homeInstance : `https://${mu.homeInstance}`) + : bootstrapOrigin; // null homeInstance = native local user + if (muHome !== bootstrapOrigin) continue; connectionManager.sendToUser(mu.id, { type: 'dm_channel_created', dmChannel: bootstrapResult, diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index 1f178a59..3d3f7fbc 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -288,8 +288,10 @@ export function getGroupDmTargetOrigins(dmChannelId: string): string[] | undefin const origins = new Set(); for (const p of participants) { - if (p.homeInstance !== ourOrigin) { - origins.add(p.homeInstance); + // Normalize homeInstance to full URL format to match federation_peers.origin + const normalized = p.homeInstance.startsWith('http') ? p.homeInstance : `https://${p.homeInstance}`; + if (normalized !== ourOrigin) { + origins.add(normalized); } } diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 67990aaf..1cb7ff26 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -171,6 +171,7 @@ async function processOutboxTick(): Promise { timestamp: entry.createdAt, }; if (isDm && entry.contextId) evt.dmChannelId = entry.contextId; + if (parsed.federatedId) evt.federatedId = parsed.federatedId; if (parsed.participants) evt.participants = parsed.participants; if (parsed.message) evt.message = parsed.message; if (parsed.reactions) evt.reactions = parsed.reactions;