fix: federated friend request routed to wrong user with same name
When two instances each have a native user with the same username, the Add Friend search card for the federated one sent its request to the local namesake instead of the intended remote user. Root cause: `isNative = !homeUserId` in socialStore's searchUsers and loadFriends dedup. The server backfills native users' homeUserId to their own id so federation tier-1 lookups succeed, so `homeUserId` is set on natives too. Only `homeInstance` distinguishes native (null) from replicated stubs. With the wrong check, no entry was ever "native" and the home-origin stub of the remote user was kept over the true native record — leaving `_instanceOrigin=''`, which caused the Send button handler to drop the domain suffix and POST to the home API, where "nova" resolved to a completely different local user. Also fixes loadRequests dedup to prefer the target-native record so the search card correctly flips to "Request Pending" after sending.
This commit is contained in:
@@ -401,13 +401,13 @@ type TaggedUser = User & { _instanceOrigin: string };
|
|||||||
2. Fires `Promise.allSettled()` with:
|
2. Fires `Promise.allSettled()` with:
|
||||||
- Home instance: `api.social.friends()`
|
- Home instance: `api.social.friends()`
|
||||||
- Each connected remote instance: `inst.api.social.friends()`
|
- Each connected remote instance: `inst.api.social.friends()`
|
||||||
3. **Deduplication:** Uses a `Set<string>` keyed by `${friend.id}:${origin}` -- prevents duplicates within the same instance
|
3. **Deduplication by canonical identity:** Uses `Map<string, number>` keyed by `friend.homeUserId ?? friend.id`. First occurrence wins, but **native profiles replace replicated stubs**: a native profile (`homeInstance` is null) found for a canonical ID that was previously seen as a stub replaces the entry. Critically, the "native" check is `!homeInstance`, **not** `!homeUserId` -- the server backfills native users' `homeUserId` to their own id so federation tier-1 lookups succeed (see `federation.ts:backfillHomeUserId`), so `homeUserId` is set on natives too.
|
||||||
4. **Asset normalization:** For remote-origin friends, calls `normalizeUserAssets(friend, origin)` to resolve relative avatar/banner URLs to absolute remote URLs
|
4. **Asset normalization:** For remote-origin friends, calls `normalizeUserAssets(friend, origin)` to resolve relative avatar/banner URLs to absolute remote URLs
|
||||||
5. Stores the merged, tagged array as `friends`
|
5. Stores the merged, tagged array as `friends`
|
||||||
|
|
||||||
### Cross-Instance Request Loading (`loadRequests`)
|
### Cross-Instance Request Loading (`loadRequests`)
|
||||||
|
|
||||||
Same `Promise.allSettled()` fan-out pattern as `loadFriends`, with same dedup key: `${request.id}:${origin}`. Normalizes assets for remote request user profiles.
|
Same `Promise.allSettled()` fan-out pattern as `loadFriends`. **Dedup by the other party's canonical identity** (`request.user.homeUserId ?? request.user.id`), preferring the record from the instance where the other party is native (`!request.user.homeInstance`). This is critical: a cross-instance request exists as two rows -- one on each instance -- and both sides return it, but only the record from the target's home instance has the canonical (non-stub) user ids and the correct `_instanceOrigin` tag. Matching those is what lets the Add Friend search card flip to "Request Pending" after sending. Normalizes assets for remote request user profiles.
|
||||||
|
|
||||||
### Sending Friend Requests (Federation Routing)
|
### Sending Friend Requests (Federation Routing)
|
||||||
|
|
||||||
@@ -426,8 +426,9 @@ Same `Promise.allSettled()` fan-out pattern as `loadFriends`, with same dedup ke
|
|||||||
|
|
||||||
1. Fires parallel searches to home + all connected instances
|
1. Fires parallel searches to home + all connected instances
|
||||||
2. **Deduplication by canonical identity:** Uses `Map<string, number>` keyed by `user.homeUserId ?? user.id`
|
2. **Deduplication by canonical identity:** Uses `Map<string, number>` keyed by `user.homeUserId ?? user.id`
|
||||||
- First occurrence wins, but **native profiles replace replicated stubs**: if a native profile (`homeUserId` is null) is found for a canonical ID that was previously seen as a replicated stub, it replaces the entry
|
- First occurrence wins, but **native profiles replace replicated stubs**: if a native profile (`homeInstance` is null) is found for a canonical ID that was previously seen as a replicated stub, it replaces the entry
|
||||||
- This ensures the user sees the "real" profile rather than a replicated copy
|
- The "native" check is `!homeInstance`, **not** `!homeUserId`. Native users have `homeUserId` backfilled to their own id by the server so federation tier-1 lookups succeed (`federation.ts:backfillHomeUserId`). `homeInstance` is the only field that reliably distinguishes native users (null) from replicated stubs (set to domain).
|
||||||
|
- This ensures the user sees the "real" profile (including the correct `_instanceOrigin` tag) rather than a replicated stub whose origin would be the caller's home instance
|
||||||
|
|
||||||
### Instance API Resolution (`getApiForOrigin`)
|
### Instance API Resolution (`getApiForOrigin`)
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
const allFriends: TaggedFriend[] = [];
|
const allFriends: TaggedFriend[] = [];
|
||||||
// Deduplicate by canonical identity — a user who exists on multiple
|
// Deduplicate by canonical identity — a user who exists on multiple
|
||||||
// instances (native + replicated stub) should appear once.
|
// instances (native + replicated stub) should appear once.
|
||||||
// Native profiles (homeUserId is null) replace stubs when found.
|
// Native profiles (homeInstance is null) replace stubs when found.
|
||||||
|
// Note: homeUserId alone is NOT a native indicator — the server backfills
|
||||||
|
// native users' homeUserId to their own id so federation tier-1 lookups
|
||||||
|
// can find them. Only homeInstance distinguishes native from replicated.
|
||||||
const seen = new Map<string, number>(); // canonicalId → index in allFriends
|
const seen = new Map<string, number>(); // canonicalId → index in allFriends
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
@@ -119,12 +122,13 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
const { friends, origin } = result.value;
|
const { friends, origin } = result.value;
|
||||||
for (const friend of friends) {
|
for (const friend of friends) {
|
||||||
const canonicalId = friend.homeUserId ?? friend.id;
|
const canonicalId = friend.homeUserId ?? friend.id;
|
||||||
const isNative = !friend.homeUserId;
|
const isNative = !friend.homeInstance;
|
||||||
const existingIdx = seen.get(canonicalId);
|
const existingIdx = seen.get(canonicalId);
|
||||||
|
|
||||||
if (existingIdx !== undefined) {
|
if (existingIdx !== undefined) {
|
||||||
// Replace replicated stub with native profile when found
|
// Replace replicated stub with native profile when found
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
|
if (origin) normalizeUserAssets(friend, origin);
|
||||||
allFriends[existingIdx] = { ...friend, _instanceOrigin: origin };
|
allFriends[existingIdx] = { ...friend, _instanceOrigin: origin };
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -165,18 +169,31 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
const allRequests: TaggedFriendRequest[] = [];
|
const allRequests: TaggedFriendRequest[] = [];
|
||||||
// Deduplicate by the canonical identity of the other party —
|
// Deduplicate by the canonical identity of the other party —
|
||||||
// there can only be one pending request between any two users.
|
// there can only be one pending request between any two users.
|
||||||
const seen = new Set<string>();
|
// Prefer the record from the instance where the other party is native
|
||||||
|
// (homeInstance is null), because that record's ids and _instanceOrigin
|
||||||
|
// line up with the discover/search cards and the UserProfileModal —
|
||||||
|
// this is what lets buttons like "Request Pending" match correctly.
|
||||||
|
// Note: homeUserId alone is NOT a native indicator — see loadFriends.
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status !== 'fulfilled') continue;
|
if (result.status !== 'fulfilled') continue;
|
||||||
const { requests, origin } = result.value;
|
const { requests, origin } = result.value;
|
||||||
for (const request of requests) {
|
for (const request of requests) {
|
||||||
// Use the other party's canonical identity for dedup
|
|
||||||
const otherCanonicalId = request.user?.homeUserId ?? request.user?.id;
|
const otherCanonicalId = request.user?.homeUserId ?? request.user?.id;
|
||||||
if (otherCanonicalId) {
|
const otherIsNativeHere = !request.user?.homeInstance;
|
||||||
if (seen.has(otherCanonicalId)) continue;
|
const existingIdx = otherCanonicalId ? seen.get(otherCanonicalId) : undefined;
|
||||||
seen.add(otherCanonicalId);
|
|
||||||
|
if (existingIdx !== undefined) {
|
||||||
|
// Replace prior stub-origin record with native one
|
||||||
|
if (otherIsNativeHere) {
|
||||||
|
if (origin && request.user) normalizeUserAssets(request.user, origin);
|
||||||
|
allRequests[existingIdx] = { ...request, _instanceOrigin: origin };
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (otherCanonicalId) seen.set(otherCanonicalId, allRequests.length);
|
||||||
if (origin && request.user) normalizeUserAssets(request.user, origin);
|
if (origin && request.user) normalizeUserAssets(request.user, origin);
|
||||||
allRequests.push({ ...request, _instanceOrigin: origin });
|
allRequests.push({ ...request, _instanceOrigin: origin });
|
||||||
}
|
}
|
||||||
@@ -335,9 +352,13 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
for (const user of result.value) {
|
for (const user of result.value) {
|
||||||
// Deduplicate by canonical identity: replicated profiles share
|
// Deduplicate by canonical identity: replicated profiles share
|
||||||
// the same homeUserId as the native profile's id, so collapse them.
|
// the same homeUserId as the native profile's id, so collapse them.
|
||||||
// Prefer native profiles (homeUserId is null) over replicated ones.
|
// Prefer native profiles (homeInstance is null) over replicated ones.
|
||||||
|
// Note: homeUserId alone is NOT a native indicator — the server
|
||||||
|
// backfills native users' homeUserId to their own id so federation
|
||||||
|
// tier-1 lookups can find them. Only homeInstance distinguishes
|
||||||
|
// native from replicated.
|
||||||
const canonicalId = user.homeUserId ?? user.id;
|
const canonicalId = user.homeUserId ?? user.id;
|
||||||
const isNative = !user.homeUserId;
|
const isNative = !user.homeInstance;
|
||||||
const existingIdx = seen.get(canonicalId);
|
const existingIdx = seen.get(canonicalId);
|
||||||
|
|
||||||
if (existingIdx !== undefined) {
|
if (existingIdx !== undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user