From 49e90470054ac71fbe42a8128f77f485f09ff4d7 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 01:59:05 +0200 Subject: [PATCH] feat(client-federation): user-view cache for cross-instance DM render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a render bug where a federated user (e.g. axel@nova) appeared with the federation globe icon and a broken avatar when viewed on his own home instance. Root cause: `populateFromReady` is first-wins by federatedId and discards the entire skipped DM payload — including its `members` array — so when a sibling instance's ready arrived first, the home instance's view of every shared user was dropped on the floor. Adds a render-only `userViews` cache that mirrors the `dmAlternatives` philosophy: information from skipped ready payloads is preserved for rendering. Every wire surface that delivers a User upserts into the cache regardless of dedup outcome; render sites read through a Zustand selector hook to surface the home view when one is loaded. The DM channel ingestion race is left untouched — the existing no-flapping invariant on origin reconnect is intentional and load-bearing for failover. Layered changes: - `identity.ts`: `normalizeOriginToHost`, `canonicalUserKey`, `isDeliveryFromHome`, `isFederationGlobeApplicable` — single helpers for origin/host normalization and the home/stub tier decision. - `spaceStore.ts`: `userViews` Map, `UserViewEntry` type, `upsertUserView` action with the home-wins preference rule, prune by `deliveredBy` in `removeInstanceSpaces` (mirrors `dmAlternatives` cleanup), `reset` clears. - `userViewLookup.ts`: `useCanonicalUserView` (Zustand selector hook for React) + `getCanonicalUserView` (sync getter for non-React paths). Render reactivity is structural via the selector, not coincidence on legacy update paths. - `populateFromReady` upsert pass runs BEFORE the federatedId dedup so members of skipped DMs still reach the cache. - WS handlers (dm_message_*, message_*, user_updated, member_joined, friend_request_*, dm_channel_created, dm_member_added) and REST hydrators (socialStore, discoverStore, mutuals) feed the cache with their delivering origin. - Render-site routing through `useCanonicalUserView` at every audited user-rendering site (sidebar, header, search, message bubble, reply chips, profile popout/modal, group settings, voice tiles, mention chips, member lists, friends, invites). Self-rendering sites compose alongside via existing `isSelf`/`resolveDisplayIdentity`. - Globe predicate hoisted to `isFederationGlobeApplicable` and applied at three sites, gating on `domain !== window.location.host` so we never show the globe for users whose home IS our own. Tests: 31 new unit tests across `identity`, `userViews` store, and `userViewLookup`. Full suite 276/276. Docs: `client-federation.md` §3 gains a "User View Cache" section parallel to "DM Origin Failover"; `dm-system.md` notes the new store action and WS handler upserts. Bug 3 (federation profile-sync gap — orbit's stale profile data on nova-Axel after a clear/color-change on nova never propagated) remains open. The user-view cache routes around it for the common case (home instance is connected), but the underlying S2S relay gap is its own diagnosis and follows in a separate branch. --- docs/systems/client-federation.md | 51 +++ docs/systems/dm-system.md | 6 +- .../web/src/components/chat/FriendsPage.tsx | 134 +++++--- .../web/src/components/chat/MentionBadge.tsx | 14 +- .../src/components/chat/MentionPopover.tsx | 93 ++++-- packages/web/src/components/chat/Message.tsx | 23 +- .../web/src/components/chat/SearchPopover.tsx | 94 ++++-- .../src/components/layout/ActivityPanel.tsx | 105 +++--- .../web/src/components/layout/DmListItem.tsx | 56 +++- .../web/src/components/layout/DmSearchBar.tsx | 184 ++++++---- .../web/src/components/layout/MainContent.tsx | 22 +- .../src/components/layout/MemberSidebar.tsx | 109 +++--- .../src/components/layout/MobileDmsScreen.tsx | 270 ++++++++------- .../components/layout/MobileMembersScreen.tsx | 108 +++--- .../components/modals/AddDmMemberModal.tsx | 123 ++++--- .../web/src/components/modals/InviteModal.tsx | 223 +++++++------ .../web/src/components/modals/NewDmModal.tsx | 53 +-- .../modals/TransferOwnershipModal.tsx | 75 +++-- .../components/modals/UserProfileModal.tsx | 1 + .../spaceSettingsPanels/MembersPanel.tsx | 313 ++++++++++-------- .../src/components/ui/UserProfilePopout.tsx | 10 +- .../components/voice/IncomingCallModal.tsx | 15 +- .../web/src/hooks/useVoiceParticipantMeta.ts | 32 +- packages/web/src/hooks/useWebSocket.ts | 22 +- packages/web/src/stores/discoverStore.ts | 8 +- packages/web/src/stores/socialStore.ts | 18 + packages/web/src/stores/spaceStore.ts | 105 +++++- .../src/stores/spaceStore.userViews.test.ts | 250 ++++++++++++++ packages/web/src/utils/identity.test.ts | 156 +++++++++ packages/web/src/utils/identity.ts | 108 ++++++ packages/web/src/utils/mutuals.ts | 5 + packages/web/src/utils/userViewLookup.test.ts | 116 +++++++ packages/web/src/utils/userViewLookup.ts | 39 +++ 33 files changed, 2150 insertions(+), 791 deletions(-) create mode 100644 packages/web/src/stores/spaceStore.userViews.test.ts create mode 100644 packages/web/src/utils/identity.test.ts create mode 100644 packages/web/src/utils/userViewLookup.test.ts create mode 100644 packages/web/src/utils/userViewLookup.ts diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index b8c25233..f3fd626c 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -189,6 +189,57 @@ When a remote instance's WebSocket drops mid-session, every DM pinned to that or Source: `utils/dmOriginFailover.ts` + extensions in `stores/spaceStore.ts`, `stores/chatStore.ts`, `stores/instanceStore.ts`, `hooks/useWebSocket.ts`. Design spec: `docs/superpowers/specs/2026-04-23-dm-origin-failover-design.md`. +### User View Cache + +The DM dedup pass at `populateFromReady` is first-wins by `federatedId` and skips the duplicate channel **as a whole**, including its `members` array. When a user is connected to multiple instances and a sibling instance's `ready` arrives first, the home instance's view of the same federated DM is dropped. Without further machinery, render sites would only ever see the sibling-stub view of every member — wrong username (`name@homeHost`), stale or 404-ing avatar URL, wrong `avatarColor`, and a globe icon for users whose home IS our currently-logged-in instance. + +`userViews` is a parallel cache that mirrors the philosophy of `dmAlternatives`: information from skipped ready payloads is still load-bearing — for rendering, not for routing. Render sites read through it to surface the home view of every user the client has ever heard about, regardless of which carrying channel survived dedup. + +**State:** + +- `userViews: Map` on `spaceStore`. Each entry is `{ user: User, deliveredBy: string, isHome: boolean, updatedAt: number }`. +- `canonicalUserKey(user)` (in `utils/identity.ts`) returns `:` for federated users and `:` for purely-local users — same key across instances for the same person. +- `isDeliveryFromHome(user, deliveringOrigin)` (in `utils/identity.ts`) decides whether a delivery is "home view" or "stub view": the user is delivered from their home iff their `homeInstance` matches the delivering origin's host (with `''` resolving to `window.location.host`). + +**Preference rule on upsert (`upsertUserView(user, deliveringOrigin)`):** + +- If no entry exists: insert. +- If existing is home view and incoming is stub: ignore. +- If existing is stub and incoming is home view: overwrite (upgrade). +- Same tier (both home or both stub): freshness wins; incoming overwrites. + +`deliveringOrigin` is a REQUIRED parameter — never default it. The user's declared `homeInstance` is NOT a substitute, because a stub view delivered by orbit has `homeInstance=nova`; pruning by declared home would evict the wrong entries. + +**Wire surfaces that upsert** (every place a `User` lands on the client from a connection): + +- `populateFromReady` walks `incomingDms[].members` BEFORE the `federatedId` dedup pass — load-bearing — and walks every space's `members[].user`. `loadSpaceDetail` upserts each member after asset normalization. +- WS handlers in `useWebSocket.ts`: `ready` (space members), `dm_message_created` / `dm_message_updated` / `message_created` / `message_updated` (`message.user` and `message.replyTo?.user`), `user_updated`, `member_joined`, `friend_request_received` / `friend_request_sent` / `friend_request_accepted`, `dm_channel_created`, `dm_member_added`. +- REST hydrators: `socialStore.loadFriends` / `loadRequests` / `searchUsers`, `discoverStore.fetchUsers`, `utils/mutuals.loadFederatedMutuals`. Each upserts with the load's origin. +- Modals that fetch a profile via REST (`UserProfileModal`, `TransferOwnershipModal`) call `useSpaceStore.getState().upsertUserView(fetchedUser, fetchOrigin)` after the fetch returns. + +**Render-side lookup:** + +- `useCanonicalUserView(user)` in `utils/userViewLookup.ts` is a Zustand selector hook that subscribes to the cache entry for `canonicalUserKey(user)`. Render sites call this before reading `username` / `displayName` / `avatar` / `avatarColor` / `homeInstance` / `homeUserId`. The hook returns the input on cache miss; the component falls back to current best information until the cache fills. +- `getCanonicalUserView(user)` is the synchronous getter for non-React paths (event handlers, helpers like `useVoiceParticipantMeta`). +- `isFederationGlobeApplicable(user)` in `utils/identity.ts` is the predicate used at three globe-icon sites (`DmListItem`, `MainContent`, `MobileDmsScreen`). It gates the globe on `parseFederatedUsername(username).domain && domain !== window.location.host` — no globe for users whose home is us, even when only a stub is loaded. + +**Render reactivity is structural, not coincidental.** Subscribers receive cache updates via the Zustand selector, regardless of whether legacy update paths (`updateUserEverywhere`, `updateFriendProfile`) also fired. That coupling was deliberately avoided so that future contributors who add a new wire surface and only call `upsertUserView` do not silently break render propagation. + +**Composition with `isSelf` / `resolveDisplayIdentity`.** Self-rendering continues to flow through the existing identity helpers — `isSelf` for filtering, `resolveDisplayIdentity` for substituting the home identity into a replicated alias of self. The cache lookup composes alongside, not inside: render sites filter via `isSelf`, then pass non-self users through `useCanonicalUserView`. Self-as-member (e.g. in a group DM) goes through the cache like any other member; the cache holds the home view of self anyway. + +**Lifecycle:** + +- Pruned in `removeInstanceSpaces(origin)` — drops every entry whose `deliveredBy === origin`. Mirrors the `dmAlternatives` prune in the same function. +- `reset()` clears the cache. +- **NOT pruned on transient WS disconnect.** Last-known view persists across blips, matching `dmAlternatives`' no-flapping invariant. If the surviving cache no longer holds a home view for some user (because the home origin was fully removed), render falls back to whatever the carrying payload supplies — degrades to the stub view, no crash. + +**Out of scope by design:** + +- The cache is render-only. It does NOT feed identity-resolution or write paths. API write payloads (e.g. `api.dm.create`, `api.friend.add`) continue to source identity from the original prop or click-site state, where the user explicitly nominated `homeUserId`/`homeInstance`. +- The cache stores `User`-shaped fields. Extended profile data (`bio`, `banner`, `pronouns`) fetched via REST in profile modals lives in those modals' local state; `upsertUserView` is called from modals only to seed the home view of the cache, not to mirror extended profile data. + +Source: `stores/spaceStore.ts` (state, `upsertUserView`, prune in `removeInstanceSpaces`), `utils/identity.ts` (`normalizeOriginToHost`, `canonicalUserKey`, `isDeliveryFromHome`, `isFederationGlobeApplicable`), `utils/userViewLookup.ts` (`getCanonicalUserView`, `useCanonicalUserView`). + ### API Client Resolution ```typescript diff --git a/docs/systems/dm-system.md b/docs/systems/dm-system.md index f52e1c19..239ad081 100644 --- a/docs/systems/dm-system.md +++ b/docs/systems/dm-system.md @@ -609,16 +609,18 @@ const isLocalMember = (u: { homeInstance?: string | null }) => | `closeDm(id)` | Calls `api.dm.close(id)` via origin-aware API client, removes from state | | `leaveDm(id)` | Calls `api.dm.leave(id)` via origin-aware API client, removes from state | | `findExistingDmForUser(targetUser)` | Scans `dmChannels` for a 2-member DM where the other member's `homeUserId` matches the target's `homeUserId` | +| `upsertUserView(user, deliveringOrigin)` | Inserts/updates the user-view cache under the home-wins preference rule. Called for every DM member surface (kept AND skipped channels) so render sites surface the home view even when first-wins channel dedup discarded the home payload. See `client-federation.md` §3 "User View Cache" | ### WebSocket Event Handlers (`useWebSocket.ts`) | WS Event | Handler | |----------|---------| -| `dm_channel_created` | Normalize remote user assets, call `addDmChannel(channel, origin)` | +| `dm_channel_created` | Normalize remote user assets, upsert each member into `userViews`, call `addDmChannel(channel, origin)` | | `dm_channel_closed` | Call `removeDmChannel(dmChannelId)` | -| `dm_member_added` | Normalize remote user assets, call `addDmMember(dmChannelId, user)` | +| `dm_member_added` | Normalize remote user assets, upsert into `userViews`, call `addDmMember(dmChannelId, user)` | | `dm_member_removed` | Call `removeDmMember(dmChannelId, userId)` | | `dm_owner_updated` | Call `updateDmOwner(dmChannelId, newOwnerId)` | +| `dm_message_created` / `dm_message_updated` | Normalize message assets, upsert `message.user` and `message.replyTo?.user` into `userViews` | ### New DM Modal (`NewDmModal.tsx`) diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 14cfc717..6d237226 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import type { User } from '@backspace/shared'; import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, type TaggedUser } from '../../stores/socialStore'; import { useAuthStore } from '../../stores/authStore'; import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore'; @@ -17,12 +18,79 @@ import { Mascot } from '../ui/Mascot'; import { useActivityStore } from '../../stores/activityStore'; import { ActivityCard, hasRichActivity, getActivityAccentClass } from '../ui/ActivityCard'; import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; -import { parseFederatedUsername } from '../../utils/identity'; +import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; import { Username } from '../ui/Username'; import { ConfirmDialog } from '../ui/ConfirmDialog'; const statusLabel: Record = { online: 'Online', idle: 'Idle', dnd: 'Do Not Disturb', offline: 'Offline' }; +function ActivityFriendItem({ + friend, + isOffline, + activities, + isRichActivity, + accentClass, + mobile, + onMobileClick, + onDmClick, +}: { + friend: TaggedFriend; + isOffline: boolean; + activities: import('@backspace/shared').Activity[]; + isRichActivity: boolean; + accentClass: string; + mobile?: boolean; + onMobileClick: (userId: string) => void; + onDmClick: (id: string, homeUserId?: string, homeInstance?: string | null) => void; +}) { + const canonical = useCanonicalUserView(friend as unknown as User); + const { baseName } = parseFederatedUsername(canonical.username); + const friendDisplayName = canonical.displayName ?? baseName; + + const rowClass = isRichActivity + ? `flex items-center gap-3 px-4 py-2.5 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass}` + : 'flex items-center gap-3 px-4 py-2.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer transition-colors active:bg-interactive-hover'; + + return ( +
{ + if (mobile) { + onMobileClick(friend.id); + } else { + onDmClick(friend.id, friend.homeUserId ?? undefined, friend.homeInstance); + } + }} + className={rowClass} + > + +
+ + {!isOffline && isFederationGlobeApplicable(canonical) && ( +
@{parseFederatedUsername(canonical.username).domain}
+ )} + {!isOffline && ( + + )} +
+
+ ); +} + type Tab = 'online' | 'all' | 'pending' | 'add' | 'activity'; interface FriendsPageProps { @@ -199,54 +267,22 @@ export function FriendsPage({ mobile }: FriendsPageProps) { } const renderActivityFriend = (friend: TaggedFriend, isOffline = false) => { - const { baseName, domain } = parseFederatedUsername(friend.username); - const friendDisplayName = friend.displayName ?? baseName; const activities = userActivities.get(friend.homeUserId ?? friend.id) ?? []; const isRichActivity = !isOffline && hasRichActivity(activities); const primary = getPrimaryActivity(activities); const accentClass = primary ? getActivityAccentClass(primary.type) : ''; - - const rowClass = isRichActivity - ? `flex items-center gap-3 px-4 py-2.5 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass}` - : 'flex items-center gap-3 px-4 py-2.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer transition-colors active:bg-interactive-hover'; - return ( -
{ - if (mobile) { - pushMobileScreen('user-profile', { userId: friend.id }); - } else { - handleOpenDm(friend.id, friend.homeUserId ?? undefined, friend.homeInstance); - } - }} - className={rowClass} - > - -
- - {domain && !isOffline && ( -
@{domain}
- )} - {!isOffline && ( - - )} -
-
+ friend={friend} + isOffline={isOffline} + activities={activities} + isRichActivity={isRichActivity} + accentClass={accentClass} + mobile={mobile} + onMobileClick={(userId) => pushMobileScreen('user-profile', { userId })} + onDmClick={handleOpenDm} + /> ); }; @@ -863,13 +899,14 @@ function TabButton({ children, active, onClick }: { children: React.ReactNode, a } function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove: () => void, onDm: () => void }) { + const canonical = useCanonicalUserView(friend as unknown as User); const instanceLabel = friend._instanceOrigin ? (() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })() : ''; - const { baseName: friendBaseName } = parseFederatedUsername(friend.username); - const friendDisplayName = friend.displayName ?? friendBaseName; + const { baseName: friendBaseName } = parseFederatedUsername(canonical.username); + const friendDisplayName = canonical.displayName ?? friendBaseName; return (
- +
{friendDisplayName} @@ -914,7 +951,10 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: { onDecline?: () => void; onCancel?: () => void; }) { - const user = request.user; + const rawUser = request.user; + const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; + const canonicalUser = useCanonicalUserView((rawUser as unknown as User | null) ?? _FALLBACK_USER); + const user = rawUser ? canonicalUser : null; if (!user) return null; const instanceLabel = request._instanceOrigin ? (() => { try { return new URL(request._instanceOrigin).host; } catch { return request._instanceOrigin; } })() : ''; const { baseName: reqBaseName } = parseFederatedUsername(user.username); diff --git a/packages/web/src/components/chat/MentionBadge.tsx b/packages/web/src/components/chat/MentionBadge.tsx index df474daf..91895cc1 100644 --- a/packages/web/src/components/chat/MentionBadge.tsx +++ b/packages/web/src/components/chat/MentionBadge.tsx @@ -1,6 +1,8 @@ import React from 'react'; +import type { User } from '@backspace/shared'; import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; interface MentionBadgeProps { userId: string; @@ -16,11 +18,15 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention const space = spaces.find((s) => s.id === currentSpaceId); const ownerId = space?.ownerId; + const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; + const canonicalMemberUser = useCanonicalUserView(member?.user ?? _FALLBACK_USER); + const memberUser = member ? canonicalMemberUser : null; + let displayName: string; let color: string; - if (member) { - displayName = member.user.displayName ?? member.user.username; + if (member && memberUser) { + displayName = memberUser.displayName ?? memberUser.username; if (member.roles && member.roles.length > 0) { const sorted = [...member.roles].sort((a, b) => b.position - a.position); color = sorted[0]!.color; @@ -35,10 +41,10 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention } const handleClick = (e: React.MouseEvent) => { - if (!member) return; + if (!member || !memberUser) return; e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); - openUserProfile(member.user, { + openUserProfile(memberUser, { top: Math.min(rect.top, window.innerHeight - 450), left: rect.right + 8, }); diff --git a/packages/web/src/components/chat/MentionPopover.tsx b/packages/web/src/components/chat/MentionPopover.tsx index 592ebfcd..1cef4539 100644 --- a/packages/web/src/components/chat/MentionPopover.tsx +++ b/packages/web/src/components/chat/MentionPopover.tsx @@ -4,9 +4,56 @@ import type { MemberWithUser } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { useSpaceStore } from '../../stores/spaceStore'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; const MAX_RESULTS = 8; +function MentionMemberRow({ + member, + isSelected, + selectedRef, + onSelect, + roleColor, +}: { + member: MemberWithUser; + isSelected: boolean; + selectedRef: React.RefObject; + onSelect: (member: MemberWithUser) => void; + roleColor: string | undefined; +}) { + const canonical = useCanonicalUserView(member.user); + const displayName = canonical.displayName ?? canonical.username; + return ( +
onSelect(member)} + className={`flex items-center gap-2.5 px-2 py-1.5 mx-1 rounded cursor-pointer transition-colors ${ + isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover' + }`} + > + + + {displayName} + + {canonical.displayName && ( + + @{canonical.username} + + )} +
+ ); +} + interface MentionPopoverProps { query: string; selectedIndex: number; @@ -62,42 +109,16 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
Members
- {filtered.map((member, i) => { - const displayName = member.user.displayName ?? member.user.username; - const roleColor = getMemberColor(member); - const isSelected = i === selectedIndex; - - return ( -
onSelect(member)} - className={`flex items-center gap-2.5 px-2 py-1.5 mx-1 rounded cursor-pointer transition-colors ${ - isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover' - }`} - > - - - {displayName} - - {member.user.displayName && ( - - @{member.user.username} - - )} -
- ); - })} + {filtered.map((member, i) => ( + + ))}
, document.body, diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index c32dc605..a346ddfa 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import type { MessageWithUser, Embed } from '@backspace/shared'; +import type { MessageWithUser, Embed, User } from '@backspace/shared'; import { MarkdownRenderer } from './MarkdownRenderer'; import { MentionBadge } from './MentionBadge'; import { Avatar } from '../ui/Avatar'; @@ -17,6 +17,7 @@ import { Username } from '../ui/Username'; import { EmojiPicker } from './EmojiPicker'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { isSelf, resolveDisplayIdentity } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; import { isPendingMessage, usePendingMessageStore, @@ -170,6 +171,12 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId const setReplyTo = useChatStore((s) => s.setReplyTo); const markUnread = useChatStore((s) => s.markUnread); + const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; + const _rawMsgUser = message.user ?? null; + const _canonicalMsgUser = useCanonicalUserView(_rawMsgUser ?? _FALLBACK_USER); + const _rawReplyUser = (!isPendingMessage(message) && message.replyTo?.user) ? message.replyTo.user : null; + const _canonicalReplyUser = useCanonicalUserView(_rawReplyUser ?? _FALLBACK_USER); + const isOwnReaction = (r: { userId: string; user?: { id: string; username: string; homeInstance?: string | null } | null }) => r.user ? isSelf(r.user, currentUser) : r.userId === currentUser?.id; @@ -339,8 +346,13 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId } }; - // Resolve display identity: replicated-self messages show home user's avatar/name - const displayIdentity = resolveDisplayIdentity(message.user, currentUser); + // Resolve display identity: replicated-self messages show home user's avatar/name. + // For non-self messages, further route through canonical user view cache so stale + // federated stubs are replaced with the best-known profile data. + const _resolvedIdentity = resolveDisplayIdentity(message.user, currentUser); + const displayIdentity = (!isSelf(_resolvedIdentity, currentUser) && _rawMsgUser) + ? _canonicalMsgUser + : _resolvedIdentity; const displayName = displayIdentity.displayName ?? displayIdentity.username; const spaces = useSpaceStore((s) => s.spaces); @@ -409,7 +421,10 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId {/* Content */}
{message.replyTo && (() => { - const replyIdentity = resolveDisplayIdentity(message.replyTo.user, currentUser); + const _rawReply = resolveDisplayIdentity(message.replyTo.user, currentUser); + const replyIdentity = (!isSelf(_rawReply, currentUser) && _rawReplyUser) + ? _canonicalReplyUser + : _rawReply; const replyDisplayName = replyIdentity.displayName ?? replyIdentity.username; return (
diff --git a/packages/web/src/components/chat/SearchPopover.tsx b/packages/web/src/components/chat/SearchPopover.tsx index 453ace0e..d6870b0b 100644 --- a/packages/web/src/components/chat/SearchPopover.tsx +++ b/packages/web/src/components/chat/SearchPopover.tsx @@ -3,7 +3,8 @@ import { createPortal } from 'react-dom'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore'; import { Avatar } from '../ui/Avatar'; -import type { MessageWithUser, DmMessageWithUser } from '@backspace/shared'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; +import type { MessageWithUser, DmMessageWithUser, User } from '@backspace/shared'; type AnyMessage = MessageWithUser | DmMessageWithUser; @@ -40,6 +41,58 @@ function highlightMatch(text: string, query: string): React.ReactNode { ); } +const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; + +function SearchResultRow({ + msg, + query, + onJumpToMessage, +}: { + msg: AnyMessage; + query: string; + onJumpToMessage: (id: string) => void; +}) { + const canonical = useCanonicalUserView(msg.user ?? _FALLBACK_USER); + const displayName = canonical.displayName ?? canonical.username ?? '?'; + const avatar = msg.user ? canonical.avatar : undefined; + return ( + + ); +} + export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJumpToMessage }: SearchPopoverProps) { const popoverRef = useRef(null); const inputRef = useRef(null); @@ -277,41 +330,12 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum {totalCount} result{totalCount !== 1 ? 's' : ''}
{results.map((msg) => ( - + msg={msg} + query={query} + onJumpToMessage={onJumpToMessage} + /> ))} {results.length < totalCount && (
diff --git a/packages/web/src/components/layout/ActivityPanel.tsx b/packages/web/src/components/layout/ActivityPanel.tsx index ccc51165..f2c67d48 100644 --- a/packages/web/src/components/layout/ActivityPanel.tsx +++ b/packages/web/src/components/layout/ActivityPanel.tsx @@ -5,9 +5,66 @@ import { useActivityStore } from '../../stores/activityStore'; import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; import { ActivityCard, hasRichActivity, getActivityAccentClass } from '../ui/ActivityCard'; -import type { Friend } from '@backspace/shared'; +import type { Friend, Activity, User } from '@backspace/shared'; import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; -import { parseFederatedUsername } from '../../utils/identity'; +import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; + +function ActivityFriendRow({ + friend, + isOffline, + activities, + isRichActivity, + accentClass, + onClickFriend, +}: { + friend: Friend; + isOffline: boolean; + activities: Activity[]; + isRichActivity: boolean; + accentClass: string; + onClickFriend: (e: React.MouseEvent, friend: Friend) => void; +}) { + const canonical = useCanonicalUserView(friend as unknown as User); + const { baseName } = parseFederatedUsername(canonical.username); + const friendDisplayName = canonical.displayName ?? baseName; + + const rowClass = isRichActivity + ? `flex items-center gap-2.5 px-2.5 py-2 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass}` + : 'flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer group transition-colors'; + + return ( +
onClickFriend(e, friend)} + className={rowClass} + > + +
+ + {!isOffline && isFederationGlobeApplicable(canonical) && ( +
@{parseFederatedUsername(canonical.username).domain}
+ )} + {!isOffline && ( + + )} +
+
+ ); +} export function ActivityPanel() { const friends = useSocialStore((s) => s.friends); @@ -74,48 +131,20 @@ export function ActivityPanel() { }; const renderFriend = (friend: Friend, isOffline = false) => { - const { baseName, domain } = parseFederatedUsername(friend.username); - const friendDisplayName = friend.displayName ?? baseName; const activities = userActivities.get(friend.homeUserId ?? friend.id) ?? []; const isRichActivity = !isOffline && hasRichActivity(activities); const primary = getPrimaryActivity(activities); const accentClass = primary ? getActivityAccentClass(primary.type) : ''; - - const rowClass = isRichActivity - ? `flex items-center gap-2.5 px-2.5 py-2 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass}` - : 'flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer group transition-colors'; - return ( -
handleFriendClick(e, friend)} - className={rowClass} - > - -
- - {domain && !isOffline && ( -
@{domain}
- )} - {!isOffline && ( - - )} -
-
+ friend={friend} + isOffline={isOffline} + activities={activities} + isRichActivity={isRichActivity} + accentClass={accentClass} + onClickFriend={handleFriendClick} + /> ); }; diff --git a/packages/web/src/components/layout/DmListItem.tsx b/packages/web/src/components/layout/DmListItem.tsx index aa1d6764..99f1cdca 100644 --- a/packages/web/src/components/layout/DmListItem.tsx +++ b/packages/web/src/components/layout/DmListItem.tsx @@ -1,10 +1,34 @@ import type { DmChannel, User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { Tooltip } from '../ui/Tooltip'; -import { parseFederatedUsername, isSelf } from '../../utils/identity'; +import { parseFederatedUsername, isSelf, isFederationGlobeApplicable } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; import { formatDmTimestamp, formatDmSidebarPreview } from '../../utils/dmFormatters'; import { getRejectedPeerOrigins, getAwaitingApprovalPeerOrigins } from '../../hooks/useWebSocket'; +/** + * Renders a single avatar slot in the group DM avatar pair. + * Extracted as a component so useCanonicalUserView can be called per-slot + * (hooks must not be called inside a variable-length .map()). + */ +function DmGroupAvatarSlot({ member, index }: { member: User; index: number }) { + const canonical = useCanonicalUserView(member); + const displayName = canonical.displayName ?? parseFederatedUsername(canonical.username).baseName; + return ( +
+ +
+ ); +} + function isMemberUnreachable(homeInstance: string | null | undefined): boolean { if (!homeInstance) return false; const normalized = homeInstance.startsWith('http') ? homeInstance : `https://${homeInstance}`; @@ -33,8 +57,15 @@ export function DmListItem({ dm, isActive, isUnread, user, onSelect, onClose, on const isGroup = !!dm.ownerId; if (otherMembers.length === 0 && !isGroup) return null; - const firstOther = isGroup ? null : otherMembers[0]; - const { baseName, domain } = parseFederatedUsername(firstOther?.username ?? ''); + // Route the 1-on-1 partner through the canonical view cache. Group member + // avatars are handled per-slot in DmGroupAvatarSlot (hook-in-loop safety). + // eslint-disable-next-line react-hooks/rules-of-hooks + const rawFirstOther = isGroup ? null : (otherMembers[0] ?? null); + // eslint-disable-next-line react-hooks/rules-of-hooks + const firstOtherCanonical = useCanonicalUserView(rawFirstOther ?? user); + const firstOther = rawFirstOther ? firstOtherCanonical : null; + + const { baseName } = parseFederatedUsername(firstOther?.username ?? ''); const displayName = isGroup ? (otherMembers.length > 0 ? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ') @@ -126,22 +157,11 @@ export function DmListItem({ dm, isActive, isUnread, user, onSelect, onClose, on {isGroup ? (
{otherMembers.slice(0, 2).map((m, i) => ( -
- -
+ ))}
) : ( - + )} {/* Content */} @@ -150,8 +170,8 @@ export function DmListItem({ dm, isActive, isUnread, user, onSelect, onClose, on {displayName} - {!isGroup && domain && ( - + {!isGroup && firstOther && isFederationGlobeApplicable(firstOther) && ( + diff --git a/packages/web/src/components/layout/DmSearchBar.tsx b/packages/web/src/components/layout/DmSearchBar.tsx index 0929be37..fe39365d 100644 --- a/packages/web/src/components/layout/DmSearchBar.tsx +++ b/packages/web/src/components/layout/DmSearchBar.tsx @@ -8,8 +8,123 @@ import { useAuthStore } from '../../stores/authStore'; import { useUIStore } from '../../stores/uiStore'; import { api } from '../../api/client'; import { isSelf, parseFederatedUsername } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; +/** + * Single slot in the group-avatar stack for the DM search bar dropdown. + * Extracted as a component so useCanonicalUserView is called per-slot (hooks + * must not be called inside a variable-length .map()). + */ +function DmSearchGroupAvatarSlot({ member, index }: { member: User; index: number }) { + const canonical = useCanonicalUserView(member); + const displayName = canonical.displayName ?? parseFederatedUsername(canonical.username).baseName; + return ( +
+ +
+ ); +} + +/** + * Avatar + display-name row for a user result in the DM search bar. + * Extracted as a component so useCanonicalUserView can be called at the top + * of a stable component rather than inside a variable-length .map(). + */ +function DmSearchUserRow({ user, isSelected, selectedRef, onClick }: { + user: User; + isSelected: boolean; + selectedRef?: React.Ref; + onClick: () => void; +}) { + const canonical = useCanonicalUserView(user); + const displayName = canonical.displayName ?? canonical.username; + return ( +
+ +
+ + {displayName} + + {canonical.displayName && ( + + @{canonical.username} + + )} +
+
+ ); +} + +/** + * Full row for a DM conversation item in the DM search bar dropdown. + * For 1-on-1 DMs, routes the partner through useCanonicalUserView. + * For group DMs, delegates per-slot to DmSearchGroupAvatarSlot. + */ +function DmSearchDmRow({ item, isSelected, selectedRef, onClick }: { + item: DmItem; + isSelected: boolean; + selectedRef?: React.Ref; + onClick: () => void; +}) { + // For 1-on-1 DMs: canonicalize the single partner. For groups: pass through + // unchanged (DmSearchGroupAvatarSlot handles per-slot canonicalization). + const rawPartner = !item.isGroup ? (item.otherMembers[0] ?? null) : null; + // Call the hook unconditionally — pass a stable fallback (empty User shape) + // for group DMs so hooks are always called the same number of times. + const FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; + const canonicalPartner = useCanonicalUserView(rawPartner ?? FALLBACK_USER); + const partner = rawPartner ? canonicalPartner : null; + + return ( +
+ {item.isGroup ? ( +
+ {item.otherMembers.slice(0, 2).map((m, idx) => ( + + ))} +
+ ) : ( + + )} + {item.displayName} +
+ ); +} + const MAX_RECENT = 8; const SEARCH_DEBOUNCE = 300; @@ -265,43 +380,13 @@ export function DmSearchBar() { const globalIndex = i; const isSelected = globalIndex === selectedIndex; return ( -
selectItem(item)} - className={`flex items-center gap-2.5 px-2 py-1.5 mx-1 rounded cursor-pointer transition-colors ${ - isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover' - }`} - > - {item.isGroup ? ( -
- {item.otherMembers.slice(0, 2).map((m, idx) => ( -
- -
- ))} -
- ) : ( - - )} - {item.displayName} -
+ /> ); })} @@ -320,32 +405,13 @@ export function DmSearchBar() { const globalIndex = dmItems.length + i; const isSelected = globalIndex === selectedIndex; return ( -
selectItem(item)} - className={`flex items-center gap-2.5 px-2 py-1.5 mx-1 rounded cursor-pointer transition-colors ${ - isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover' - }`} - > - -
- - {item.user.displayName ?? item.user.username} - - {item.user.displayName && ( - - @{item.user.username} - - )} -
-
+ /> ); })} diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index e5b6b5d2..ea076392 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -16,7 +16,9 @@ import { useVoiceStore } from '../../stores/voiceStore'; import { wsSend } from '../../hooks/useWebSocket'; import { MemberListToggleButton } from './MemberListToggleButton'; import { TransferIndicator } from './TransferIndicator'; -import { isSelf, parseFederatedUsername } from '../../utils/identity'; +import { isSelf, parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; +import type { User } from '@backspace/shared'; import { Tooltip } from '../ui/Tooltip'; import { joinVoiceChannel } from '../../utils/voice'; import { SearchPopover } from '../chat/SearchPopover'; @@ -48,6 +50,14 @@ export function MainContent() { const [searchOpen, setSearchOpen] = useState(false); const [jumpToMessageId, setJumpToMessageId] = useState(null); + // Resolve the DM header's "first other" member through the canonical view + // cache. The hook must be called unconditionally at the component top, so we + // pass a fallback when there is no current DM channel or 1-on-1 partner. + const _dmChannel = dmChannels.find(dm => dm.id === currentChannelId); + const _rawFirstOther = _dmChannel?.members.filter(m => !isSelf(m, authUser))[0] ?? null; + const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; + const _canonicalFirstOther = useCanonicalUserView(_rawFirstOther ?? _FALLBACK_USER); + // Reset search when channel changes useEffect(() => { setSearchOpen(false); @@ -85,8 +95,10 @@ export function MainContent() { const dmChannel = dmChannels.find(dm => dm.id === currentChannelId); const otherMembers = dmChannel?.members.filter(m => !isSelf(m, authUser)) ?? []; const isGroupDm = !!dmChannel?.ownerId; - const firstOther = otherMembers[0]; - const { baseName: firstBaseName, domain: firstDomain } = parseFederatedUsername(firstOther?.username ?? ''); + // Use the canonicalized view of the 1-on-1 partner (resolved above the + // conditional so the hook is called unconditionally). + const firstOther = _rawFirstOther ? _canonicalFirstOther : (otherMembers[0] ?? null); + const { baseName: firstBaseName } = parseFederatedUsername(firstOther?.username ?? ''); const dmName = isGroupDm ? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ') : firstOther?.displayName ?? (firstBaseName || 'Direct Message'); @@ -173,8 +185,8 @@ export function MainContent() { {dmName} - {!isGroupDm && firstDomain && ( - + {!isGroupDm && firstOther && isFederationGlobeApplicable(firstOther) && ( + diff --git a/packages/web/src/components/layout/MemberSidebar.tsx b/packages/web/src/components/layout/MemberSidebar.tsx index a0b4fdd9..869a0c5f 100644 --- a/packages/web/src/components/layout/MemberSidebar.tsx +++ b/packages/web/src/components/layout/MemberSidebar.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import type { MemberWithUser } from '@backspace/shared'; +import type { MemberWithUser, Activity } from '@backspace/shared'; import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import { useActivityStore } from '../../stores/activityStore'; @@ -7,7 +7,8 @@ import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; import { ActivityCard, hasRichActivity, getActivityAccentClass } from '../ui/ActivityCard'; import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; -import { parseFederatedUsername } from '../../utils/identity'; +import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; import { useDelayedLoading } from '../../hooks/useDelayedLoading'; /** @@ -45,6 +46,65 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) { }; } +function MemberSidebarRow({ + member, + isOffline, + colorStyle, + activities, + isRichActivity, + accentClass, + onClickMember, +}: { + member: MemberWithUser; + isOffline: boolean; + colorStyle: React.CSSProperties | undefined; + activities: Activity[]; + isRichActivity: boolean; + accentClass: string; + onClickMember: (e: React.MouseEvent, user: MemberWithUser['user']) => void; +}) { + const canonical = useCanonicalUserView(member.user); + const { baseName } = parseFederatedUsername(canonical.username); + const displayName = canonical.displayName ?? baseName; + + const rowClass = isRichActivity + ? `flex items-center gap-2.5 px-2.5 py-2 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass}` + : 'flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer group transition-colors'; + + return ( +
onClickMember(e, canonical)} + className={rowClass} + > + +
+ + {!isOffline && isFederationGlobeApplicable(canonical) && ( +
@{parseFederatedUsername(canonical.username).domain}
+ )} + {!isOffline && ( + + )} +
+
+ ); +} + export function MemberSidebar() { const members = useSpaceStore((s) => s.members); const spaces = useSpaceStore((s) => s.spaces); @@ -105,49 +165,22 @@ export function MemberSidebar() { }; const renderMember = (member: MemberWithUser, isOffline = false) => { - const { baseName, domain } = parseFederatedUsername(member.user.username); - const displayName = member.user.displayName ?? baseName; const colorStyle = isOffline ? undefined : getMemberColor(member); const activities = userActivities.get(member.userId) ?? []; const isRichActivity = !isOffline && hasRichActivity(activities); const primary = getPrimaryActivity(activities); const accentClass = primary ? getActivityAccentClass(primary.type) : ''; - - const rowClass = isRichActivity - ? `flex items-center gap-2.5 px-2.5 py-2 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass}` - : 'flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer group transition-colors'; - return ( -
handleMemberClick(e, member.user)} - className={rowClass} - > - -
- - {domain && !isOffline && ( -
@{domain}
- )} - {!isOffline && ( - - )} -
-
+ member={member} + isOffline={isOffline} + colorStyle={colorStyle} + activities={activities} + isRichActivity={isRichActivity} + accentClass={accentClass} + onClickMember={handleMemberClick} + /> ); }; diff --git a/packages/web/src/components/layout/MobileDmsScreen.tsx b/packages/web/src/components/layout/MobileDmsScreen.tsx index f66cf495..7d733e01 100644 --- a/packages/web/src/components/layout/MobileDmsScreen.tsx +++ b/packages/web/src/components/layout/MobileDmsScreen.tsx @@ -9,8 +9,147 @@ import { Avatar } from '../ui/Avatar'; import { Mascot } from '../ui/Mascot'; import { resolveAssetUrl } from '../../utils/assetUrls'; import { useNavigate } from 'react-router-dom'; -import { parseFederatedUsername } from '../../utils/identity'; +import { parseFederatedUsername, isFederationGlobeApplicable, isSelf } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; import { formatDmSidebarPreview } from '../../utils/dmFormatters'; +import type { DmChannel, User } from '@backspace/shared'; +import type { TaggedFriend } from '../../stores/socialStore'; + +const FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; + +function MobileFriendBubble({ + friend, + dmChannels, + onTap, +}: { + friend: TaggedFriend; + dmChannels: DmChannel[]; + onTap: (dmId: string) => void; +}) { + const canonical = useCanonicalUserView(friend as unknown as User); + // _instanceOrigin lives on TaggedFriend, not on the canonical User. Use the + // canonical avatar value but source the origin from the original friend. + const avatarUrl = canonical.avatar + ? resolveAssetUrl(canonical.avatar, friend._instanceOrigin) ?? `/api/uploads/${canonical.avatar}` + : null; + const displayName = canonical.displayName ?? parseFederatedUsername(canonical.username).baseName; + + return ( + + ); +} + +function MobileDmRow({ + dm, + authUser, + readStates, + onTap, + onContextMenu, + formatTimestamp, +}: { + dm: DmChannel; + authUser: User | null; + readStates: Map; + onTap: (id: string) => void; + onContextMenu: (e: React.MouseEvent, id: string, isGroup: boolean) => void; + formatTimestamp: (ts: number) => string; +}) { + const otherMembers = dm.members.filter(m => authUser ? !isSelf(m, authUser) : m.id !== authUser); + const isGroup = !!dm.ownerId; + const rawMainUser = otherMembers[0] ?? null; + const canonicalMainUser = useCanonicalUserView(rawMainUser ?? FALLBACK_USER); + const mainUser = rawMainUser ? canonicalMainUser : null; + + const name = isGroup + ? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ') + : mainUser?.displayName ?? (parseFederatedUsername(mainUser?.username ?? '').baseName || 'Unknown'); + + const lastMsgId = dm.lastMessage?.id; + const readState = readStates.get(dm.id); + const isUnread = lastMsgId && (!readState || readState < lastMsgId); + + const preview = formatDmSidebarPreview(dm, authUser ?? null); + const previewTime = dm.lastMessage?.createdAt; + + const avatarUrl = mainUser?.avatar ? `/api/uploads/${mainUser.avatar}` : null; + + return ( + + ); +} export function MobileDmsScreen() { const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); @@ -98,122 +237,31 @@ export function MobileDmsScreen() { {onlineFriends.length > 0 && (
- {onlineFriends.map(friend => { - const avatarUrl = friend.avatar - ? resolveAssetUrl(friend.avatar, friend._instanceOrigin) ?? `/api/uploads/${friend.avatar}` - : null; - return ( - - ); - })} + {onlineFriends.map(friend => ( + + ))}
)} {/* DM list */}
- {sortedDms.map(dm => { - const otherMembers = dm.members.filter(m => m.id !== authUser?.id); - const isGroup = !!dm.ownerId; - const name = isGroup - ? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ') - : otherMembers[0]?.displayName ?? (parseFederatedUsername(otherMembers[0]?.username ?? '').baseName || 'Unknown'); - - const lastMsgId = dm.lastMessage?.id; - const readState = readStates.get(dm.id); - const isUnread = lastMsgId && (!readState || readState < lastMsgId); - - // formatDmSidebarPreview returns the full preview line (system messages - // get human-readable text; group user-messages get the "Sender: " prefix). - const preview = formatDmSidebarPreview(dm, authUser ?? null); - const previewTime = dm.lastMessage?.createdAt; - - const mainUser = otherMembers[0]; - const avatarUrl = mainUser?.avatar - ? `/api/uploads/${mainUser.avatar}` - : null; - - return ( - - ); - })} + {sortedDms.map(dm => ( + + ))} {sortedDms.length === 0 && (
diff --git a/packages/web/src/components/layout/MobileMembersScreen.tsx b/packages/web/src/components/layout/MobileMembersScreen.tsx index 7cb0ad70..e102713c 100644 --- a/packages/web/src/components/layout/MobileMembersScreen.tsx +++ b/packages/web/src/components/layout/MobileMembersScreen.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import type { MemberWithUser } from '@backspace/shared'; +import type { MemberWithUser, Activity } from '@backspace/shared'; import { useSpaceStore } from '../../stores/spaceStore'; import { useActivityStore } from '../../stores/activityStore'; import { useUIStore } from '../../stores/uiStore'; @@ -7,7 +7,8 @@ import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; import { ActivityCard, hasRichActivity, getActivityAccentClass } from '../ui/ActivityCard'; import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; -import { parseFederatedUsername } from '../../utils/identity'; +import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; import { MobileScreenHeader } from './MobileScreenHeader'; /** @@ -42,6 +43,64 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) { }; } +function MobileMemberRow({ + member, + isOffline, + colorStyle, + activities, + isRichActivity, + accentClass, + onClickMember, +}: { + member: MemberWithUser; + isOffline: boolean; + colorStyle: React.CSSProperties | undefined; + activities: Activity[]; + isRichActivity: boolean; + accentClass: string; + onClickMember: (userId: string) => void; +}) { + const canonical = useCanonicalUserView(member.user); + const { baseName } = parseFederatedUsername(canonical.username); + const displayName = canonical.displayName ?? baseName; + + const rowClass = isRichActivity + ? `flex items-center gap-2.5 px-4 py-2.5 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass} active:bg-interactive-hover` + : 'flex items-center gap-2.5 px-4 py-2.5 rounded-[4px] cursor-pointer transition-colors active:bg-interactive-hover'; + + return ( +
onClickMember(member.userId)} + className={rowClass} + > + +
+ + {!isOffline && isFederationGlobeApplicable(canonical) && ( +
@{parseFederatedUsername(canonical.username).domain}
+ )} + {!isOffline && ( + + )} +
+
+ ); +} + interface MobileMembersScreenProps { params?: Record; } @@ -95,49 +154,22 @@ export function MobileMembersScreen({ params }: MobileMembersScreenProps) { }; const renderMember = (member: MemberWithUser, isOffline = false) => { - const { baseName, domain } = parseFederatedUsername(member.user.username); - const displayName = member.user.displayName ?? baseName; const colorStyle = isOffline ? undefined : getMemberColor(member); const activities = userActivities.get(member.userId) ?? []; const isRichActivity = !isOffline && hasRichActivity(activities); const primary = getPrimaryActivity(activities); const accentClass = primary ? getActivityAccentClass(primary.type) : ''; - - const rowClass = isRichActivity - ? `flex items-center gap-2.5 px-4 py-2.5 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass} active:bg-interactive-hover` - : 'flex items-center gap-2.5 px-4 py-2.5 rounded-[4px] cursor-pointer transition-colors active:bg-interactive-hover'; - return ( -
handleMemberClick(member.userId)} - className={rowClass} - > - -
- - {domain && !isOffline && ( -
@{domain}
- )} - {!isOffline && ( - - )} -
-
+ member={member} + isOffline={isOffline} + colorStyle={colorStyle} + activities={activities} + isRichActivity={isRichActivity} + accentClass={accentClass} + onClickMember={handleMemberClick} + /> ); }; diff --git a/packages/web/src/components/modals/AddDmMemberModal.tsx b/packages/web/src/components/modals/AddDmMemberModal.tsx index 4b8b420e..85aaf25d 100644 --- a/packages/web/src/components/modals/AddDmMemberModal.tsx +++ b/packages/web/src/components/modals/AddDmMemberModal.tsx @@ -5,9 +5,76 @@ import { Avatar } from '../ui/Avatar'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore } from '../../stores/spaceStore'; import { useAuthStore } from '../../stores/authStore'; -import { useSocialStore } from '../../stores/socialStore'; +import { useSocialStore, type TaggedFriend } from '../../stores/socialStore'; import { api } from '../../api/client'; import { isSelf, parseFederatedUsername } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; +import type { User } from '@backspace/shared'; + +function AddDmFriendRow({ + friend, + isInDm, + isSelected, + atCapacity, + isAdding, + onToggle, +}: { + friend: TaggedFriend; + isInDm: boolean; + isSelected: boolean; + atCapacity: boolean; + isAdding: boolean; + onToggle: (id: string) => void; +}) { + const canonical = useCanonicalUserView(friend as unknown as User); + const { baseName } = parseFederatedUsername(canonical.username); + const friendDisplayName = canonical.displayName ?? baseName; + return ( + + ); +} export function AddDmMemberModal() { const [query, setQuery] = useState(''); @@ -197,54 +264,16 @@ export function AddDmMemberModal() { const isInDm = currentMemberIds.has(friend.id); const isSelected = selected.has(friend.id); const atCapacity = !isSelected && selected.size >= remainingSlots; - const { baseName, domain } = parseFederatedUsername(friend.username); - const friendDisplayName = friend.displayName ?? baseName; - return ( - + friend={friend} + isInDm={isInDm} + isSelected={isSelected} + atCapacity={atCapacity} + isAdding={isAdding} + onToggle={toggleFriend} + /> ); })}
diff --git a/packages/web/src/components/modals/InviteModal.tsx b/packages/web/src/components/modals/InviteModal.tsx index 7286dc53..efce5291 100644 --- a/packages/web/src/components/modals/InviteModal.tsx +++ b/packages/web/src/components/modals/InviteModal.tsx @@ -7,7 +7,8 @@ import { useAuthStore } from '../../stores/authStore'; import { useSocialStore } from '../../stores/socialStore'; import { api } from '../../api/client'; import { isSelf, parseFederatedUsername } from '../../utils/identity'; -import type { Friend, MemberWithUser, SpaceInviteRequest } from '@backspace/shared'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; +import type { Friend, MemberWithUser, SpaceInviteRequest, User } from '@backspace/shared'; type SendStatus = | { kind: 'pending' } @@ -40,6 +41,101 @@ function reasonForError(error: unknown): string { return "Couldn't send (server error)"; } +function InviteResultFriendRow({ + friend, + status, +}: { + friend: Friend; + status: SendStatus | undefined; +}) { + const canonical = useCanonicalUserView(friend as unknown as User); + const { baseName } = parseFederatedUsername(canonical.username); + const dn = canonical.displayName ?? baseName; + return ( +
+ +
+
{dn}
+
@{canonical.username}
+
+ {status?.kind === 'success' && ( + ✓ Sent + )} + {status?.kind === 'failure' && ( + ✗ {status.reason} + )} + {status?.kind === 'pending' && ( + ... + )} +
+ ); +} + +function InviteSelectFriendRow({ + friend, + isSelected, + alreadyMember, + sending, + onToggle, +}: { + friend: Friend; + isSelected: boolean; + alreadyMember: boolean; + sending: boolean; + onToggle: (id: string, friend: Friend) => void; +}) { + const canonical = useCanonicalUserView(friend as unknown as User); + const { baseName } = parseFederatedUsername(canonical.username); + const dn = canonical.displayName ?? baseName; + return ( + + ); +} + export function InviteModal() { const activeModal = useUIStore((s) => s.activeModal); const closeModal = useUIStore((s) => s.closeModal); @@ -278,48 +374,13 @@ export function InviteModal() { {/* Friend list / Results view */}
{inResultsView ? ( - selectedFriends.map((f) => { - const status = results.get(f.id); - const { baseName } = parseFederatedUsername(f.username); - const dn = f.displayName ?? baseName; - return ( -
- -
-
- {dn} -
-
- @{f.username} -
-
- {status?.kind === 'success' && ( - - ✓ Sent - - )} - {status?.kind === 'failure' && ( - - ✗ {status.reason} - - )} - {status?.kind === 'pending' && ( - - ... - - )} -
- ); - }) + selectedFriends.map((f) => ( + + )) ) : ( <> {filteredFriends.length === 0 && ( @@ -329,72 +390,16 @@ export function InviteModal() { : 'No friends yet'}
)} - {filteredFriends.map((friend) => { - const alreadyMember = isFriendAlreadyMember(friend); - const isSelected = selected.has(friend.id); - const { baseName } = parseFederatedUsername(friend.username); - const dn = friend.displayName ?? baseName; - return ( - - ); - })} + {filteredFriends.map((friend) => ( + + ))} )}
diff --git a/packages/web/src/components/modals/NewDmModal.tsx b/packages/web/src/components/modals/NewDmModal.tsx index 15de6367..8bd1289c 100644 --- a/packages/web/src/components/modals/NewDmModal.tsx +++ b/packages/web/src/components/modals/NewDmModal.tsx @@ -7,6 +7,33 @@ import { useSpaceStore } from '../../stores/spaceStore'; import { api } from '../../api/client'; import type { User } from '@backspace/shared'; import { parseFederatedUsername } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; + +function NewDmUserRow({ + user, + onSelect, +}: { + user: User; + onSelect: (user: User) => void; +}) { + const canonical = useCanonicalUserView(user); + const { baseName } = parseFederatedUsername(canonical.username); + const displayName = canonical.displayName ?? baseName; + return ( + + ); +} export function NewDmModal() { const [query, setQuery] = useState(''); @@ -106,25 +133,13 @@ export function NewDmModal() {
No users found
)} - {results.map((user) => { - const { baseName } = parseFederatedUsername(user.username); - const displayName = user.displayName ?? baseName; - return ( - - ); - })} + {results.map((user) => ( + + ))}
diff --git a/packages/web/src/components/modals/TransferOwnershipModal.tsx b/packages/web/src/components/modals/TransferOwnershipModal.tsx index 49503f73..cbdcb0c6 100644 --- a/packages/web/src/components/modals/TransferOwnershipModal.tsx +++ b/packages/web/src/components/modals/TransferOwnershipModal.tsx @@ -5,8 +5,44 @@ import { useSpaceStore, getApiForOrigin, type TaggedSpace } from '../../stores/s import { useAuthStore } from '../../stores/authStore'; import { useUIStore } from '../../stores/uiStore'; import { normalizeUserAssets } from '../../utils/assetUrls'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; import { Avatar } from '../ui/Avatar'; +function TransferMemberRow({ + member, + onSelect, +}: { + member: MemberWithUser; + onSelect: (userId: string) => void; +}) { + const canonical = useCanonicalUserView(member.user); + const displayName = canonical.displayName || canonical.username; + return ( + + ); +} + export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) { const modalRef = useRef(null); const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId)); @@ -29,8 +65,9 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; const client = getApiForOrigin(origin); const fetched = await client.spaces.members(spaceId); if (cancelled) return; - if (origin) { - for (const m of fetched) normalizeUserAssets(m.user, origin); + for (const m of fetched) { + if (origin) normalizeUserAssets(m.user, origin); + useSpaceStore.getState().upsertUserView(m.user, origin); } setMembers(fetched); } catch { @@ -156,33 +193,13 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; ) : filteredMembers.length === 0 ? (

No members found

) : ( - filteredMembers.map((member) => { - return ( - - ); - }) + filteredMembers.map((member) => ( + + )) )}
diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index 1e3a6f53..9cb4e92b 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -87,6 +87,7 @@ export function UserProfileModal() { const targetApi = getApiForOrigin(origin); const u = await targetApi.users.get(id); setUser(u); + useSpaceStore.getState().upsertUserView(u, origin); } catch { // User not found } diff --git a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx index eed96b95..135658bc 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx @@ -3,9 +3,168 @@ import { Avatar } from '../../ui/Avatar'; import { ConfirmDialog } from '../../ui/ConfirmDialog'; import { useSpaceStore, getApiForOrigin } from '../../../stores/spaceStore'; import { useAuthStore } from '../../../stores/authStore'; -import { parseFederatedUsername } from '../../../utils/identity'; +import { parseFederatedUsername, isFederationGlobeApplicable } from '../../../utils/identity'; +import { useCanonicalUserView } from '../../../utils/userViewLookup'; import { hasPermissionBit, PermissionBits } from '../../../utils/permissions'; -import type { MemberWithUser } from '@backspace/shared'; +import type { MemberWithUser, Role } from '@backspace/shared'; + +function MembersPanelRow({ + member, + spaceId, + ownerId, + isExpanded, + expandable, + canKick, + canBan, + currentUserId, + assignableRoles, + memberRoleIds, + hasPendingChanges, + onToggleExpand, + onRoleToggle, + onSaveRoles, + onCancelRoleChange, + onPendingAction, +}: { + member: MemberWithUser; + spaceId: string; + ownerId: string | undefined; + isExpanded: boolean; + expandable: boolean; + canKick: boolean; + canBan: boolean; + currentUserId: string | undefined; + assignableRoles: Role[]; + memberRoleIds: Set; + hasPendingChanges: boolean; + onToggleExpand: (userId: string) => void; + onRoleToggle: (userId: string, roleId: string, currentRoleIds: Set) => void; + onSaveRoles: (userId: string) => void; + onCancelRoleChange: (userId: string) => void; + onPendingAction: (action: { type: 'kick' | 'ban'; userId: string; displayName: string }) => void; +}) { + const canonical = useCanonicalUserView(member.user); + const isOwner = member.userId === ownerId; + const displayName = canonical.displayName ?? canonical.username; + + return ( +
+
{ + if (expandable) onToggleExpand(member.userId); + }} + > +
+ +
+
+ {displayName} + {isFederationGlobeApplicable(canonical) && ( + @{parseFederatedUsername(canonical.username).domain} + )} +
+
+ {isOwner && ( + + Owner + + )} + {member.roles?.filter((r) => r.id !== spaceId).map((r) => ( + + {r.name} + + ))} + {!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && ( + No roles + )} +
+
+
+ +
+ {canBan && member.userId !== currentUserId && !isOwner && ( + + )} + {canKick && member.userId !== currentUserId && !isOwner && ( + + )} + {expandable && ( + + + + )} +
+
+ + {/* Role checkboxes — only shown when expanded */} + {isExpanded && expandable && ( +
+ {assignableRoles.map((role) => ( + + ))} + {hasPendingChanges && ( +
+ + +
+ )} +
+ )} +
+ ); +} interface MembersPanelProps { spaceId: string; @@ -112,135 +271,27 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
- {members.map((member) => { - const { domain } = parseFederatedUsername(member.user.username); - const displayName = member.user.displayName ?? member.user.username; - const isOwner = member.userId === space.ownerId; - const memberRoleIds = getMemberRoleIds(member); - const hasPendingChanges = pendingRoleChanges.has(member.userId); - const isExpanded = expandedMemberId === member.userId; - const expandable = canExpandMember(member); - - return ( -
-
{ - if (expandable) { - setExpandedMemberId(isExpanded ? null : member.userId); - } - }} - > -
- -
-
- {displayName} - {domain && ( - @{domain} - )} -
-
- {isOwner && ( - - Owner - - )} - {member.roles?.filter((r) => r.id !== spaceId).map((r) => ( - - {r.name} - - ))} - {!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && ( - No roles - )} -
-
-
- -
- {canBan && member.userId !== currentUser?.id && !isOwner && ( - - )} - {canKick && member.userId !== currentUser?.id && !isOwner && ( - - )} - {expandable && ( - - - - )} -
-
- - {/* Role checkboxes — only shown when expanded */} - {isExpanded && expandable && ( -
- {assignableRoles.map((role) => ( - - ))} - {hasPendingChanges && ( -
- - -
- )} -
- )} -
- ); - })} + {members.map((member) => ( + setExpandedMemberId(expandedMemberId === uid ? null : uid)} + onRoleToggle={handleRoleToggle} + onSaveRoles={handleSaveRoles} + onCancelRoleChange={handleCancelRoleChange} + onPendingAction={setPendingAction} + /> + ))}
diff --git a/packages/web/src/components/ui/UserProfilePopout.tsx b/packages/web/src/components/ui/UserProfilePopout.tsx index 64ffd61a..c8fd2175 100644 --- a/packages/web/src/components/ui/UserProfilePopout.tsx +++ b/packages/web/src/components/ui/UserProfilePopout.tsx @@ -9,6 +9,7 @@ import { api } from '../../api/client'; import { useUIStore } from '../../stores/uiStore'; import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradients'; import { parseFederatedUsername } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; import { loadFederatedMutuals } from '../../utils/mutuals'; interface UserProfilePopoutProps { @@ -17,10 +18,17 @@ interface UserProfilePopoutProps { position?: { top: number; left: number }; } -export function UserProfilePopout({ user, onClose, position }: UserProfilePopoutProps) { +export function UserProfilePopout({ user: propUser, onClose, position }: UserProfilePopoutProps) { const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); const openModal = useUIStore((s) => s.openModal); + // Resolve to the best-known view of this user from the userViews cache. + // The prop frequently arrives as a federated stub (when the carrying DM + // came from a sibling instance that won the populateFromReady dedup race); + // routing through the cache surfaces the home view when one is loaded. + // Identity fields (id, homeUserId, homeInstance) are preserved across + // canonicalization, so write-payload code paths below remain correct. + const user = useCanonicalUserView(propUser); const { baseName, domain } = parseFederatedUsername(user.username); const displayName = user.displayName ?? baseName; diff --git a/packages/web/src/components/voice/IncomingCallModal.tsx b/packages/web/src/components/voice/IncomingCallModal.tsx index d497daae..596dd939 100644 --- a/packages/web/src/components/voice/IncomingCallModal.tsx +++ b/packages/web/src/components/voice/IncomingCallModal.tsx @@ -3,7 +3,9 @@ import { useVoiceStore } from '../../stores/voiceStore'; import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; import { wsSend } from '../../hooks/useWebSocket'; import { parseFederatedUsername } from '../../utils/identity'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; import { Avatar } from '../ui/Avatar'; +import type { User } from '@backspace/shared'; export function IncomingCallModal() { const incomingCall = useVoiceStore((s) => s.incomingCall); @@ -31,11 +33,18 @@ export function IncomingCallModal() { const dmChannels = useSpaceStore((s) => s.dmChannels); + const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; + // Look up caller member before the early return so useCanonicalUserView is always called + const _rawCallerMember = (() => { + if (!incomingCall) return null; + const dmChannel = dmChannels.find(d => d.id === incomingCall.dmChannelId); + return dmChannel?.members.find(m => m.id === incomingCall.callerId) ?? null; + })(); + const _canonicalCaller = useCanonicalUserView((_rawCallerMember as User | null) ?? _FALLBACK_USER); + const callerMember = _rawCallerMember ? _canonicalCaller : null; + if (!incomingCall) return null; - // Look up the caller in DM channel members for homeUserId - const dmChannel = dmChannels.find(d => d.id === incomingCall.dmChannelId); - const callerMember = dmChannel?.members.find(m => m.id === incomingCall.callerId); const callerAvatarId = callerMember?.homeUserId ?? incomingCall.callerId; const { baseName: callerBaseName } = parseFederatedUsername(incomingCall.callerName); diff --git a/packages/web/src/hooks/useVoiceParticipantMeta.ts b/packages/web/src/hooks/useVoiceParticipantMeta.ts index 62f1b7e4..67c865b1 100644 --- a/packages/web/src/hooks/useVoiceParticipantMeta.ts +++ b/packages/web/src/hooks/useVoiceParticipantMeta.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useSpaceStore } from '../stores/spaceStore'; import { parseFederatedUsername } from '../utils/identity'; +import { getCanonicalUserView } from '../utils/userViewLookup'; import type { ParticipantInfo } from './useLiveKit'; import type { User } from '@backspace/shared'; @@ -18,11 +19,12 @@ export function useVoiceParticipantMeta(participant: ParticipantInfo) { // 1. Try space members (primary — covers space voice channels) const member = members.find(m => m.userId === participant.userId); if (member?.user) { - const { baseName } = parseFederatedUsername(member.user.username); + const canonical = getCanonicalUserView(member.user as User); + const { baseName } = parseFederatedUsername(canonical.username); return { - displayName: member.user.displayName ?? baseName, - avatar: member.user.avatar ?? null, - user: member.user as User, + displayName: canonical.displayName ?? baseName, + avatar: canonical.avatar ?? null, + user: canonical, }; } @@ -30,22 +32,26 @@ export function useVoiceParticipantMeta(participant: ParticipantInfo) { for (const dm of dmChannels) { const dmMember = dm.members?.find(m => m.id === participant.userId); if (dmMember) { - const { baseName } = parseFederatedUsername(dmMember.username); + const canonical = getCanonicalUserView(dmMember as User); + const { baseName } = parseFederatedUsername(canonical.username); return { - displayName: dmMember.displayName ?? baseName, - avatar: dmMember.avatar ?? null, - user: dmMember as User, + displayName: canonical.displayName ?? baseName, + avatar: canonical.avatar ?? null, + user: canonical, }; } } - // 3. Fallback to cached user from ParticipantInfo (federation carry-forward) + // 3. Fallback to cached user from ParticipantInfo (federation carry-forward). + // Route through the userViews cache: a User captured from federation handoff + // can be a stale stub view, and the cache may hold a fresher home view. if (participant.cachedUser) { - const { baseName } = parseFederatedUsername(participant.cachedUser.username); + const canonical = getCanonicalUserView(participant.cachedUser); + const { baseName } = parseFederatedUsername(canonical.username); return { - displayName: participant.cachedUser.displayName ?? baseName, - avatar: participant.cachedUser.avatar ?? null, - user: participant.cachedUser, + displayName: canonical.displayName ?? baseName, + avatar: canonical.avatar ?? null, + user: canonical, }; } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index f86a1131..8f80e91b 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -5,7 +5,7 @@ import { useChatStore } from '../stores/chatStore'; import { useVoiceStore } from '../stores/voiceStore'; import { useSocialStore } from '../stores/socialStore'; import { useSettingsStore } from '../stores/settingsStore'; -import type { ServerEvent, ClientEvent, ActiveCallInfo, Activity } from '@backspace/shared'; +import type { ServerEvent, ClientEvent, ActiveCallInfo, Activity, User } from '@backspace/shared'; import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls'; import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice'; import { sortDmChannels } from '../utils/dmSorting'; @@ -128,7 +128,7 @@ const HOME_ORIGIN = ''; function handleEvent(origin: string, event: ServerEvent): void { const isHome = origin === HOME_ORIGIN; const { setUser } = useAuthStore.getState(); - const { populateFromReady, loadSpaceDetail, currentSpaceId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useSpaceStore.getState(); + const { populateFromReady, loadSpaceDetail, currentSpaceId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel, upsertUserView } = useSpaceStore.getState(); const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, clearTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState(); const { addVoiceUser, removeVoiceUser, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState(); @@ -456,6 +456,8 @@ function handleEvent(origin: string, event: ServerEvent): void { } } } + if (event.message.user) upsertUserView(event.message.user, origin); + if (event.message.replyTo?.user) upsertUserView(event.message.replyTo.user, origin); addRealtimeMessage(event.message.channelId, event.message); { const { currentChannelId, markChannelUnread } = useChatStore.getState(); @@ -479,6 +481,8 @@ function handleEvent(origin: string, event: ServerEvent): void { } } } + if (event.message.user) upsertUserView(event.message.user, origin); + if (event.message.replyTo?.user) upsertUserView(event.message.replyTo.user, origin); updateMessage(event.message); break; @@ -505,6 +509,7 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'user_updated': { if (!isHome) normalizeUserAssets(event.user, origin); + upsertUserView(event.user, origin); useSpaceStore.getState().updateUserEverywhere(event.user); useSocialStore.getState().updateFriendProfile(event.user); useChatStore.getState().updateUserInMessages(event.user); @@ -608,6 +613,7 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'member_joined': if (!isHome) normalizeUserAssets(event.member.user, origin); + upsertUserView(event.member.user, origin); addMember(event.member); break; @@ -642,6 +648,8 @@ function handleEvent(origin: string, event: ServerEvent): void { } } } + if ((event.message as any).user) upsertUserView((event.message as any).user, origin); + if ((event.message as any).replyTo?.user) upsertUserView((event.message as any).replyTo.user, origin); const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useSpaceStore.getState(); const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId); @@ -732,6 +740,8 @@ function handleEvent(origin: string, event: ServerEvent): void { } } } + if ((event.message as any).user) upsertUserView((event.message as any).user, origin); + if ((event.message as any).replyTo?.user) upsertUserView((event.message as any).replyTo.user, origin); updateMessage(event.message as any); break; @@ -885,6 +895,7 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'friend_request_received': { if (!isHome && event.request.user) normalizeUserAssets(event.request.user, origin); + if (event.request.user) upsertUserView(event.request.user, origin); const { addIncomingRequest } = useSocialStore.getState(); addIncomingRequest(event.request, origin); break; @@ -893,6 +904,7 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'friend_request_sent': { // Multi-tab sync: another tab/device of the same user just created an outbound request. if (!isHome && event.request.user) normalizeUserAssets(event.request.user, origin); + if (event.request.user) upsertUserView(event.request.user, origin); const { addOutboundRequest } = useSocialStore.getState(); addOutboundRequest(event.request, origin); break; @@ -913,6 +925,8 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'friend_request_accepted': { if (!isHome) normalizeUserAssets(event.friend, origin); + // Friend carries the identity fields the cache needs; cast to User for upsert. + upsertUserView(event.friend as unknown as User, origin); const { addFriendFromAccepted } = useSocialStore.getState(); addFriendFromAccepted(event.friend, event.requestId, origin); import('../stores/discoverStore').then(({ useDiscoverStore }) => { @@ -1062,6 +1076,9 @@ function handleEvent(origin: string, event: ServerEvent): void { normalizeUserAssets(m, origin); } } + for (const m of event.dmChannel.members) { + upsertUserView(m, origin); + } // Dedup: skip if a channel with the same federatedId already exists const fid = event.dmChannel.federatedId; if (fid) { @@ -1080,6 +1097,7 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'dm_member_added': { if (!isHome && !activePeerOrigins.has(origin)) break; if (!isHome) normalizeUserAssets(event.user, origin); + upsertUserView(event.user, origin); const { addDmMember } = useSpaceStore.getState(); addDmMember(event.dmChannelId, event.user); break; diff --git a/packages/web/src/stores/discoverStore.ts b/packages/web/src/stores/discoverStore.ts index 6cd4085f..1fca3924 100644 --- a/packages/web/src/stores/discoverStore.ts +++ b/packages/web/src/stores/discoverStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { DiscoverUser } from '@backspace/shared'; +import type { DiscoverUser, User } from '@backspace/shared'; import { api } from '../api/client'; import { useInstanceStore } from './instanceStore'; import { normalizeUserAssets } from '../utils/assetUrls'; @@ -52,6 +52,10 @@ export const useDiscoverStore = create((set) => ({ }); } + // Lazy import — avoids pulling spaceStore's transitive dependency chain + // (voiceStore → AudioManager) into test environments. + const { useSpaceStore } = await import('./spaceStore'); + try { const instances = useInstanceStore.getState().instances; const connectedInstances = instances.filter(i => i.status === 'connected'); @@ -84,6 +88,8 @@ export const useDiscoverStore = create((set) => ({ seen.add(key); if (origin) normalizeUserAssets(user as unknown as { avatar?: string | null; banner?: string | null }, origin); allUsers.push({ ...user, _instanceOrigin: origin }); + // DiscoverUser carries the identity/avatar fields the cache needs; cast to User. + useSpaceStore.getState().upsertUserView(user as unknown as User, origin); } } diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 2553cd4e..c1114c8b 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -81,6 +81,10 @@ export const useSocialStore = create((set, get) => ({ // Wait for all remote connections to establish before fanning out await waitForAutoConnect(); + // Lazy import — avoids pulling spaceStore's transitive chain (voiceStore → + // AudioManager) into test environments that mock only instanceStore. + const { useSpaceStore } = await import('./spaceStore'); + const instances = useInstanceStore.getState().instances; const connectedInstances = instances.filter(i => i.status === 'connected'); @@ -113,6 +117,9 @@ export const useSocialStore = create((set, get) => ({ if (isNative) { if (origin) normalizeUserAssets(friend, origin); allFriends[existingIdx] = { ...friend, _instanceOrigin: origin }; + // Upsert the upgraded (native) view into the userViews cache. + // Friend carries all identity/avatar fields the cache needs. + useSpaceStore.getState().upsertUserView(friend as unknown as User, origin); } continue; } @@ -120,6 +127,7 @@ export const useSocialStore = create((set, get) => ({ seen.set(canonicalId, allFriends.length); if (origin) normalizeUserAssets(friend, origin); allFriends.push({ ...friend, _instanceOrigin: origin }); + useSpaceStore.getState().upsertUserView(friend as unknown as User, origin); } } @@ -139,6 +147,9 @@ export const useSocialStore = create((set, get) => ({ // Wait for all remote connections to establish before fanning out await waitForAutoConnect(); + // Lazy import — same pattern as loadFriends; avoids AudioManager TDZ in tests. + const { useSpaceStore } = await import('./spaceStore'); + const instances = useInstanceStore.getState().instances; const connectedInstances = instances.filter(i => i.status === 'connected'); @@ -172,6 +183,7 @@ export const useSocialStore = create((set, get) => ({ if (otherIsNativeHere) { if (origin && request.user) normalizeUserAssets(request.user, origin); allRequests[existingIdx] = { ...request, _instanceOrigin: origin }; + if (request.user) useSpaceStore.getState().upsertUserView(request.user, origin); } continue; } @@ -179,6 +191,7 @@ export const useSocialStore = create((set, get) => ({ if (otherCanonicalId) seen.set(otherCanonicalId, allRequests.length); if (origin && request.user) normalizeUserAssets(request.user, origin); allRequests.push({ ...request, _instanceOrigin: origin }); + if (request.user) useSpaceStore.getState().upsertUserView(request.user, origin); } } @@ -278,6 +291,9 @@ export const useSocialStore = create((set, get) => ({ searchUsers: async (query: string) => { try { + // Lazy import — avoids AudioManager TDZ in test environments. + const { useSpaceStore } = await import('./spaceStore'); + const instances = useInstanceStore.getState().instances; const connectedInstances = instances.filter(i => i.status === 'connected'); @@ -316,6 +332,7 @@ export const useSocialStore = create((set, get) => ({ if (isNative) { if (origin) normalizeUserAssets(user, origin); allUsers[existingIdx] = { ...user, _instanceOrigin: origin }; + useSpaceStore.getState().upsertUserView(user, origin); } continue; } @@ -323,6 +340,7 @@ export const useSocialStore = create((set, get) => ({ seen.set(canonicalId, allUsers.length); if (origin) normalizeUserAssets(user, origin); allUsers.push({ ...user, _instanceOrigin: origin }); + useSpaceStore.getState().upsertUserView(user, origin); } }); diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 580e959f..cc66305d 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -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 { api, BackspaceApiClient } from '../api/client'; import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls'; -import { isSelf } from '../utils/identity'; +import { isSelf, canonicalUserKey, isDeliveryFromHome } from '../utils/identity'; import { sortDmChannels } from '../utils/dmSorting'; import { getApiForOrigin, @@ -29,6 +29,31 @@ export class NotConnectedError extends Error { } } +// ─── User-view cache types ──────────────────────────────────────────────────── + +/** + * A single cached view of a user, populated from one delivering origin. + * + * The userViews cache stores the best-known view of each canonical identity + * across every connected instance, regardless of whether the carrying channel + * survived dedup. Mirrors `dmAlternatives` philosophy: information from + * skipped ready payloads is still load-bearing for rendering. + * + * - `deliveredBy`: the origin string used at insert time. Required for + * lifecycle pruning (drop entries whose delivering origin is removed from + * Connections) — the user's declared `homeInstance` is NOT a substitute, + * because a stub view delivered by orbit has homeInstance=nova. + * - `isHome`: cached at insert time so the preference rule does not need to + * re-normalize on every write. + * - `updatedAt`: same-tier freshness tiebreaker. + */ +export interface UserViewEntry { + user: User; + deliveredBy: string; + isHome: boolean; + updatedAt: number; +} + // ─── Store interface ────────────────────────────────────────────────────────── interface SpaceState { @@ -50,6 +75,16 @@ interface SpaceState { categoryOriginMap: Map; // categoryId → instance origin ('' = home) /** federatedId → (origin → localChannelId). Every DM from every origin's ready payload is recorded here regardless of dedup outcome, so failover can re-point to an alternate origin's local channel ID. */ dmAlternatives: Map>; + /** + * canonicalUserKey → best-known view of that user. Populated from every wire + * surface that delivers a User object (DM members, message authors, friends, + * space members, profile updates). Pruned only on full instance removal + * (`removeInstanceSpaces`) and `reset`, never on transient WS disconnect — + * mirrors `dmAlternatives`' no-flapping invariant. Render sites read through + * `getCanonicalUserView` / `useCanonicalUserView` to surface the home view + * even when the carrying channel was deduped away. + */ + userViews: Map; loadingSpaceId: string | null; // non-null while loadSpaceDetail is fetching _layoutUpdatedAt: number; setSpaces: (spaces: TaggedSpace[]) => void; @@ -90,6 +125,16 @@ interface SpaceState { setSpaceLayout: (layout: SpaceLayoutItem[] | null) => void; updateSpaceLayout: (items: SpaceLayoutItem[], folders: Record) => Promise; populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null, layoutUpdatedAt?: number) => void; + /** + * Upsert a User into the userViews cache under the preference rule: + * - if no entry: insert + * - if existing is home view and incoming is stub: ignore + * - if existing is stub and incoming is home view: overwrite (upgrade) + * - same tier (both home or both stub): freshness wins (incoming overwrites) + * Origin is REQUIRED to derive the home/stub tier and to enable pruning by + * delivering origin on instance removal. + */ + upsertUserView: (user: User, deliveringOrigin: string) => void; addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void; removeInstanceSpaces: (origin: string) => void; transferOwnership: (spaceId: string, newOwnerId: string) => Promise; @@ -142,6 +187,7 @@ export const useSpaceStore = create((set, get) => ({ voiceChannelIds: new Set(), categoryOriginMap: new Map(), dmAlternatives: new Map(), + userViews: new Map(), loadingSpaceId: null, _layoutUpdatedAt: 0, @@ -165,6 +211,7 @@ export const useSpaceStore = create((set, get) => ({ voiceChannelIds: new Set(), categoryOriginMap: new Map(), dmAlternatives: new Map(), + userViews: new Map(), loadingSpaceId: null, _layoutUpdatedAt: 0, }); @@ -189,6 +236,24 @@ export const useSpaceStore = create((set, get) => ({ }; }), + upsertUserView: (user, deliveringOrigin) => set((state) => { + const key = canonicalUserKey(user); + const incomingIsHome = isDeliveryFromHome(user, deliveringOrigin); + const existing = state.userViews.get(key); + + // Stub view never overwrites a home view. + if (existing && existing.isHome && !incomingIsHome) return state; + + const next = new Map(state.userViews); + next.set(key, { + user, + deliveredBy: deliveringOrigin, + isHome: incomingIsHome, + updatedAt: Date.now(), + }); + return { userViews: next }; + }), + removeDmChannel: (id) => { set((state) => ({ dmChannels: state.dmChannels.filter(c => c.id !== id) @@ -265,6 +330,11 @@ export const useSpaceStore = create((set, get) => ({ normalizeUserAssets(member.user, origin); } } + // Upsert every member into the userViews cache (home or remote). + // Assets are already normalized above for the remote case. + for (const member of detail.members) { + get().upsertUserView(member.user, origin); + } // Populate permission maps from REST response const spacePermissions = new Map(get().spacePermissions); @@ -662,6 +732,15 @@ export const useSpaceStore = create((set, get) => ({ categoryOriginMap.set(cat.id, origin); } } + // Upsert every space member into the userViews cache. Assets for remote + // origins were normalized by the ready handler in useWebSocket before + // populateFromReady was called, so the user objects are already clean here. + if (srv.members) { + const { upsertUserView } = get(); + for (const member of srv.members) { + upsertUserView(member.user, origin); + } + } } // Accept DMs from all origins. Each instance serves its own DM data. @@ -676,6 +755,19 @@ export const useSpaceStore = create((set, get) => ({ } } + // Upsert every DM member from every origin into the userViews cache. + // This runs unconditionally (home + remote) and BEFORE the dedup pass so + // members of DMs that are about to be discarded still land in the cache. + // Assets are already normalized above for the remote case. + { + const { upsertUserView } = get(); + for (const dm of incomingDms) { + for (const member of dm.members) { + upsertUserView(member, origin); + } + } + } + // Build a set of existing federatedIds for dedup (only from OTHER origins — // DMs from the reconnecting origin will be replaced, not deduplicated) const existingFederatedIds = new Map(); // federatedId → dmChannelId @@ -890,6 +982,16 @@ export const useSpaceStore = create((set, get) => ({ if (nextInner.size > 0) dmAlternatives.set(fid, nextInner); } + // Prune userViews: drop entries delivered by this origin. Symmetrical + // with dmAlternatives — full removal evicts; transient disconnect leaves + // the last-known view in place. If the surviving cache no longer holds + // a home view for some user, render falls back to whatever the carrying + // payload supplies (no crash; just degrades to stub view). + const userViews = new Map(); + for (const [key, entry] of state.userViews) { + if (entry.deliveredBy !== origin) userViews.set(key, entry); + } + return { spaces: remainingSpaces, channelToSpaceMap, @@ -898,6 +1000,7 @@ export const useSpaceStore = create((set, get) => ({ channelOriginMap, spacePermissions, dmAlternatives, + userViews, currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId) ? state.currentSpaceId : null, diff --git a/packages/web/src/stores/spaceStore.userViews.test.ts b/packages/web/src/stores/spaceStore.userViews.test.ts new file mode 100644 index 00000000..649a4910 --- /dev/null +++ b/packages/web/src/stores/spaceStore.userViews.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +vi.mock('./instanceStore', () => ({ + useInstanceStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ instances: [], _autoConnectDone: true }), + { + getState: () => ({ instances: [], _autoConnectDone: true }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +vi.mock('./authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { + getState: () => ({ user: null, token: null }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +import { useSpaceStore } from './spaceStore'; +import { canonicalUserKey } from '../utils/identity'; +import type { User } from '@backspace/shared'; + +function makeUser(extras: Partial & Pick): User { + return { + displayName: extras.username, + avatar: '', + avatarColor: 'mint', + homeUserId: null, + homeInstance: null, + status: 'online', + customStatus: null, + bio: null, + banner: null, + isAdmin: false, + isDeleted: false, + discoverable: true, + showActivity: true, + createdAt: 0, + ...extras, + } as User; +} + +beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { host: 'nova.ddns.net' }, + writable: true, + }); + useSpaceStore.getState().reset(); +}); + +describe('spaceStore.upsertUserView preference rule', () => { + it('inserts a fresh entry when none exists', () => { + const user = makeUser({ id: 'local-1', username: 'alice' }); + useSpaceStore.getState().upsertUserView(user, ''); + const entry = useSpaceStore.getState().userViews.get(canonicalUserKey(user)); + expect(entry).toBeDefined(); + expect(entry!.user).toBe(user); + expect(entry!.isHome).toBe(true); + expect(entry!.deliveredBy).toBe(''); + }); + + it('home view (delivered by user home) wins over an existing stub', () => { + // orbit delivers Axel as a federated stub (axel's home is nova). + const stubAxel = makeUser({ + id: 'orbit-local-id', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'lavender', + avatar: 'https://nova.ddns.net/api/uploads/old.png', + }); + useSpaceStore.getState().upsertUserView(stubAxel, 'https://orbit.ddns.net'); + + // Then nova delivers axel natively (no homeInstance, our home origin ''). + // canonicalUserKey for the home view: needs to match the stub's key. + // Stub key = "nova.ddns.net:nova-axel-id". + // Home view (nova native): homeInstance=null, homeUserId=null, id="nova-axel-id" + // → key = ":nova-axel-id" + // These keys are different on purpose: the home record on its home instance + // has no homeInstance/homeUserId. The cross-instance match relies on the + // stub being the federated form. Verify behavior accordingly. + const homeAxel = makeUser({ + id: 'nova-axel-id', + username: 'axel', + avatar: '', + avatarColor: 'teal', + }); + useSpaceStore.getState().upsertUserView(homeAxel, ''); + + // Stub entry is unchanged (different canonical key). + const stubEntry = useSpaceStore.getState().userViews.get(canonicalUserKey(stubAxel)); + expect(stubEntry?.user.avatarColor).toBe('lavender'); + // Home entry exists under its own key. + const homeEntry = useSpaceStore.getState().userViews.get(canonicalUserKey(homeAxel)); + expect(homeEntry?.user.avatarColor).toBe('teal'); + }); + + it('two same-canonical-key federated views: home delivery upgrades over sibling stub', () => { + // Same person, same canonical key (homeInstance=nova, homeUserId=nova-axel-id), + // but delivered from two different origins. + const fromOrbit = makeUser({ + id: 'orbit-local', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'lavender', + }); + const fromNova = makeUser({ + id: 'nova-local', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'teal', + }); + + useSpaceStore.getState().upsertUserView(fromOrbit, 'https://orbit.ddns.net'); + useSpaceStore.getState().upsertUserView(fromNova, 'https://nova.ddns.net'); + + const entry = useSpaceStore.getState().userViews.get(canonicalUserKey(fromNova)); + expect(entry?.isHome).toBe(true); + expect(entry?.user.avatarColor).toBe('teal'); + expect(entry?.deliveredBy).toBe('https://nova.ddns.net'); + }); + + it('stub view does NOT overwrite an existing home view', () => { + const fromNova = makeUser({ + id: 'nova-local', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'teal', + }); + const fromOrbit = makeUser({ + id: 'orbit-local', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'lavender', + }); + + useSpaceStore.getState().upsertUserView(fromNova, 'https://nova.ddns.net'); + useSpaceStore.getState().upsertUserView(fromOrbit, 'https://orbit.ddns.net'); + + const entry = useSpaceStore.getState().userViews.get(canonicalUserKey(fromNova)); + expect(entry?.isHome).toBe(true); + expect(entry?.user.avatarColor).toBe('teal'); + expect(entry?.deliveredBy).toBe('https://nova.ddns.net'); + }); + + it('same-tier writes update freshness (later write wins)', () => { + const a = makeUser({ + id: 'orbit-1', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'lavender', + }); + const b = makeUser({ + id: 'orbit-1', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'sky', // simulating a later profile-update event + }); + + useSpaceStore.getState().upsertUserView(a, 'https://orbit.ddns.net'); + useSpaceStore.getState().upsertUserView(b, 'https://orbit.ddns.net'); + + const entry = useSpaceStore.getState().userViews.get(canonicalUserKey(b)); + expect(entry?.user.avatarColor).toBe('sky'); + }); + + it('reset clears userViews', () => { + const user = makeUser({ id: 'local-1', username: 'alice' }); + useSpaceStore.getState().upsertUserView(user, ''); + expect(useSpaceStore.getState().userViews.size).toBe(1); + useSpaceStore.getState().reset(); + expect(useSpaceStore.getState().userViews.size).toBe(0); + }); + + it('removeInstanceSpaces prunes entries delivered by the removed origin only', () => { + const homeView = makeUser({ + id: 'nova-axel-id', + username: 'axel', + avatarColor: 'teal', + }); + const stubView = makeUser({ + id: 'orbit-axel-stub', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'lavender', + }); + + useSpaceStore.getState().upsertUserView(homeView, ''); + useSpaceStore.getState().upsertUserView(stubView, 'https://orbit.ddns.net'); + expect(useSpaceStore.getState().userViews.size).toBe(2); + + // Removing orbit should drop the stub but keep the home view. + useSpaceStore.getState().removeInstanceSpaces('https://orbit.ddns.net'); + const remaining = useSpaceStore.getState().userViews; + expect(remaining.size).toBe(1); + expect(remaining.get(canonicalUserKey(homeView))).toBeDefined(); + expect(remaining.get(canonicalUserKey(stubView))).toBeUndefined(); + }); + + it('removeInstanceSpaces of the home origin evicts entries it delivered', () => { + const homeView = makeUser({ + id: 'nova-axel-id', + username: 'axel', + avatarColor: 'teal', + }); + useSpaceStore.getState().upsertUserView(homeView, ''); + useSpaceStore.getState().removeInstanceSpaces(''); + expect(useSpaceStore.getState().userViews.size).toBe(0); + }); + + it('treats native users delivered by a remote as that remote\'s home view', () => { + // jannis is native to orbit (homeInstance=null on orbit). When orbit + // delivers him, that's the home view. canonicalKey uses orbit-host. + const jannis = makeUser({ + id: 'orbit-jannis-id', + username: 'jannis', + avatarColor: 'sky', + }); + useSpaceStore.getState().upsertUserView(jannis, 'https://orbit.ddns.net'); + // Key is built from user.homeInstance — but jannis has none. So the key is + // ':orbit-jannis-id'. That's correct: when delivered later from a sibling, + // jannis would arrive WITH homeInstance set (synthesized by normalizeUserAssets), + // producing a different (federated) key. The cache holds both, with the + // home view winning on a cross-key collision-free basis. + const entry = useSpaceStore.getState().userViews.get(`:${jannis.id}`); + expect(entry?.isHome).toBe(true); + }); +}); diff --git a/packages/web/src/utils/identity.test.ts b/packages/web/src/utils/identity.test.ts new file mode 100644 index 00000000..f92873ae --- /dev/null +++ b/packages/web/src/utils/identity.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + normalizeOriginToHost, + canonicalUserKey, + isDeliveryFromHome, + isFederationGlobeApplicable, +} from './identity'; + +describe('normalizeOriginToHost', () => { + it('returns empty for falsy inputs', () => { + expect(normalizeOriginToHost('')).toBe(''); + expect(normalizeOriginToHost(null)).toBe(''); + expect(normalizeOriginToHost(undefined)).toBe(''); + }); + + it('extracts host from full URLs', () => { + expect(normalizeOriginToHost('https://nova.ddns.net')).toBe('nova.ddns.net'); + expect(normalizeOriginToHost('http://localhost:3000')).toBe('localhost:3000'); + expect(normalizeOriginToHost('https://orbit.example.com:8443/path')).toBe('orbit.example.com:8443'); + }); + + it('returns bare-domain inputs unchanged', () => { + expect(normalizeOriginToHost('nova.ddns.net')).toBe('nova.ddns.net'); + expect(normalizeOriginToHost('localhost:3000')).toBe('localhost:3000'); + }); + + it('returns empty string for malformed URL inputs (defensive)', () => { + expect(normalizeOriginToHost('https://')).toBe(''); + expect(normalizeOriginToHost('://broken')).toBe(''); + }); +}); + +describe('canonicalUserKey', () => { + it('keys purely-local users by id only (empty host segment)', () => { + expect(canonicalUserKey({ id: '123' })).toBe(':123'); + expect(canonicalUserKey({ id: '123', homeUserId: null, homeInstance: null })).toBe(':123'); + }); + + it('keys federated users by their home host + homeUserId', () => { + expect(canonicalUserKey({ + id: '999', // local id on the receiving instance (irrelevant) + homeUserId: '291641217365663744', + homeInstance: 'nova.ddns.net', + })).toBe('nova.ddns.net:291641217365663744'); + }); + + it('produces the same key for stubs of the same person across instances', () => { + const fromOrbit = canonicalUserKey({ + id: 'orbitLocalId', + homeUserId: 'nova-axel', + homeInstance: 'nova.ddns.net', + }); + const fromAnotherPeer = canonicalUserKey({ + id: 'otherPeerLocalId', + homeUserId: 'nova-axel', + homeInstance: 'nova.ddns.net', + }); + expect(fromOrbit).toBe(fromAnotherPeer); + }); + + it('falls back to local id when homeInstance is set but homeUserId is missing', () => { + expect(canonicalUserKey({ + id: 'localId', + homeInstance: 'nova.ddns.net', + homeUserId: null, + })).toBe('nova.ddns.net:localId'); + }); + + it('local users do not collide with federated keys', () => { + const local = canonicalUserKey({ id: '291641217365663744' }); + const federated = canonicalUserKey({ + id: '999', + homeUserId: '291641217365663744', + homeInstance: 'nova.ddns.net', + }); + expect(local).not.toBe(federated); + }); +}); + +describe('isDeliveryFromHome', () => { + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { host: 'nova.ddns.net' }, + writable: true, + }); + }); + + it('treats native users delivered by our home connection as home view', () => { + expect(isDeliveryFromHome({ homeInstance: null }, '')).toBe(true); + expect(isDeliveryFromHome({ homeInstance: undefined }, '')).toBe(true); + }); + + it('treats native users delivered by a remote connection as home view of that remote', () => { + expect(isDeliveryFromHome({ homeInstance: null }, 'https://orbit.ddns.net')).toBe(true); + }); + + it('marks federated user as home view when delivering origin is their home', () => { + expect(isDeliveryFromHome( + { homeInstance: 'nova.ddns.net' }, + 'https://nova.ddns.net', + )).toBe(true); + }); + + it('marks federated user as home view when our home connection (origin "") IS their home', () => { + // We are at nova; user.homeInstance is nova; delivery from origin '' means our home. + expect(isDeliveryFromHome( + { homeInstance: 'nova.ddns.net' }, + '', + )).toBe(true); + }); + + it('rejects sibling-stub deliveries (orbit delivering Axel whose home is nova)', () => { + expect(isDeliveryFromHome( + { homeInstance: 'nova.ddns.net' }, + 'https://orbit.ddns.net', + )).toBe(false); + }); + + it('rejects our-home delivery of a user whose home is a different instance', () => { + // We are at nova; user.homeInstance is orbit; delivery from '' (our home). + expect(isDeliveryFromHome( + { homeInstance: 'orbit.ddns.net' }, + '', + )).toBe(false); + }); + + it('handles bare-domain homeInstance against full-URL delivering origin', () => { + expect(isDeliveryFromHome( + { homeInstance: 'orbit.ddns.net' }, + 'https://orbit.ddns.net', + )).toBe(true); + }); +}); + +describe('isFederationGlobeApplicable', () => { + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { host: 'nova.ddns.net' }, + writable: true, + }); + }); + + it('returns false for purely-local users (no @domain in username)', () => { + expect(isFederationGlobeApplicable({ username: 'axel' })).toBe(false); + expect(isFederationGlobeApplicable({ username: 'youruser' })).toBe(false); + }); + + it('returns false when the username domain matches our own host (the load-bearing case)', () => { + // Logged in to nova; viewing orbit-stub of Axel whose username is "axel@nova.ddns.net". + expect(isFederationGlobeApplicable({ username: 'axel@nova.ddns.net' })).toBe(false); + }); + + it('returns true for genuinely remote users', () => { + expect(isFederationGlobeApplicable({ username: 'jannis@orbit.ddns.net' })).toBe(true); + }); +}); diff --git a/packages/web/src/utils/identity.ts b/packages/web/src/utils/identity.ts index 2a3f179a..5f44c6b7 100644 --- a/packages/web/src/utils/identity.ts +++ b/packages/web/src/utils/identity.ts @@ -59,6 +59,114 @@ export function resolveDisplayIdentity(user: User, homeUser: User | null): User return user; } +// ─── Cross-instance origin / canonical-identity helpers ───────────────────── +// Used by the userViews cache (spaceStore) and any code that needs to compare +// a user's home instance against a delivering connection's origin. Bare-domain +// `users.home_instance` and full-URL connection origins must always agree +// through these helpers — never via ad-hoc string comparisons. + +/** + * Extract the bare host from a delivering-origin string. + * + * '' → '' (the empty-origin sentinel for "home connection") + * null / undefined → '' + * 'https://nova.ddns.net' → 'nova.ddns.net' + * 'http://localhost:3000' → 'localhost:3000' + * 'nova.ddns.net' → 'nova.ddns.net' + * + * Empty inputs return `''`. Use {@link deliveringHost} when you need the + * concrete host that an origin represents (which substitutes + * `window.location.host` for the empty sentinel). + */ +export function normalizeOriginToHost(input: string | null | undefined): string { + if (!input) return ''; + if (input.includes('://')) { + try { + return new URL(input).host; + } catch { + return ''; + } + } + return input; +} + +/** + * Resolve a delivering origin to its concrete host. Substitutes + * `window.location.host` for the empty-origin sentinel (`''` = our home + * connection). All other inputs are normalized via {@link normalizeOriginToHost}. + */ +function deliveringHost(origin: string): string { + if (origin === '') return typeof window === 'undefined' ? '' : window.location.host; + return normalizeOriginToHost(origin); +} + +/** + * Stable cross-instance cache key for a user. + * + * Federated user: `:` — same key for the same + * person regardless of which instance's local stub we're holding. + * + * Purely local user: `:` (homeInstance and homeUserId are null) — local + * users never collide with federated keys because the host segment is empty. + * + * Defensive fallback: if homeInstance is set but homeUserId is missing + * (legacy stubs from before homeUserId was populated), the local id is used + * as the identifier portion. This is rare and self-corrects when fresh + * profile data arrives. + */ +export function canonicalUserKey( + user: { id: string; homeUserId?: string | null; homeInstance?: string | null }, +): string { + const host = normalizeOriginToHost(user.homeInstance); + const ident = user.homeUserId ?? user.id; + return `${host}:${ident}`; +} + +/** + * True iff the delivering origin is the user's home — i.e. the receiving + * payload contains the authoritative view of this user. + * + * Cases: + * - `user.homeInstance` is null/empty: the user is native to whatever + * instance delivered them. Always a home view. + * - `user.homeInstance` is set: home view iff the delivering host equals + * the user's home host (with `''` resolving to `window.location.host`). + * + * Used as the "isHome" tier in the userViews preference rule. Stub views + * never overwrite home views; home views always upgrade stubs. + */ +export function isDeliveryFromHome( + user: { homeInstance?: string | null }, + deliveringOrigin: string, +): boolean { + const dh = deliveringHost(deliveringOrigin); + const uh = user.homeInstance ? normalizeOriginToHost(user.homeInstance) : dh; + return uh === dh; +} + +/** + * Should the federation-globe indicator render for this user, from the + * current client's perspective? + * + * True iff the user is genuinely remote: their username carries an `@domain` + * suffix AND that domain is NOT our own host. Catches the bug where a stub + * delivered by a sibling instance (e.g. orbit-side `axel@nova.ddns.net` + * viewed from a session logged in to nova) would otherwise show the globe. + * + * Compose with {@link useCanonicalUserView} at render sites: resolve the + * canonical view first, then run this predicate so the answer reflects the + * best-known view of the user, not whichever stub the carrying channel + * happened to land on. + */ +export function isFederationGlobeApplicable( + user: { username: string }, +): boolean { + const { domain } = parseFederatedUsername(user.username); + if (!domain) return false; + if (typeof window === 'undefined') return true; // SSR fallback + return domain !== window.location.host; +} + /** * Federation-safe check: do two user-like objects represent the same person? * Uses cascading strategies to handle missing homeUserId on old replicated users. diff --git a/packages/web/src/utils/mutuals.ts b/packages/web/src/utils/mutuals.ts index ed1c751d..6dc929f3 100644 --- a/packages/web/src/utils/mutuals.ts +++ b/packages/web/src/utils/mutuals.ts @@ -52,6 +52,10 @@ export async function loadFederatedMutuals( }); } + // Lazy import — avoids pulling spaceStore's transitive dependency chain into + // test environments that don't set up AudioWorkletNode. + const { useSpaceStore } = await import('../stores/spaceStore'); + const instances = useInstanceStore.getState().instances; const connectedInstances = instances.filter(i => i.status === 'connected'); @@ -82,6 +86,7 @@ export async function loadFederatedMutuals( seenFriends.add(canonicalId); if (origin) normalizeUserAssets(friend, origin); allFriends.push({ ...friend, _instanceOrigin: origin }); + useSpaceStore.getState().upsertUserView(friend, origin); } // Spaces on different instances are distinct — deduplicate within same origin diff --git a/packages/web/src/utils/userViewLookup.test.ts b/packages/web/src/utils/userViewLookup.test.ts new file mode 100644 index 00000000..f5e1ef4f --- /dev/null +++ b/packages/web/src/utils/userViewLookup.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +vi.mock('../stores/instanceStore', () => ({ + useInstanceStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ instances: [], _autoConnectDone: true }), + { + getState: () => ({ instances: [], _autoConnectDone: true }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +vi.mock('../stores/authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { + getState: () => ({ user: null, token: null }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +import { useSpaceStore } from '../stores/spaceStore'; +import { getCanonicalUserView } from './userViewLookup'; +import type { User } from '@backspace/shared'; + +function makeUser(extras: Partial & Pick): User { + return { + displayName: extras.username, + avatar: '', + avatarColor: 'mint', + homeUserId: null, + homeInstance: null, + status: 'online', + customStatus: null, + bio: null, + banner: null, + isAdmin: false, + isDeleted: false, + discoverable: true, + showActivity: true, + createdAt: 0, + ...extras, + } as User; +} + +beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { host: 'nova.ddns.net' }, + writable: true, + }); + useSpaceStore.getState().reset(); +}); + +describe('getCanonicalUserView', () => { + it('returns the input unchanged on cache miss', () => { + const stub = makeUser({ + id: 'orbit-axel-stub', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'lavender', + }); + expect(getCanonicalUserView(stub)).toBe(stub); + }); + + it('returns the cached entry when one exists for the same canonical key', () => { + const stub = makeUser({ + id: 'orbit-axel-stub', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'lavender', + }); + const homeFromNova = makeUser({ + id: 'nova-local-id', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + avatarColor: 'teal', + }); + useSpaceStore.getState().upsertUserView(homeFromNova, 'https://nova.ddns.net'); + + const resolved = getCanonicalUserView(stub); + expect(resolved).toBe(homeFromNova); + expect(resolved.avatarColor).toBe('teal'); + }); + + it('returns the input on miss even after cache holds different users', () => { + const someOther = makeUser({ + id: 'unrelated', + username: 'unrelated', + avatarColor: 'sky', + }); + useSpaceStore.getState().upsertUserView(someOther, ''); + + const stub = makeUser({ + id: 'orbit-axel-stub', + username: 'axel@nova.ddns.net', + homeUserId: 'nova-axel-id', + homeInstance: 'nova.ddns.net', + }); + expect(getCanonicalUserView(stub)).toBe(stub); + }); +}); diff --git a/packages/web/src/utils/userViewLookup.ts b/packages/web/src/utils/userViewLookup.ts new file mode 100644 index 00000000..e752bcab --- /dev/null +++ b/packages/web/src/utils/userViewLookup.ts @@ -0,0 +1,39 @@ +import type { User } from '@backspace/shared'; +import { useSpaceStore } from '../stores/spaceStore'; +import { canonicalUserKey } from './identity'; + +/** + * Synchronous lookup into the userViews cache. Returns the best-known view of + * the user from any connected origin, or the input unchanged on cache miss. + * + * Use from non-React paths (event handlers, helpers, predicates). React + * render sites should use {@link useCanonicalUserView} so subscriptions tick + * when the cache updates. + * + * Pass User-shaped inputs. Returning the cache entry replaces the input + * reference; callers that depend on extra fields (UI-augmented types) should + * either route the input through this helper before extending it, or call + * with the underlying User and re-augment. + */ +export function getCanonicalUserView(user: User): User { + const key = canonicalUserKey(user); + const entry = useSpaceStore.getState().userViews.get(key); + return entry ? entry.user : user; +} + +/** + * Reactive lookup into the userViews cache. Subscribes to the specific cache + * entry so the calling component re-renders when an upsert lands a better + * view (e.g. nova's home view of Axel arriving after orbit's stub + * populated the cache first). Returns the input unchanged on cache miss; the + * site falls back to the current best information until the cache fills. + * + * Composes with `isSelf` / `resolveDisplayIdentity` rather than replacing + * them — call those for self-detection / self-rendering as before, and pass + * non-self users through this hook for cross-instance view resolution. + */ +export function useCanonicalUserView(user: User): User { + const key = canonicalUserKey(user); + const entry = useSpaceStore((state) => state.userViews.get(key)); + return entry ? entry.user : user; +}