diff --git a/docs/systems/social.md b/docs/systems/social.md index bdcde69e..7f44a131 100644 --- a/docs/systems/social.md +++ b/docs/systems/social.md @@ -401,13 +401,13 @@ type TaggedUser = User & { _instanceOrigin: string }; 2. Fires `Promise.allSettled()` with: - Home instance: `api.social.friends()` - Each connected remote instance: `inst.api.social.friends()` -3. **Deduplication:** Uses a `Set` keyed by `${friend.id}:${origin}` -- prevents duplicates within the same instance +3. **Deduplication by canonical identity:** Uses `Map` 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 5. Stores the merged, tagged array as `friends` ### 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) @@ -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 2. **Deduplication by canonical identity:** Uses `Map` 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 - - This ensures the user sees the "real" profile rather than a replicated copy + - 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 + - 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`) diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 62a9c775..419fc02a 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -111,7 +111,10 @@ export const useSocialStore = create((set, get) => ({ const allFriends: TaggedFriend[] = []; // Deduplicate by canonical identity — a user who exists on multiple // 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(); // canonicalId → index in allFriends for (const result of results) { @@ -119,12 +122,13 @@ export const useSocialStore = create((set, get) => ({ const { friends, origin } = result.value; for (const friend of friends) { const canonicalId = friend.homeUserId ?? friend.id; - const isNative = !friend.homeUserId; + const isNative = !friend.homeInstance; const existingIdx = seen.get(canonicalId); if (existingIdx !== undefined) { // Replace replicated stub with native profile when found if (isNative) { + if (origin) normalizeUserAssets(friend, origin); allFriends[existingIdx] = { ...friend, _instanceOrigin: origin }; } continue; @@ -165,18 +169,31 @@ export const useSocialStore = create((set, get) => ({ const allRequests: TaggedFriendRequest[] = []; // Deduplicate by the canonical identity of the other party — // there can only be one pending request between any two users. - const seen = new Set(); + // 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(); for (const result of results) { if (result.status !== 'fulfilled') continue; const { requests, origin } = result.value; for (const request of requests) { - // Use the other party's canonical identity for dedup const otherCanonicalId = request.user?.homeUserId ?? request.user?.id; - if (otherCanonicalId) { - if (seen.has(otherCanonicalId)) continue; - seen.add(otherCanonicalId); + const otherIsNativeHere = !request.user?.homeInstance; + const existingIdx = otherCanonicalId ? seen.get(otherCanonicalId) : undefined; + + 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); allRequests.push({ ...request, _instanceOrigin: origin }); } @@ -335,9 +352,13 @@ export const useSocialStore = create((set, get) => ({ for (const user of result.value) { // Deduplicate by canonical identity: replicated profiles share // 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 isNative = !user.homeUserId; + const isNative = !user.homeInstance; const existingIdx = seen.get(canonicalId); if (existingIdx !== undefined) {