Files
backspace/packages/web/src/components/layout/DmListItem.tsx
T
Jannis Braun 49e9047005 feat(client-federation): user-view cache for cross-instance DM render
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.
2026-05-05 01:59:05 +02:00

227 lines
9.5 KiB
TypeScript

import type { DmChannel, User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { Tooltip } from '../ui/Tooltip';
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 (
<div
className="absolute rounded-full overflow-hidden border-2 border-surface-channel"
style={{
width: 22, height: 22,
left: index * 10,
top: index * 6,
zIndex: 2 - index,
}}
>
<Avatar src={canonical.avatar} name={displayName} size={22} userId={canonical.homeUserId ?? canonical.id} user={canonical} />
</div>
);
}
function isMemberUnreachable(homeInstance: string | null | undefined): boolean {
if (!homeInstance) return false;
const normalized = homeInstance.startsWith('http') ? homeInstance : `https://${homeInstance}`;
return getRejectedPeerOrigins().has(normalized);
}
function isMemberAwaitingApproval(homeInstance: string | null | undefined): boolean {
if (!homeInstance) return false;
const normalized = homeInstance.startsWith('http') ? homeInstance : `https://${homeInstance}`;
return getAwaitingApprovalPeerOrigins().has(normalized);
}
interface DmListItemProps {
dm: DmChannel;
isActive: boolean;
isUnread: boolean;
user: User;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onLeave: (id: string) => void;
onContextMenu?: (e: React.MouseEvent, id: string) => void;
}
export function DmListItem({ dm, isActive, isUnread, user, onSelect, onClose, onLeave, onContextMenu }: DmListItemProps) {
const otherMembers = dm.members.filter(m => !isSelf(m, user));
const isGroup = !!dm.ownerId;
if (otherMembers.length === 0 && !isGroup) return null;
// 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(', ')
: 'Empty Group')
: firstOther?.displayName ?? baseName;
const handleClick = () => onSelect(dm.id);
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
if (isGroup) {
onLeave(dm.id);
} else {
onClose(dm.id);
}
};
const handleContextMenu = onContextMenu
? (e: React.MouseEvent) => onContextMenu(e, dm.id)
: undefined;
// ── State-driven classes ──────────────────────────────────────────────
// Container: 6px radius (up from 4px), 44px height (up from 42px)
const containerClass = `relative flex items-center gap-3 px-2 h-[44px] rounded-[6px] cursor-pointer transition-colors group ${
isActive
? 'bg-interactive-selected text-white'
: isUnread
? 'text-white hover:bg-interactive-hover'
: 'text-txt-tertiary hover:bg-interactive-hover hover:text-txt-secondary'
}`;
// Name: font-semibold for unread (deliberately NOT font-bold — design decision)
const nameClass = `text-[15px] truncate leading-tight ${
isActive ? 'text-white font-medium'
: isUnread ? 'text-white font-semibold'
: 'text-txt-tertiary group-hover:text-txt-secondary font-medium'
}`;
// Timestamp: brightens on hover and lifts for unread/selected
const timestampClass = `text-[11px] ml-auto flex-shrink-0 ${
isActive || isUnread
? 'text-txt-secondary'
: 'text-txt-tertiary group-hover:text-txt-secondary'
}`;
// Preview: brightens on hover and lifts for unread/selected
const previewClass = `text-[12px] truncate leading-tight mt-0.5 ${
isActive || isUnread
? 'text-txt-secondary'
: 'text-txt-tertiary group-hover:text-txt-secondary'
}`;
// Federation badge: brightens with parent
const fedBadgeClass = `flex-shrink-0 ${
isActive || isUnread
? 'text-txt-secondary/60'
: 'text-txt-tertiary/60 group-hover:text-txt-secondary/60'
}`;
// Close button: always visible when selected, hover-reveal otherwise
const closeClass = `${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
} text-txt-tertiary hover:text-txt-primary transition-opacity flex-shrink-0 ml-1`;
// ── Preview text ──────────────────────────────────────────────────────
// formatDmSidebarPreview handles user/system messages and applies the
// sender prefix for group user-messages. We only need to provide the
// empty-group fallback ourselves.
const preview = formatDmSidebarPreview(dm, user);
const previewText = preview ?? (isGroup ? `${dm.members.length} Members` : null);
const itemJsx = (
<div
onClick={handleClick}
className={containerClass}
>
{/* Selected accent bar */}
{isActive && (
<div
className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] bg-white rounded-r-full"
style={{ height: '55%', opacity: 0.7 }}
/>
)}
{/* Unread indicator */}
{isUnread && (
<div className="absolute -left-1 w-1 h-2 bg-white rounded-r-full" />
)}
{/* Avatar */}
{isGroup ? (
<div className="relative w-8 h-8 flex-shrink-0">
{otherMembers.slice(0, 2).map((m, i) => (
<DmGroupAvatarSlot key={m.id} member={m} index={i} />
))}
</div>
) : (
<Avatar src={firstOther?.avatar} name={firstOther?.displayName ?? parseFederatedUsername(firstOther?.username ?? '').baseName} size={32} status={firstOther?.status as any} userId={firstOther?.homeUserId ?? firstOther?.id} user={firstOther ?? undefined} />
)}
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 min-w-0">
<span className={nameClass}>
{displayName}
</span>
{!isGroup && firstOther && isFederationGlobeApplicable(firstOther) && (
<Tooltip content={firstOther.username} position="top">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className={fedBadgeClass}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
</Tooltip>
)}
{firstOther && isMemberUnreachable(firstOther.homeInstance) && (
<Tooltip content="Cannot relay messages — their server denied peering. Contact their admin." position="top">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="text-accent-rose opacity-70 flex-shrink-0">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
</svg>
</Tooltip>
)}
{firstOther && !isMemberUnreachable(firstOther.homeInstance) && isMemberAwaitingApproval(firstOther.homeInstance) && (
<Tooltip content="Messages will be delivered once their admin approves the peering request." position="top">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber opacity-70 flex-shrink-0">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
</Tooltip>
)}
{dm.lastMessage && (
<span className={timestampClass}>
{formatDmTimestamp(dm.lastMessage.createdAt)}
</span>
)}
</div>
{previewText && (
<div className={previewClass}>
{previewText}
</div>
)}
</div>
{/* Close / Leave button */}
<button
onClick={handleClose}
className={closeClass}
title={isGroup ? 'Leave Group DM' : 'Close DM'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
);
// Group DMs get a context menu wrapper
if (isGroup && handleContextMenu) {
return <div onContextMenu={handleContextMenu}>{itemJsx}</div>;
}
return itemJsx;
}