From 016ca2c59bf2c678c676c5df5b22baef23db8310 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:23:49 +0200 Subject: [PATCH] fix: use canonical identity for federated friend/request dedup The socialStore WS-driven handlers (addFriendFromAccepted, addIncomingRequest, removeFriendLocally, removeRequestById, updateFriendPresence) used instance-local id:origin composite keys for deduplication. When the client is connected to multiple instances, both fire WS events for the same federated user with different local IDs, bypassing the dedup and creating duplicate entries. Switch all handlers to use homeUserId??id (canonical identity), matching the pattern loadFriends/loadRequests already use. Also replace the loadRequests() re-fetch in updateFriendRequest with optimistic canonical removal to avoid racing S2S relay propagation. --- packages/web/src/hooks/useWebSocket.ts | 4 +-- packages/web/src/stores/socialStore.ts | 49 +++++++++++++++++++------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index c778f704..44f4c906 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -736,7 +736,7 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'friend_request_cancelled': { const { removeRequestById } = useSocialStore.getState(); - removeRequestById(event.requestId, origin); + removeRequestById(event.requestId, origin, event.userId); import('../stores/discoverStore').then(({ useDiscoverStore }) => { useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none'); }); @@ -745,7 +745,7 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'friend_request_declined': { const { removeRequestById } = useSocialStore.getState(); - removeRequestById(event.requestId, origin); + removeRequestById(event.requestId, origin, event.userId); import('../stores/discoverStore').then(({ useDiscoverStore }) => { useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none'); }); diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 0110352b..62a9c775 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -79,7 +79,7 @@ interface SocialState { updateFriendPresence: (userId: string, status: string) => void; updateFriendProfile: (user: User) => void; removeFriendLocally: (userId: string, origin: string) => void; - removeRequestById: (requestId: string, origin: string) => void; + removeRequestById: (requestId: string, origin: string, userId?: string) => void; removeRequestsForUser: (userId: string) => void; reset: () => void; } @@ -248,7 +248,18 @@ export const useSocialStore = create((set, get) => ({ const client = getApiForOrigin(origin); await client.social.updateRequest(id, status); - await get().loadRequests(); + + // Optimistically remove all requests from the same canonical user — + // the S2S relay will eventually clean up the other instance, but + // re-fetching immediately would race with relay propagation. + const canonicalId = request?.user?.homeUserId ?? request?.user?.id; + set((state) => ({ + requests: canonicalId + ? state.requests.filter(r => (r.user?.homeUserId ?? r.user?.id) !== canonicalId) + : state.requests.filter(r => r.id !== id), + isLoading: false, + })); + if (status === 'accepted') { await get().loadFriends(); } @@ -266,8 +277,11 @@ export const useSocialStore = create((set, get) => ({ const client = getApiForOrigin(origin); await client.social.cancelRequest(id); + const canonicalId = request?.user?.homeUserId ?? request?.user?.id; set((state) => ({ - requests: state.requests.filter(r => r.id !== id), + requests: canonicalId + ? state.requests.filter(r => (r.user?.homeUserId ?? r.user?.id) !== canonicalId) + : state.requests.filter(r => r.id !== id), isLoading: false, })); } catch (err) { @@ -351,8 +365,10 @@ export const useSocialStore = create((set, get) => ({ // Called from WS handler when another user sends you a friend request addIncomingRequest: (request: FriendRequest, origin: string) => { set((state) => { - const key = `${request.id}:${origin}`; - if (state.requests.find(r => `${r.id}:${r._instanceOrigin}` === key)) return state; + const canonicalId = request.user?.homeUserId ?? request.user?.id; + if (canonicalId && state.requests.some(r => (r.user?.homeUserId ?? r.user?.id) === canonicalId)) { + return state; + } return { requests: [...state.requests, { ...request, _instanceOrigin: origin }] }; }); }, @@ -360,8 +376,8 @@ export const useSocialStore = create((set, get) => ({ // Called from WS handler when someone accepts your friend request addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => { set((state) => { - const key = `${friend.id}:${origin}`; - const alreadyExists = state.friends.some(f => `${f.id}:${f._instanceOrigin}` === key); + const canonicalId = friend.homeUserId ?? friend.id; + const alreadyExists = state.friends.some(f => (f.homeUserId ?? f.id) === canonicalId); return { friends: alreadyExists ? state.friends : [...state.friends, { ...friend, _instanceOrigin: origin }], requests: state.requests.filter(r => !(r.id === requestId && r._instanceOrigin === origin)), @@ -370,16 +386,25 @@ export const useSocialStore = create((set, get) => ({ }, // Called from WS handler when the other user removes us as a friend - removeFriendLocally: (userId: string, origin: string) => { + removeFriendLocally: (userId: string, _origin: string) => { set((state) => ({ - friends: state.friends.filter(f => !(f.id === userId && f._instanceOrigin === origin)), + friends: state.friends.filter(f => f.id !== userId && f.homeUserId !== userId), })); }, // Called from WS handler when a friend request is cancelled or declined - removeRequestById: (requestId: string, origin: string) => { + removeRequestById: (requestId: string, _origin: string, userId?: string) => { set((state) => ({ - requests: state.requests.filter(r => !(r.id === requestId && r._instanceOrigin === origin)), + requests: state.requests.filter(r => { + if (r.id === requestId) return false; + // Also match by canonical identity — the WS event may carry a different + // request ID than the one stored (different instance's copy) + if (userId) { + const canonical = r.user?.homeUserId ?? r.user?.id; + if (canonical === userId || r.user?.id === userId || r.user?.homeUserId === userId) return false; + } + return true; + }), })); }, @@ -394,7 +419,7 @@ export const useSocialStore = create((set, get) => ({ updateFriendPresence: (userId: string, status: string) => { set((state) => ({ friends: state.friends.map(f => - f.id === userId ? { ...f, status: status as Friend['status'] } : f + (f.id === userId || f.homeUserId === userId) ? { ...f, status: status as Friend['status'] } : f ), })); },