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.
This commit is contained in:
@@ -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<canonicalUserKey, UserViewEntry>` on `spaceStore`. Each entry is `{ user: User, deliveredBy: string, isHome: boolean, updatedAt: number }`.
|
||||
- `canonicalUserKey(user)` (in `utils/identity.ts`) returns `<homeInstanceHost>:<homeUserId>` for federated users and `:<id>` 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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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<string, string> = { 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 (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (mobile) {
|
||||
onMobileClick(friend.id);
|
||||
} else {
|
||||
onDmClick(friend.id, friend.homeUserId ?? undefined, friend.homeInstance);
|
||||
}
|
||||
}}
|
||||
className={rowClass}
|
||||
>
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={friendDisplayName}
|
||||
size={36}
|
||||
status={isOffline ? 'offline' : canonical.status}
|
||||
className={isOffline ? 'opacity-60' : undefined}
|
||||
userId={canonical.homeUserId ?? canonical.id}
|
||||
avatarColor={canonical.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Username
|
||||
username={friendDisplayName}
|
||||
className={`text-sm leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : 'text-txt-primary'}`}
|
||||
/>
|
||||
{!isOffline && isFederationGlobeApplicable(canonical) && (
|
||||
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{parseFederatedUsername(canonical.username).domain}</div>
|
||||
)}
|
||||
{!isOffline && (
|
||||
<ActivityCard
|
||||
activities={activities}
|
||||
fallbackCustomStatus={canonical.customStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
<ActivityFriendItem
|
||||
key={friend.id}
|
||||
onClick={() => {
|
||||
if (mobile) {
|
||||
pushMobileScreen('user-profile', { userId: friend.id });
|
||||
} else {
|
||||
handleOpenDm(friend.id, friend.homeUserId ?? undefined, friend.homeInstance);
|
||||
}
|
||||
}}
|
||||
className={rowClass}
|
||||
>
|
||||
<Avatar
|
||||
src={friend.avatar}
|
||||
name={friendDisplayName}
|
||||
size={36}
|
||||
status={isOffline ? 'offline' : friend.status}
|
||||
className={isOffline ? 'opacity-60' : undefined}
|
||||
userId={friend.homeUserId ?? friend.id}
|
||||
avatarColor={friend.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Username
|
||||
username={friendDisplayName}
|
||||
className={`text-sm leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : 'text-txt-primary'}`}
|
||||
/>
|
||||
{domain && !isOffline && (
|
||||
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{domain}</div>
|
||||
)}
|
||||
{!isOffline && (
|
||||
<ActivityCard
|
||||
activities={activities}
|
||||
fallbackCustomStatus={friend.customStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
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 (
|
||||
<div className="flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={friend.avatar} name={friendDisplayName} size={32} status={friend.status} userId={friend.homeUserId ?? friend.id} avatarColor={friend.avatarColor} />
|
||||
<Avatar src={canonical.avatar} name={friendDisplayName} size={32} status={canonical.status} userId={canonical.homeUserId ?? canonical.id} avatarColor={canonical.avatarColor} />
|
||||
<div className="flex flex-col leading-tight">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-txt-primary font-semibold text-[15px]">{friendDisplayName}</span>
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<HTMLDivElement>;
|
||||
onSelect: (member: MemberWithUser) => void;
|
||||
roleColor: string | undefined;
|
||||
}) {
|
||||
const canonical = useCanonicalUserView(member.user);
|
||||
const displayName = canonical.displayName ?? canonical.username;
|
||||
return (
|
||||
<div
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={displayName}
|
||||
size={24}
|
||||
status={canonical.status}
|
||||
userId={canonical.homeUserId ?? canonical.id}
|
||||
user={canonical}
|
||||
/>
|
||||
<span
|
||||
className="text-[14px] font-medium truncate"
|
||||
style={roleColor ? { color: roleColor } : undefined}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
{canonical.displayName && (
|
||||
<span className="text-[12px] text-txt-tertiary truncate">
|
||||
@{canonical.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MentionPopoverProps {
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
@@ -62,42 +109,16 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me
|
||||
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
|
||||
Members
|
||||
</div>
|
||||
{filtered.map((member, i) => {
|
||||
const displayName = member.user.displayName ?? member.user.username;
|
||||
const roleColor = getMemberColor(member);
|
||||
const isSelected = i === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.userId}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={member.user.avatar}
|
||||
name={displayName}
|
||||
size={24}
|
||||
status={member.user.status}
|
||||
userId={member.user.homeUserId ?? member.user.id}
|
||||
user={member.user}
|
||||
/>
|
||||
<span
|
||||
className="text-[14px] font-medium truncate"
|
||||
style={roleColor ? { color: roleColor } : undefined}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
{member.user.displayName && (
|
||||
<span className="text-[12px] text-txt-tertiary truncate">
|
||||
@{member.user.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.map((member, i) => (
|
||||
<MentionMemberRow
|
||||
key={member.userId}
|
||||
member={member}
|
||||
isSelected={i === selectedIndex}
|
||||
selectedRef={selectedRef}
|
||||
onSelect={onSelect}
|
||||
roleColor={getMemberColor(member)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -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 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{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 (
|
||||
<div className="flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply">
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => onJumpToMessage(msg.id)}
|
||||
className="w-full px-3 py-2.5 hover:bg-interactive-hover transition-colors text-left flex items-start gap-2.5 group/result"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<Avatar
|
||||
src={avatar}
|
||||
name={displayName}
|
||||
size={28}
|
||||
user={msg.user ? canonical : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[13px] font-semibold text-txt-primary truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-[10px] text-txt-tertiary flex-shrink-0">
|
||||
{formatTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-txt-secondary leading-[1.4] line-clamp-2 mt-0.5">
|
||||
{highlightMatch(msg.content ?? '', query)}
|
||||
</div>
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1 text-[11px] text-txt-tertiary">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H9v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S6 2.79 6 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
|
||||
</svg>
|
||||
{msg.attachments.length} attachment{msg.attachments.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJumpToMessage }: SearchPopoverProps) {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -277,41 +330,12 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
||||
{totalCount} result{totalCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{results.map((msg) => (
|
||||
<button
|
||||
<SearchResultRow
|
||||
key={msg.id}
|
||||
onClick={() => onJumpToMessage(msg.id)}
|
||||
className="w-full px-3 py-2.5 hover:bg-interactive-hover transition-colors text-left flex items-start gap-2.5 group/result"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<Avatar
|
||||
src={msg.user?.avatar}
|
||||
name={msg.user?.displayName ?? msg.user?.username ?? '?'}
|
||||
size={28}
|
||||
user={msg.user ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[13px] font-semibold text-txt-primary truncate">
|
||||
{msg.user?.displayName ?? msg.user?.username ?? 'Unknown'}
|
||||
</span>
|
||||
<span className="text-[10px] text-txt-tertiary flex-shrink-0">
|
||||
{formatTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-txt-secondary leading-[1.4] line-clamp-2 mt-0.5">
|
||||
{highlightMatch(msg.content ?? '', query)}
|
||||
</div>
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1 text-[11px] text-txt-tertiary">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H9v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S6 2.79 6 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
|
||||
</svg>
|
||||
{msg.attachments.length} attachment{msg.attachments.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
msg={msg}
|
||||
query={query}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
/>
|
||||
))}
|
||||
{results.length < totalCount && (
|
||||
<div className="px-3 py-2 border-t border-white/[0.07]">
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
onClick={(e) => onClickFriend(e, friend)}
|
||||
className={rowClass}
|
||||
>
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={friendDisplayName}
|
||||
size={32}
|
||||
status={isOffline ? 'offline' : canonical.status}
|
||||
className={isOffline ? 'opacity-60' : undefined}
|
||||
userId={canonical.homeUserId ?? canonical.id}
|
||||
avatarColor={canonical.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Username
|
||||
username={friendDisplayName}
|
||||
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : 'text-txt-primary'}`}
|
||||
/>
|
||||
{!isOffline && isFederationGlobeApplicable(canonical) && (
|
||||
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{parseFederatedUsername(canonical.username).domain}</div>
|
||||
)}
|
||||
{!isOffline && (
|
||||
<ActivityCard
|
||||
activities={activities}
|
||||
fallbackCustomStatus={canonical.customStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
<ActivityFriendRow
|
||||
key={friend.id}
|
||||
onClick={(e) => handleFriendClick(e, friend)}
|
||||
className={rowClass}
|
||||
>
|
||||
<Avatar
|
||||
src={friend.avatar}
|
||||
name={friendDisplayName}
|
||||
size={32}
|
||||
status={isOffline ? 'offline' : friend.status}
|
||||
className={isOffline ? 'opacity-60' : undefined}
|
||||
userId={friend.homeUserId ?? friend.id}
|
||||
avatarColor={friend.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Username
|
||||
username={friendDisplayName}
|
||||
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : 'text-txt-primary'}`}
|
||||
/>
|
||||
{domain && !isOffline && (
|
||||
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{domain}</div>
|
||||
)}
|
||||
{!isOffline && (
|
||||
<ActivityCard
|
||||
activities={activities}
|
||||
fallbackCustomStatus={friend.customStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
friend={friend}
|
||||
isOffline={isOffline}
|
||||
activities={activities}
|
||||
isRichActivity={isRichActivity}
|
||||
accentClass={accentClass}
|
||||
onClickFriend={handleFriendClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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}`;
|
||||
@@ -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 ? (
|
||||
<div className="relative w-8 h-8 flex-shrink-0">
|
||||
{otherMembers.slice(0, 2).map((m, i) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="absolute rounded-full overflow-hidden border-2 border-surface-channel"
|
||||
style={{
|
||||
width: 22, height: 22,
|
||||
left: i * 10,
|
||||
top: i * 6,
|
||||
zIndex: 2 - i,
|
||||
}}
|
||||
>
|
||||
<Avatar src={m.avatar} name={m.displayName ?? parseFederatedUsername(m.username).baseName} size={22} userId={m.homeUserId ?? m.id} user={m} />
|
||||
</div>
|
||||
<DmGroupAvatarSlot key={m.id} member={m} index={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Avatar src={otherMembers[0]?.avatar} name={otherMembers[0]?.displayName ?? parseFederatedUsername(otherMembers[0]?.username ?? '').baseName} size={32} status={otherMembers[0]?.status as any} userId={otherMembers[0]?.homeUserId ?? otherMembers[0]?.id} user={otherMembers[0]} />
|
||||
<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 */}
|
||||
@@ -150,8 +170,8 @@ export function DmListItem({ dm, isActive, isUnread, user, onSelect, onClose, on
|
||||
<span className={nameClass}>
|
||||
{displayName}
|
||||
</span>
|
||||
{!isGroup && domain && (
|
||||
<Tooltip content={firstOther?.username ?? ''} position="top">
|
||||
{!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>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="absolute rounded-full overflow-hidden border-[1.5px] border-surface-channel"
|
||||
style={{
|
||||
width: 18, height: 18,
|
||||
left: index * 8,
|
||||
top: index * 4,
|
||||
zIndex: 2 - index,
|
||||
}}
|
||||
>
|
||||
<Avatar src={canonical.avatar} name={displayName} size={18} userId={canonical.homeUserId ?? canonical.id} user={canonical} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLDivElement>;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const canonical = useCanonicalUserView(user);
|
||||
const displayName = canonical.displayName ?? canonical.username;
|
||||
return (
|
||||
<div
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
onClick={onClick}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={displayName}
|
||||
size={24}
|
||||
status={canonical.status as 'online' | 'idle' | 'dnd' | 'offline' | undefined}
|
||||
userId={canonical.homeUserId ?? canonical.id}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span className="text-[14px] text-txt-primary truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
{canonical.displayName && (
|
||||
<span className="text-[12px] text-txt-tertiary truncate">
|
||||
@{canonical.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLDivElement>;
|
||||
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 (
|
||||
<div
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
onClick={onClick}
|
||||
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 ? (
|
||||
<div className="relative w-6 h-6 flex-shrink-0">
|
||||
{item.otherMembers.slice(0, 2).map((m, idx) => (
|
||||
<DmSearchGroupAvatarSlot key={m.id} member={m} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
src={partner?.avatar}
|
||||
name={partner?.displayName ?? parseFederatedUsername(partner?.username ?? '').baseName}
|
||||
size={24}
|
||||
status={partner?.status as 'online' | 'idle' | 'dnd' | 'offline' | undefined}
|
||||
userId={partner?.homeUserId ?? partner?.id}
|
||||
user={partner ?? undefined}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[14px] text-txt-primary truncate">{item.displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_RECENT = 8;
|
||||
const SEARCH_DEBOUNCE = 300;
|
||||
|
||||
@@ -265,43 +380,13 @@ export function DmSearchBar() {
|
||||
const globalIndex = i;
|
||||
const isSelected = globalIndex === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
<DmSearchDmRow
|
||||
key={item.dm.id}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
item={item}
|
||||
isSelected={isSelected}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
onClick={() => 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 ? (
|
||||
<div className="relative w-6 h-6 flex-shrink-0">
|
||||
{item.otherMembers.slice(0, 2).map((m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="absolute rounded-full overflow-hidden border-[1.5px] border-surface-channel"
|
||||
style={{
|
||||
width: 18, height: 18,
|
||||
left: idx * 8,
|
||||
top: idx * 4,
|
||||
zIndex: 2 - idx,
|
||||
}}
|
||||
>
|
||||
<Avatar src={m.avatar} name={m.displayName ?? parseFederatedUsername(m.username).baseName} size={18} userId={m.homeUserId ?? m.id} user={m} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
src={item.otherMembers[0]?.avatar}
|
||||
name={item.otherMembers[0]?.displayName ?? parseFederatedUsername(item.otherMembers[0]?.username ?? '').baseName}
|
||||
size={24}
|
||||
status={item.otherMembers[0]?.status as 'online' | 'idle' | 'dnd' | 'offline' | undefined}
|
||||
userId={item.otherMembers[0]?.homeUserId ?? item.otherMembers[0]?.id}
|
||||
user={item.otherMembers[0]}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[14px] text-txt-primary truncate">{item.displayName}</span>
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -320,32 +405,13 @@ export function DmSearchBar() {
|
||||
const globalIndex = dmItems.length + i;
|
||||
const isSelected = globalIndex === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
<DmSearchUserRow
|
||||
key={item.user.id}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
user={item.user}
|
||||
isSelected={isSelected}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={item.user.avatar}
|
||||
name={item.user.displayName ?? item.user.username}
|
||||
size={24}
|
||||
status={item.user.status as 'online' | 'idle' | 'dnd' | 'offline' | undefined}
|
||||
userId={item.user.homeUserId ?? item.user.id}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span className="text-[14px] text-txt-primary truncate">
|
||||
{item.user.displayName ?? item.user.username}
|
||||
</span>
|
||||
{item.user.displayName && (
|
||||
<span className="text-[12px] text-txt-tertiary truncate">
|
||||
@{item.user.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
<path d="M12.5 2A6.5 6.5 0 0 0 6 8.5c0 1.82.75 3.47 1.95 4.65A10.02 10.02 0 0 0 2 22h2c0-4.42 3.58-8 8-8 .35 0 .69.03 1.03.07A6.49 6.49 0 0 0 19 8.5 6.5 6.5 0 0 0 12.5 2Zm0 11A4.5 4.5 0 1 1 17 8.5a4.5 4.5 0 0 1-4.5 4.5Z" />
|
||||
</svg>
|
||||
<span className="font-bold text-[15px] tracking-[-0.02em] text-txt-primary truncate">{dmName}</span>
|
||||
{!isGroupDm && firstDomain && (
|
||||
<Tooltip content={firstOther?.username ?? ''} position="bottom">
|
||||
{!isGroupDm && firstOther && isFederationGlobeApplicable(firstOther) && (
|
||||
<Tooltip content={firstOther.username} position="bottom">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary/80 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 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>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={member.userId}
|
||||
onClick={(e) => onClickMember(e, canonical)}
|
||||
className={rowClass}
|
||||
>
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={displayName}
|
||||
size={32}
|
||||
status={isOffline ? 'offline' : canonical.status}
|
||||
className={isOffline ? 'opacity-60' : undefined}
|
||||
user={canonical}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Username
|
||||
username={displayName}
|
||||
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : (!colorStyle ? 'text-txt-primary' : '')}`}
|
||||
style={colorStyle}
|
||||
/>
|
||||
{!isOffline && isFederationGlobeApplicable(canonical) && (
|
||||
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{parseFederatedUsername(canonical.username).domain}</div>
|
||||
)}
|
||||
{!isOffline && (
|
||||
<ActivityCard
|
||||
activities={activities}
|
||||
fallbackCustomStatus={canonical.customStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
<MemberSidebarRow
|
||||
key={member.userId}
|
||||
onClick={(e) => handleMemberClick(e, member.user)}
|
||||
className={rowClass}
|
||||
>
|
||||
<Avatar
|
||||
src={member.user.avatar}
|
||||
name={displayName}
|
||||
size={32}
|
||||
status={isOffline ? 'offline' : member.user.status}
|
||||
className={isOffline ? 'opacity-60' : undefined}
|
||||
user={member.user}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Username
|
||||
username={displayName}
|
||||
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : (!colorStyle ? 'text-txt-primary' : '')}`}
|
||||
style={colorStyle}
|
||||
/>
|
||||
{domain && !isOffline && (
|
||||
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{domain}</div>
|
||||
)}
|
||||
{!isOffline && (
|
||||
<ActivityCard
|
||||
activities={activities}
|
||||
fallbackCustomStatus={member.user.customStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
member={member}
|
||||
isOffline={isOffline}
|
||||
colorStyle={colorStyle}
|
||||
activities={activities}
|
||||
isRichActivity={isRichActivity}
|
||||
accentClass={accentClass}
|
||||
onClickMember={handleMemberClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => {
|
||||
const existingDm = dmChannels.find(dm =>
|
||||
!dm.ownerId && dm.members.some(m => m.id === friend.id)
|
||||
);
|
||||
if (existingDm) {
|
||||
onTap(existingDm.id);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center gap-1 shrink-0 w-14"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={displayName}
|
||||
avatarColor={canonical.avatarColor}
|
||||
size={40}
|
||||
/>
|
||||
<span className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-surface-base ${
|
||||
friend.status === 'online' ? 'bg-status-online' :
|
||||
friend.status === 'idle' ? 'bg-status-idle' :
|
||||
'bg-status-dnd'
|
||||
}`} />
|
||||
</div>
|
||||
<span className="text-[10px] text-txt-secondary truncate w-full text-center">
|
||||
{displayName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDmRow({
|
||||
dm,
|
||||
authUser,
|
||||
readStates,
|
||||
onTap,
|
||||
onContextMenu,
|
||||
formatTimestamp,
|
||||
}: {
|
||||
dm: DmChannel;
|
||||
authUser: User | null;
|
||||
readStates: Map<string, string>;
|
||||
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 (
|
||||
<button
|
||||
key={dm.id}
|
||||
data-context-menu
|
||||
onClick={() => onTap(dm.id)}
|
||||
onContextMenu={(e) => onContextMenu(e, dm.id, isGroup)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-interactive-hover text-left transition-colors"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
{isGroup ? (
|
||||
<div className="w-10 h-10 rounded-full bg-surface-elevated flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-txt-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={name}
|
||||
avatarColor={mainUser?.avatarColor}
|
||||
size={40}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`text-sm truncate ${isUnread ? 'font-semibold text-txt-primary' : 'text-txt-primary'}`}>
|
||||
{name}
|
||||
</span>
|
||||
{previewTime && (
|
||||
<span className="text-[11px] text-txt-tertiary shrink-0">
|
||||
{formatTimestamp(previewTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isGroup && mainUser && isFederationGlobeApplicable(mainUser) && (
|
||||
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">
|
||||
@{parseFederatedUsername(mainUser.username).domain}
|
||||
</div>
|
||||
)}
|
||||
{preview && (
|
||||
<p className={`text-xs truncate mt-0.5 ${isUnread ? 'text-txt-secondary font-medium' : 'text-txt-tertiary'}`}>
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isUnread && <span className="w-2.5 h-2.5 rounded-full bg-accent-primary shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileDmsScreen() {
|
||||
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||
@@ -98,122 +237,31 @@ export function MobileDmsScreen() {
|
||||
{onlineFriends.length > 0 && (
|
||||
<div className="px-4 py-3 border-b border-border-soft">
|
||||
<div className="flex gap-3 overflow-x-auto no-scrollbar">
|
||||
{onlineFriends.map(friend => {
|
||||
const avatarUrl = friend.avatar
|
||||
? resolveAssetUrl(friend.avatar, friend._instanceOrigin) ?? `/api/uploads/${friend.avatar}`
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={friend.id}
|
||||
onClick={() => {
|
||||
// Find or create DM with this friend
|
||||
const existingDm = dmChannels.find(dm =>
|
||||
!dm.ownerId && dm.members.some(m => m.id === friend.id)
|
||||
);
|
||||
if (existingDm) {
|
||||
handleDmTap(existingDm.id);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center gap-1 shrink-0 w-14"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={friend.displayName ?? parseFederatedUsername(friend.username).baseName}
|
||||
avatarColor={friend.avatarColor}
|
||||
size={40}
|
||||
/>
|
||||
<span className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-surface-base ${
|
||||
friend.status === 'online' ? 'bg-status-online' :
|
||||
friend.status === 'idle' ? 'bg-status-idle' :
|
||||
'bg-status-dnd'
|
||||
}`} />
|
||||
</div>
|
||||
<span className="text-[10px] text-txt-secondary truncate w-full text-center">
|
||||
{friend.displayName ?? parseFederatedUsername(friend.username).baseName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{onlineFriends.map(friend => (
|
||||
<MobileFriendBubble
|
||||
key={friend.id}
|
||||
friend={friend}
|
||||
dmChannels={dmChannels}
|
||||
onTap={handleDmTap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DM list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{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 (
|
||||
<button
|
||||
key={dm.id}
|
||||
data-context-menu
|
||||
onClick={() => handleDmTap(dm.id)}
|
||||
onContextMenu={(e) => handleDmContextMenu(e, dm.id, isGroup)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-interactive-hover text-left transition-colors"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
{isGroup ? (
|
||||
<div className="w-10 h-10 rounded-full bg-surface-elevated flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-txt-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={name}
|
||||
avatarColor={mainUser?.avatarColor}
|
||||
size={40}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`text-sm truncate ${isUnread ? 'font-semibold text-txt-primary' : 'text-txt-primary'}`}>
|
||||
{name}
|
||||
</span>
|
||||
{previewTime && (
|
||||
<span className="text-[11px] text-txt-tertiary shrink-0">
|
||||
{formatTimestamp(previewTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const mainUser = otherMembers[0];
|
||||
if (!mainUser) return null;
|
||||
const { domain } = parseFederatedUsername(mainUser.username);
|
||||
if (!domain) return null;
|
||||
return <div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{domain}</div>;
|
||||
})()}
|
||||
{preview && (
|
||||
<p className={`text-xs truncate mt-0.5 ${isUnread ? 'text-txt-secondary font-medium' : 'text-txt-tertiary'}`}>
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isUnread && <span className="w-2.5 h-2.5 rounded-full bg-accent-primary shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{sortedDms.map(dm => (
|
||||
<MobileDmRow
|
||||
key={dm.id}
|
||||
dm={dm}
|
||||
authUser={authUser ?? null}
|
||||
readStates={readStates}
|
||||
onTap={handleDmTap}
|
||||
onContextMenu={handleDmContextMenu}
|
||||
formatTimestamp={formatTimestamp}
|
||||
/>
|
||||
))}
|
||||
|
||||
{sortedDms.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-40 opacity-80">
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
onClick={() => onClickMember(member.userId)}
|
||||
className={rowClass}
|
||||
>
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={displayName}
|
||||
size={36}
|
||||
status={isOffline ? 'offline' : canonical.status}
|
||||
className={isOffline ? 'opacity-60' : undefined}
|
||||
user={canonical}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Username
|
||||
username={displayName}
|
||||
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : (!colorStyle ? 'text-txt-primary' : '')}`}
|
||||
style={colorStyle}
|
||||
/>
|
||||
{!isOffline && isFederationGlobeApplicable(canonical) && (
|
||||
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{parseFederatedUsername(canonical.username).domain}</div>
|
||||
)}
|
||||
{!isOffline && (
|
||||
<ActivityCard
|
||||
activities={activities}
|
||||
fallbackCustomStatus={canonical.customStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MobileMembersScreenProps {
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
<MobileMemberRow
|
||||
key={member.userId}
|
||||
onClick={() => handleMemberClick(member.userId)}
|
||||
className={rowClass}
|
||||
>
|
||||
<Avatar
|
||||
src={member.user.avatar}
|
||||
name={displayName}
|
||||
size={36}
|
||||
status={isOffline ? 'offline' : member.user.status}
|
||||
className={isOffline ? 'opacity-60' : undefined}
|
||||
user={member.user}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Username
|
||||
username={displayName}
|
||||
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : (!colorStyle ? 'text-txt-primary' : '')}`}
|
||||
style={colorStyle}
|
||||
/>
|
||||
{domain && !isOffline && (
|
||||
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{domain}</div>
|
||||
)}
|
||||
{!isOffline && (
|
||||
<ActivityCard
|
||||
activities={activities}
|
||||
fallbackCustomStatus={member.user.customStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
member={member}
|
||||
isOffline={isOffline}
|
||||
colorStyle={colorStyle}
|
||||
activities={activities}
|
||||
isRichActivity={isRichActivity}
|
||||
accentClass={accentClass}
|
||||
onClickMember={handleMemberClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => onToggle(friend.id)}
|
||||
disabled={isInDm || isAdding || atCapacity}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-[4px] transition-colors text-left ${
|
||||
isInDm
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-accent-mint/[0.08]'
|
||||
: 'hover:bg-interactive-hover'
|
||||
} ${atCapacity && !isInDm ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={friendDisplayName}
|
||||
size={30}
|
||||
status={canonical.status as any}
|
||||
userId={canonical.homeUserId ?? canonical.id}
|
||||
avatarColor={canonical.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-txt-primary truncate">
|
||||
{friendDisplayName}
|
||||
</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
{isInDm ? 'Already in this DM' : `@${canonical.username}`}
|
||||
</div>
|
||||
</div>
|
||||
{!isInDm && (
|
||||
<div
|
||||
className={`w-[18px] h-[18px] rounded flex-shrink-0 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-accent-mint'
|
||||
: 'border-2 border-border-hard'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="text-surface-base">
|
||||
<path d="M2.5 6L5 8.5L9.5 3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
<AddDmFriendRow
|
||||
key={friend.id}
|
||||
onClick={() => toggleFriend(friend.id)}
|
||||
disabled={isInDm || isAdding || atCapacity}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-[4px] transition-colors text-left ${
|
||||
isInDm
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-accent-mint/[0.08]'
|
||||
: 'hover:bg-interactive-hover'
|
||||
} ${atCapacity && !isInDm ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<Avatar
|
||||
src={friend.avatar}
|
||||
name={friendDisplayName}
|
||||
size={30}
|
||||
status={friend.status as any}
|
||||
userId={friend.homeUserId ?? friend.id}
|
||||
avatarColor={friend.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-txt-primary truncate">
|
||||
{friendDisplayName}
|
||||
</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
{isInDm ? 'Already in this DM' : `@${friend.username}`}
|
||||
</div>
|
||||
</div>
|
||||
{!isInDm && (
|
||||
<div
|
||||
className={`w-[18px] h-[18px] rounded flex-shrink-0 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-accent-mint'
|
||||
: 'border-2 border-border-hard'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="text-surface-base">
|
||||
<path d="M2.5 6L5 8.5L9.5 3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
friend={friend}
|
||||
isInDm={isInDm}
|
||||
isSelected={isSelected}
|
||||
atCapacity={atCapacity}
|
||||
isAdding={isAdding}
|
||||
onToggle={toggleFriend}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-[4px]">
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={dn}
|
||||
size={30}
|
||||
userId={canonical.homeUserId ?? canonical.id}
|
||||
avatarColor={canonical.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-txt-primary truncate">{dn}</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">@{canonical.username}</div>
|
||||
</div>
|
||||
{status?.kind === 'success' && (
|
||||
<span className="text-[12px] text-accent-mint flex-shrink-0">✓ Sent</span>
|
||||
)}
|
||||
{status?.kind === 'failure' && (
|
||||
<span className="text-[12px] text-txt-danger flex-shrink-0">✗ {status.reason}</span>
|
||||
)}
|
||||
{status?.kind === 'pending' && (
|
||||
<span className="text-[12px] text-txt-tertiary flex-shrink-0">...</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
onClick={() => onToggle(friend.id, friend)}
|
||||
disabled={alreadyMember || sending}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-[4px] transition-colors text-left ${
|
||||
alreadyMember
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-accent-mint/[0.08]'
|
||||
: 'hover:bg-interactive-hover'
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={dn}
|
||||
size={30}
|
||||
status={canonical.status as any}
|
||||
userId={canonical.homeUserId ?? canonical.id}
|
||||
avatarColor={canonical.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-txt-primary truncate">{dn}</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
{alreadyMember ? 'Already in space' : `@${canonical.username}`}
|
||||
</div>
|
||||
</div>
|
||||
{!alreadyMember && (
|
||||
<div
|
||||
className={`w-[18px] h-[18px] rounded flex-shrink-0 flex items-center justify-center ${
|
||||
isSelected ? 'bg-accent-mint' : 'border-2 border-border-hard'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="text-surface-base">
|
||||
<path d="M2.5 6L5 8.5L9.5 3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className="max-h-[280px] overflow-y-auto space-y-[2px]">
|
||||
{inResultsView ? (
|
||||
selectedFriends.map((f) => {
|
||||
const status = results.get(f.id);
|
||||
const { baseName } = parseFederatedUsername(f.username);
|
||||
const dn = f.displayName ?? baseName;
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-[4px]"
|
||||
>
|
||||
<Avatar
|
||||
src={f.avatar}
|
||||
name={dn}
|
||||
size={30}
|
||||
userId={f.homeUserId ?? f.id}
|
||||
avatarColor={f.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-txt-primary truncate">
|
||||
{dn}
|
||||
</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
@{f.username}
|
||||
</div>
|
||||
</div>
|
||||
{status?.kind === 'success' && (
|
||||
<span className="text-[12px] text-accent-mint flex-shrink-0">
|
||||
✓ Sent
|
||||
</span>
|
||||
)}
|
||||
{status?.kind === 'failure' && (
|
||||
<span className="text-[12px] text-txt-danger flex-shrink-0">
|
||||
✗ {status.reason}
|
||||
</span>
|
||||
)}
|
||||
{status?.kind === 'pending' && (
|
||||
<span className="text-[12px] text-txt-tertiary flex-shrink-0">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
selectedFriends.map((f) => (
|
||||
<InviteResultFriendRow
|
||||
key={f.id}
|
||||
friend={f}
|
||||
status={results.get(f.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{filteredFriends.length === 0 && (
|
||||
@@ -329,72 +390,16 @@ export function InviteModal() {
|
||||
: 'No friends yet'}
|
||||
</div>
|
||||
)}
|
||||
{filteredFriends.map((friend) => {
|
||||
const alreadyMember = isFriendAlreadyMember(friend);
|
||||
const isSelected = selected.has(friend.id);
|
||||
const { baseName } = parseFederatedUsername(friend.username);
|
||||
const dn = friend.displayName ?? baseName;
|
||||
return (
|
||||
<button
|
||||
key={friend.id}
|
||||
onClick={() => toggleFriend(friend.id, friend)}
|
||||
disabled={alreadyMember || sending}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-[4px] transition-colors text-left ${
|
||||
alreadyMember
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-accent-mint/[0.08]'
|
||||
: 'hover:bg-interactive-hover'
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={friend.avatar}
|
||||
name={dn}
|
||||
size={30}
|
||||
status={friend.status as any}
|
||||
userId={friend.homeUserId ?? friend.id}
|
||||
avatarColor={friend.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-txt-primary truncate">
|
||||
{dn}
|
||||
</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
{alreadyMember
|
||||
? 'Already in space'
|
||||
: `@${friend.username}`}
|
||||
</div>
|
||||
</div>
|
||||
{!alreadyMember && (
|
||||
<div
|
||||
className={`w-[18px] h-[18px] rounded flex-shrink-0 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-accent-mint'
|
||||
: 'border-2 border-border-hard'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className="text-surface-base"
|
||||
>
|
||||
<path
|
||||
d="M2.5 6L5 8.5L9.5 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredFriends.map((friend) => (
|
||||
<InviteSelectFriendRow
|
||||
key={friend.id}
|
||||
friend={friend}
|
||||
isSelected={selected.has(friend.id)}
|
||||
alreadyMember={isFriendAlreadyMember(friend)}
|
||||
sending={sending}
|
||||
onToggle={toggleFriend}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => onSelect(user)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-interactive-hover transition-colors text-left"
|
||||
>
|
||||
<Avatar src={canonical.avatar} name={displayName} size={36} status={canonical.status as any} userId={canonical.homeUserId ?? canonical.id} avatarColor={canonical.avatarColor} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-medium text-txt-primary truncate">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-[12px] text-txt-tertiary truncate">@{canonical.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewDmModal() {
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -106,25 +133,13 @@ export function NewDmModal() {
|
||||
<div className="py-4 text-center text-txt-tertiary text-[14px]">No users found</div>
|
||||
)}
|
||||
|
||||
{results.map((user) => {
|
||||
const { baseName } = parseFederatedUsername(user.username);
|
||||
const displayName = user.displayName ?? baseName;
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => handleSelectUser(user)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-interactive-hover transition-colors text-left"
|
||||
>
|
||||
<Avatar src={user.avatar} name={displayName} size={36} status={user.status as any} userId={user.homeUserId ?? user.id} avatarColor={user.avatarColor} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-medium text-txt-primary truncate">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-[12px] text-txt-tertiary truncate">@{user.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{results.map((user) => (
|
||||
<NewDmUserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
onSelect={handleSelectUser}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => onSelect(member.userId)}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded-md hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={displayName}
|
||||
size={32}
|
||||
user={canonical}
|
||||
userId={member.userId}
|
||||
/>
|
||||
<div className="flex flex-col items-start min-w-0">
|
||||
<span className="text-sm text-txt-primary truncate max-w-full">
|
||||
{displayName}
|
||||
</span>
|
||||
{canonical.displayName && (
|
||||
<span className="text-[11px] text-txt-tertiary truncate max-w-full">
|
||||
{canonical.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) {
|
||||
const modalRef = useRef<HTMLDivElement>(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 ? (
|
||||
<p className="text-xs text-txt-tertiary text-center py-4">No members found</p>
|
||||
) : (
|
||||
filteredMembers.map((member) => {
|
||||
return (
|
||||
<button
|
||||
key={member.userId}
|
||||
onClick={() => setSelectedUserId(member.userId)}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded-md hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
<Avatar
|
||||
src={member.user.avatar}
|
||||
name={member.user.displayName || member.user.username}
|
||||
size={32}
|
||||
user={member.user}
|
||||
userId={member.userId}
|
||||
/>
|
||||
<div className="flex flex-col items-start min-w-0">
|
||||
<span className="text-sm text-txt-primary truncate max-w-full">
|
||||
{member.user.displayName || member.user.username}
|
||||
</span>
|
||||
{member.user.displayName && (
|
||||
<span className="text-[11px] text-txt-tertiary truncate max-w-full">
|
||||
{member.user.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
filteredMembers.map((member) => (
|
||||
<TransferMemberRow
|
||||
key={member.userId}
|
||||
member={member}
|
||||
onSelect={setSelectedUserId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
hasPendingChanges: boolean;
|
||||
onToggleExpand: (userId: string) => void;
|
||||
onRoleToggle: (userId: string, roleId: string, currentRoleIds: Set<string>) => 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 (
|
||||
<div>
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 rounded transition-colors ${
|
||||
expandable ? 'cursor-pointer hover:bg-interactive-hover' : ''
|
||||
} ${isExpanded ? 'bg-interactive-hover' : ''}`}
|
||||
onClick={() => {
|
||||
if (expandable) onToggleExpand(member.userId);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
name={displayName}
|
||||
size={32}
|
||||
status={canonical.status}
|
||||
user={canonical}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{displayName}
|
||||
{isFederationGlobeApplicable(canonical) && (
|
||||
<span className="ml-1 text-[10px] text-txt-tertiary opacity-60">@{parseFederatedUsername(canonical.username).domain}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{isOwner && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-rose/20 text-txt-danger font-medium">
|
||||
Owner
|
||||
</span>
|
||||
)}
|
||||
{member.roles?.filter((r) => r.id !== spaceId).map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||
style={{ backgroundColor: `${r.color}20`, color: r.color }}
|
||||
>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
{!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && (
|
||||
<span className="text-[10px] text-txt-tertiary">No roles</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{canBan && member.userId !== currentUserId && !isOwner && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPendingAction({ type: 'ban', userId: member.userId, displayName }); }}
|
||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||
>
|
||||
Ban
|
||||
</button>
|
||||
)}
|
||||
{canKick && member.userId !== currentUserId && !isOwner && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPendingAction({ type: 'kick', userId: member.userId, displayName }); }}
|
||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||
>
|
||||
Kick
|
||||
</button>
|
||||
)}
|
||||
{expandable && (
|
||||
<svg
|
||||
className={`w-4 h-4 text-txt-tertiary transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role checkboxes — only shown when expanded */}
|
||||
{isExpanded && expandable && (
|
||||
<div className="mt-1 mb-1 ml-10 space-y-1">
|
||||
{assignableRoles.map((role) => (
|
||||
<label key={role.id} className="flex items-center gap-2 cursor-pointer group/role">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={memberRoleIds.has(role.id)}
|
||||
onChange={() => onRoleToggle(member.userId, role.id, memberRoleIds)}
|
||||
className="w-3.5 h-3.5 rounded border-txt-tertiary accent-accent-primary"
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: role.color !== '#9ca3af' ? role.color : undefined }}
|
||||
>
|
||||
{role.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{hasPendingChanges && (
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<button
|
||||
onClick={() => onSaveRoles(member.userId)}
|
||||
className="px-2 py-0.5 text-xs bg-accent-primary hover:bg-accent-primary/80 text-white rounded transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCancelRoleChange(member.userId)}
|
||||
className="px-2 py-0.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MembersPanelProps {
|
||||
spaceId: string;
|
||||
@@ -112,135 +271,27 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||
<div className="space-y-0.5">
|
||||
{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 (
|
||||
<div key={member.userId}>
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 rounded transition-colors ${
|
||||
expandable ? 'cursor-pointer hover:bg-interactive-hover' : ''
|
||||
} ${isExpanded ? 'bg-interactive-hover' : ''}`}
|
||||
onClick={() => {
|
||||
if (expandable) {
|
||||
setExpandedMemberId(isExpanded ? null : member.userId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar
|
||||
src={member.user.avatar}
|
||||
name={displayName}
|
||||
size={32}
|
||||
status={member.user.status}
|
||||
user={member.user}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{displayName}
|
||||
{domain && (
|
||||
<span className="ml-1 text-[10px] text-txt-tertiary opacity-60">@{domain}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{isOwner && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-rose/20 text-txt-danger font-medium">
|
||||
Owner
|
||||
</span>
|
||||
)}
|
||||
{member.roles?.filter((r) => r.id !== spaceId).map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||
style={{ backgroundColor: `${r.color}20`, color: r.color }}
|
||||
>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
{!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && (
|
||||
<span className="text-[10px] text-txt-tertiary">No roles</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{canBan && member.userId !== currentUser?.id && !isOwner && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setPendingAction({ type: 'ban', userId: member.userId, displayName }); }}
|
||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||
>
|
||||
Ban
|
||||
</button>
|
||||
)}
|
||||
{canKick && member.userId !== currentUser?.id && !isOwner && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setPendingAction({ type: 'kick', userId: member.userId, displayName }); }}
|
||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||
>
|
||||
Kick
|
||||
</button>
|
||||
)}
|
||||
{expandable && (
|
||||
<svg
|
||||
className={`w-4 h-4 text-txt-tertiary transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role checkboxes — only shown when expanded */}
|
||||
{isExpanded && expandable && (
|
||||
<div className="mt-1 mb-1 ml-10 space-y-1">
|
||||
{assignableRoles.map((role) => (
|
||||
<label key={role.id} className="flex items-center gap-2 cursor-pointer group/role">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={memberRoleIds.has(role.id)}
|
||||
onChange={() => handleRoleToggle(member.userId, role.id, memberRoleIds)}
|
||||
className="w-3.5 h-3.5 rounded border-txt-tertiary accent-accent-primary"
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: role.color !== '#9ca3af' ? role.color : undefined }}
|
||||
>
|
||||
{role.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{hasPendingChanges && (
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<button
|
||||
onClick={() => handleSaveRoles(member.userId)}
|
||||
className="px-2 py-0.5 text-xs bg-accent-primary hover:bg-accent-primary/80 text-white rounded transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCancelRoleChange(member.userId)}
|
||||
className="px-2 py-0.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{members.map((member) => (
|
||||
<MembersPanelRow
|
||||
key={member.userId}
|
||||
member={member}
|
||||
spaceId={spaceId}
|
||||
ownerId={space.ownerId}
|
||||
isExpanded={expandedMemberId === member.userId}
|
||||
expandable={canExpandMember(member)}
|
||||
canKick={canKick}
|
||||
canBan={canBan}
|
||||
currentUserId={currentUser?.id}
|
||||
assignableRoles={assignableRoles}
|
||||
memberRoleIds={getMemberRoleIds(member)}
|
||||
hasPendingChanges={pendingRoleChanges.has(member.userId)}
|
||||
onToggleExpand={(uid) => setExpandedMemberId(expandedMemberId === uid ? null : uid)}
|
||||
onRoleToggle={handleRoleToggle}
|
||||
onSaveRoles={handleSaveRoles}
|
||||
onCancelRoleChange={handleCancelRoleChange}
|
||||
onPendingAction={setPendingAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DiscoverState>((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<DiscoverState>((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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,10 @@ export const useSocialStore = create<SocialState>((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<SocialState>((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<SocialState>((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<SocialState>((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<SocialState>((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<SocialState>((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<SocialState>((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<SocialState>((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<SocialState>((set, get) => ({
|
||||
seen.set(canonicalId, allUsers.length);
|
||||
if (origin) normalizeUserAssets(user, origin);
|
||||
allUsers.push({ ...user, _instanceOrigin: origin });
|
||||
useSpaceStore.getState().upsertUserView(user, origin);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string>; // 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<string, Map<string, string>>;
|
||||
/**
|
||||
* 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<string, UserViewEntry>;
|
||||
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<string, { name: string | null; color: string | null; spaceIds: string[] }>) => Promise<void>;
|
||||
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<void>;
|
||||
@@ -142,6 +187,7 @@ export const useSpaceStore = create<SpaceState>((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<SpaceState>((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<SpaceState>((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<SpaceState>((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<SpaceState>((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<SpaceState>((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<string, string>(); // federatedId → dmChannelId
|
||||
@@ -890,6 +982,16 @@ export const useSpaceStore = create<SpaceState>((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<string, UserViewEntry>();
|
||||
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<SpaceState>((set, get) => ({
|
||||
channelOriginMap,
|
||||
spacePermissions,
|
||||
dmAlternatives,
|
||||
userViews,
|
||||
currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId)
|
||||
? state.currentSpaceId
|
||||
: null,
|
||||
|
||||
@@ -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<User> & Pick<User, 'id' | 'username'>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: `<homeInstanceHost>:<homeUserId>` — same key for the same
|
||||
* person regardless of which instance's local stub we're holding.
|
||||
*
|
||||
* Purely local user: `:<id>` (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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<User> & Pick<User, 'id' | 'username'>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user