fix(web): deduplicate DM sidebar entries for federated users

When the same person registers on multiple federated instances, separate
DM channels are created for each identity, causing duplicate sidebar
entries (e.g., "Nova" appearing twice — once for local, once for
federated account).

Add deduplicateDmChannels() safety net that detects matching counterparts
using two layers: canonicalUserMatch (same ID/homeUserId/username) and
replicatedInstances cross-check (local user has a replicated account on
the federated user's home instance with the same base username). Keeps
the DM with the most recent activity, applied in both setDmChannels and
populateFromReady.

Investigation: queried both live instances (nova.ddns.net, orbit)
and confirmed root cause is same-person-two-accounts, not a server-side
creation bug or identity resolution gap.
This commit is contained in:
Jannis Braun
2026-04-02 18:11:18 +02:00
parent c7c86007c4
commit 32d9647ed1
+121 -3
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import type { Space, Channel, ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, SpaceLayoutItem, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared'; import type { Space, Channel, ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, SpaceLayoutItem, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared';
import { api, BackspaceApiClient } from '../api/client'; import { api, BackspaceApiClient } from '../api/client';
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls'; import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
import { isSelf } from '../utils/identity'; import { isSelf, canonicalUserMatch, parseFederatedUsername } from '../utils/identity';
import { sortDmChannels } from '../utils/dmSorting'; import { sortDmChannels } from '../utils/dmSorting';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { useChatStore } from './chatStore'; import { useChatStore } from './chatStore';
@@ -115,6 +115,115 @@ async function pushLayoutToOrigin(
} }
} }
/**
* Check if two users are the same person who registered on multiple instances.
*
* Extends `canonicalUserMatch` with a replicatedInstances cross-check:
* if user A has a replicated account on user B's home instance (or vice versa)
* and both share the same base username, they represent the same person.
*
* Example: local "nova" has replicatedInstances on orbit.ddns.net, and
* federated "nova@orbit.ddns.net" has homeInstance "orbit.ddns.net"
* with base username "nova" → same person.
*/
function isSameCounterpart(a: User, b: User): boolean {
// Fast path: canonical identity match handles same-ID, homeUserId cross-match,
// and same-instance username match
if (canonicalUserMatch(a, b)) return true;
// Extended check: replicatedInstances linking.
// If user A is local (no homeInstance) and has a replicated account on user B's
// homeInstance, and the base usernames match, they're the same person with
// accounts on both instances.
const aBase = parseFederatedUsername(a.username).baseName;
const bBase = parseFederatedUsername(b.username).baseName;
if (aBase !== bBase) return false;
// Check if A has a replicated account on B's home instance
if (b.homeInstance && a.replicatedInstances?.length) {
const bOrigin = b.homeInstance.startsWith('http') ? b.homeInstance : `https://${b.homeInstance}`;
for (const ri of a.replicatedInstances) {
const riOrigin = ri.origin ?? (ri.domain ? `https://${ri.domain}` : '');
if (riOrigin === bOrigin) return true;
}
}
// Check the reverse: B has a replicated account on A's home instance
if (a.homeInstance && b.replicatedInstances?.length) {
const aOrigin = a.homeInstance.startsWith('http') ? a.homeInstance : `https://${a.homeInstance}`;
for (const ri of b.replicatedInstances) {
const riOrigin = ri.origin ?? (ri.domain ? `https://${ri.domain}` : '');
if (riOrigin === aOrigin) return true;
}
}
return false;
}
/**
* Safety-net deduplication for 1-on-1 DM channels.
*
* When the same person has accounts on multiple federated instances (or when
* federation relay creates duplicate channel records), the sidebar can show
* multiple DM entries for the same counterpart. This function collapses them
* by using identity matching to detect when two counterpart users represent
* the same person, keeping the DM with the most recent activity.
*
* Detection layers:
* 1. `canonicalUserMatch` — same local ID, homeUserId cross-match, or
* same base username + home instance
* 2. `replicatedInstances` cross-check — local user has a replicated account
* on the federated user's home instance with the same base username
*
* Group DMs (ownerId set) are never deduplicated.
*/
function deduplicateDmChannels(dmChannels: DmChannel[], myUserId: string): DmChannel[] {
const counterparts: Array<{ user: User; resultIdx: number }> = [];
const result: DmChannel[] = [];
for (const dm of dmChannels) {
// Only dedup 1-on-1 DMs (no ownerId = not a group DM)
if (dm.ownerId) {
result.push(dm);
continue;
}
// Find the counterpart (the other member)
const other = dm.members.find(m => {
const mHomeId = m.homeUserId ?? m.id;
return mHomeId !== myUserId && m.id !== myUserId;
});
if (!other) {
result.push(dm);
continue;
}
// Check if we've already seen a DM with a matching counterpart
let duplicate = false;
for (const prev of counterparts) {
if (isSameCounterpart(other, prev.user)) {
// Found a duplicate — keep the one with the most recent activity
const existing = result[prev.resultIdx]!;
const existingTime = existing.lastMessage?.createdAt ?? existing.createdAt;
const currentTime = dm.lastMessage?.createdAt ?? dm.createdAt;
if (currentTime > existingTime) {
result[prev.resultIdx] = dm;
prev.user = other; // update counterpart reference
}
duplicate = true;
break;
}
}
if (!duplicate) {
counterparts.push({ user: other, resultIdx: result.length });
result.push(dm);
}
}
return result;
}
export const useSpaceStore = create<SpaceState>((set, get) => ({ export const useSpaceStore = create<SpaceState>((set, get) => ({
spaces: [], spaces: [],
currentSpaceId: null, currentSpaceId: null,
@@ -165,7 +274,10 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
setCategories: (categories) => set({ categories }), setCategories: (categories) => set({ categories }),
setMembers: (members) => set({ members }), setMembers: (members) => set({ members }),
setRoles: (roles) => set({ roles }), setRoles: (roles) => set({ roles }),
setDmChannels: (dmChannels) => set({ dmChannels }), setDmChannels: (dmChannels) => {
const myUserId = useAuthStore.getState().user?.id;
set({ dmChannels: myUserId ? deduplicateDmChannels(dmChannels, myUserId) : dmChannels });
},
addDmChannel: (channel, origin?: string) => set((state) => { addDmChannel: (channel, origin?: string) => set((state) => {
const channelOriginMap = new Map(state.channelOriginMap); const channelOriginMap = new Map(state.channelOriginMap);
@@ -669,7 +781,13 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
const dmOrigin = get().channelOriginMap.get(dm.id); const dmOrigin = get().channelOriginMap.get(dm.id);
return dmOrigin !== origin; return dmOrigin !== origin;
}); });
const mergedDms = [...existingDmsFromOtherOrigins, ...incomingDms]; const rawMergedDms = [...existingDmsFromOtherOrigins, ...incomingDms];
// Deduplicate 1-on-1 DMs that target the same counterpart across federated
// identities (e.g., local "nova" and federated "nova@orbit.ddns.net"
// are the same person — keep only the most recently active DM).
const myUserId = useAuthStore.getState().user?.id;
const mergedDms = myUserId ? deduplicateDmChannels(rawMergedDms, myUserId) : rawMergedDms;
// Sort DMs using unread-first ordering. On initial load, unreadChannels may // Sort DMs using unread-first ordering. On initial load, unreadChannels may
// still be empty (read states are processed after populateFromReady); the // still be empty (read states are processed after populateFromReady); the