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.
This commit is contained in:
Jannis Braun
2026-04-09 01:23:49 +02:00
parent 3789ece0ca
commit 016ca2c59b
2 changed files with 39 additions and 14 deletions
+2 -2
View File
@@ -736,7 +736,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'friend_request_cancelled': { case 'friend_request_cancelled': {
const { removeRequestById } = useSocialStore.getState(); const { removeRequestById } = useSocialStore.getState();
removeRequestById(event.requestId, origin); removeRequestById(event.requestId, origin, event.userId);
import('../stores/discoverStore').then(({ useDiscoverStore }) => { import('../stores/discoverStore').then(({ useDiscoverStore }) => {
useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none'); useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none');
}); });
@@ -745,7 +745,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'friend_request_declined': { case 'friend_request_declined': {
const { removeRequestById } = useSocialStore.getState(); const { removeRequestById } = useSocialStore.getState();
removeRequestById(event.requestId, origin); removeRequestById(event.requestId, origin, event.userId);
import('../stores/discoverStore').then(({ useDiscoverStore }) => { import('../stores/discoverStore').then(({ useDiscoverStore }) => {
useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none'); useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none');
}); });
+37 -12
View File
@@ -79,7 +79,7 @@ interface SocialState {
updateFriendPresence: (userId: string, status: string) => void; updateFriendPresence: (userId: string, status: string) => void;
updateFriendProfile: (user: User) => void; updateFriendProfile: (user: User) => void;
removeFriendLocally: (userId: string, origin: string) => 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; removeRequestsForUser: (userId: string) => void;
reset: () => void; reset: () => void;
} }
@@ -248,7 +248,18 @@ export const useSocialStore = create<SocialState>((set, get) => ({
const client = getApiForOrigin(origin); const client = getApiForOrigin(origin);
await client.social.updateRequest(id, status); 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') { if (status === 'accepted') {
await get().loadFriends(); await get().loadFriends();
} }
@@ -266,8 +277,11 @@ export const useSocialStore = create<SocialState>((set, get) => ({
const client = getApiForOrigin(origin); const client = getApiForOrigin(origin);
await client.social.cancelRequest(id); await client.social.cancelRequest(id);
const canonicalId = request?.user?.homeUserId ?? request?.user?.id;
set((state) => ({ 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, isLoading: false,
})); }));
} catch (err) { } catch (err) {
@@ -351,8 +365,10 @@ export const useSocialStore = create<SocialState>((set, get) => ({
// Called from WS handler when another user sends you a friend request // Called from WS handler when another user sends you a friend request
addIncomingRequest: (request: FriendRequest, origin: string) => { addIncomingRequest: (request: FriendRequest, origin: string) => {
set((state) => { set((state) => {
const key = `${request.id}:${origin}`; const canonicalId = request.user?.homeUserId ?? request.user?.id;
if (state.requests.find(r => `${r.id}:${r._instanceOrigin}` === key)) return state; if (canonicalId && state.requests.some(r => (r.user?.homeUserId ?? r.user?.id) === canonicalId)) {
return state;
}
return { requests: [...state.requests, { ...request, _instanceOrigin: origin }] }; return { requests: [...state.requests, { ...request, _instanceOrigin: origin }] };
}); });
}, },
@@ -360,8 +376,8 @@ export const useSocialStore = create<SocialState>((set, get) => ({
// Called from WS handler when someone accepts your friend request // Called from WS handler when someone accepts your friend request
addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => { addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => {
set((state) => { set((state) => {
const key = `${friend.id}:${origin}`; const canonicalId = friend.homeUserId ?? friend.id;
const alreadyExists = state.friends.some(f => `${f.id}:${f._instanceOrigin}` === key); const alreadyExists = state.friends.some(f => (f.homeUserId ?? f.id) === canonicalId);
return { return {
friends: alreadyExists ? state.friends : [...state.friends, { ...friend, _instanceOrigin: origin }], friends: alreadyExists ? state.friends : [...state.friends, { ...friend, _instanceOrigin: origin }],
requests: state.requests.filter(r => !(r.id === requestId && r._instanceOrigin === origin)), requests: state.requests.filter(r => !(r.id === requestId && r._instanceOrigin === origin)),
@@ -370,16 +386,25 @@ export const useSocialStore = create<SocialState>((set, get) => ({
}, },
// Called from WS handler when the other user removes us as a friend // 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) => ({ 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 // 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) => ({ 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<SocialState>((set, get) => ({
updateFriendPresence: (userId: string, status: string) => { updateFriendPresence: (userId: string, status: string) => {
set((state) => ({ set((state) => ({
friends: state.friends.map(f => 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
), ),
})); }));
}, },