feat: stateless federated identity resolver + federation UX improvements

Add identity.ts with isSelf() and resolveDisplayIdentity() — pure
stateless functions that detect replicated-self using the immutable
(username, homeInstance) composite key. No store lookups, no data
mutation. Fixes wrong avatar gradient and missing edit/delete on
own messages in remote channels.

Also includes: optimistic message dedup fix for cross-instance
messages (content-only matching), federation toast notifications,
Username component with @domain display, invite parser, and
deploy script simplification.
This commit is contained in:
Jannis Braun
2026-03-04 03:37:49 +01:00
parent 64fd8edfc9
commit dca9c4dc83
19 changed files with 713 additions and 110 deletions
@@ -20,8 +20,10 @@ import { PictureInPicture } from '../voice/PictureInPicture';
import { SoundController } from '../voice/SoundController';
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
import { UserProfilePopout } from '../ui/UserProfilePopout';
import { ToastContainer } from '../ui/ToastContainer';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useFederationToasts } from '../../hooks/useFederationToasts';
import { useLiveKit } from '../../hooks/useLiveKit';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
@@ -106,6 +108,9 @@ export function AppLayout() {
// Initialize WebSocket
const { isConnected: isWsConnected } = useWebSocket();
// Federation toast notifications for remote instance connection state changes
useFederationToasts();
// Track the last channel we attempted to connect to, to prevent effect loops
const lastAttemptedRef = React.useRef<string | null>(null);
@@ -260,6 +265,9 @@ export function AppLayout() {
/>
</>
)}
{/* Federation toasts */}
<ToastContainer />
</div>
);
}
@@ -1,9 +1,10 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { VoiceChannel } from '../voice/VoiceChannel';
import { VoiceControls } from '../voice/VoiceControls';
import { useVoiceStore } from '../../stores/voiceStore';
@@ -71,6 +72,15 @@ export function ChannelSidebar() {
const serverPermissions = useServerStore((s) => s.serverPermissions);
const server = servers.find(s => s.id === currentServerId);
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
const federationInstances = useInstanceStore((s) => s.instances);
const instanceLabel = useMemo(() => {
const origin = (server as any)?._instanceOrigin;
if (!origin) return null;
const inst = federationInstances.find(i => i.origin === origin);
if (inst) return inst.label;
try { return new URL(origin).host; } catch { return origin; }
}, [server, federationInstances]);
const canManageChannels = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_CHANNELS);
const textChannels = channels.filter(c => c.type === 'text');
@@ -280,7 +290,14 @@ export function ChannelSidebar() {
onClick={() => openModal('serverSettings')}
className="flex-1 h-full px-4 flex items-center justify-between hover:bg-interactive-hover transition-colors min-w-0"
>
<span className="font-bold text-[15px] tracking-[-0.02em] text-txt-primary truncate leading-tight">{server.name}</span>
<div className="min-w-0">
<span className="font-bold text-[15px] tracking-[-0.02em] text-txt-primary truncate leading-tight block">{server.name}</span>
{instanceLabel && (
<span className="text-[10px] text-txt-tertiary font-medium truncate block leading-tight">
{instanceLabel}
</span>
)}
</div>
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor" className="text-txt-tertiary flex-shrink-0">
<path d="M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" />
</svg>
@@ -3,6 +3,7 @@ import type { MemberWithUser } from '@backspace/shared';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
/**
* Derives the display group for a member based on their highest-positioned role
@@ -111,12 +112,11 @@ export function MemberSidebar() {
user={member.user}
/>
<div className="flex-1 min-w-0">
<div
<Username
username={displayName}
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : (!colorStyle ? 'text-txt-primary' : '')}`}
style={colorStyle}
>
{displayName}
</div>
/>
{!isOffline && member.user.customStatus && (
<div className="text-[11px] leading-[1.3] text-txt-tertiary truncate">{member.user.customStatus}</div>
)}
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { getServerGradient, HOME_GRADIENT } from '../../utils/gradients';
@@ -15,9 +16,10 @@ interface SidebarItemProps {
type?: 'server' | 'dm' | 'action';
actionType?: 'add' | 'join';
hasUnread?: boolean;
dimmed?: boolean;
}
function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionType, hasUnread }: SidebarItemProps) {
function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionType, hasUnread, dimmed }: SidebarItemProps) {
const [isHovered, setIsHovered] = useState(false);
const firstLetter = name.charAt(0).toUpperCase();
@@ -79,7 +81,7 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionT
</div>
)}
<button onClick={onClick} className={getButtonClasses()} style={backgroundStyle} title={name}>
<button onClick={onClick} className={`${getButtonClasses()} ${dimmed ? 'opacity-40 saturate-50' : ''}`} style={backgroundStyle} title={name}>
{type === 'dm' ? (
<span className="text-[17px] font-bold">B</span>
) : type === 'action' ? (
@@ -115,9 +117,36 @@ export function ServerSidebar() {
const showDms = useUIStore((s) => s.showDms);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const addToast = useUIStore((s) => s.addToast);
const unreadChannels = useChatStore((s) => s.unreadChannels);
const instances = useInstanceStore((s) => s.instances);
const navigate = useNavigate();
// Group servers by origin
const groupedServers = useMemo(() => {
const home = servers.filter(s => !(s as any)._instanceOrigin);
const remoteMap = new Map<string, typeof servers>();
for (const s of servers) {
const origin = (s as any)._instanceOrigin;
if (!origin) continue;
const list = remoteMap.get(origin) || [];
list.push(s);
remoteMap.set(origin, list);
}
return { home, remoteGroups: Array.from(remoteMap.entries()) };
}, [servers]);
// Set of disconnected origins
const disconnectedOrigins = useMemo(() => {
const set = new Set<string>();
for (const inst of instances) {
if (inst.status === 'disconnected' || inst.status === 'error') {
set.add(inst.origin);
}
}
return set;
}, [instances]);
// Compute which servers have unread channels
const unreadServerIds = useMemo(() => {
const ids = new Set<string>();
@@ -137,6 +166,13 @@ export function ServerSidebar() {
}, [unreadChannels, dmChannels]);
const handleServerClick = (serverId: string) => {
const server = servers.find(s => s.id === serverId);
const origin = (server as any)?._instanceOrigin;
if (origin && disconnectedOrigins.has(origin)) {
const inst = instances.find(i => i.origin === origin);
addToast(`Reconnecting to ${inst?.label || 'remote instance'}...`, 'warning', 4000);
return;
}
setCurrentServer(serverId);
setShowDms(false);
navigate(`/channels/${serverId}`);
@@ -161,7 +197,8 @@ export function ServerSidebar() {
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
{servers.map((server) => (
{/* Home servers */}
{groupedServers.home.map((server) => (
<SidebarItem
key={server.id}
id={server.id}
@@ -173,6 +210,33 @@ export function ServerSidebar() {
/>
))}
{/* Remote instance groups */}
{groupedServers.remoteGroups.map(([origin, groupServers]) => {
const inst = instances.find(i => i.origin === origin);
const label = inst?.label || (() => { try { return new URL(origin).host; } catch { return '?'; } })();
const isDimmed = disconnectedOrigins.has(origin);
return (
<React.Fragment key={origin}>
<div className="w-8 h-[2px] bg-interactive-muted/50 rounded-full my-1" />
<div className="text-[9px] text-txt-tertiary font-medium uppercase tracking-wider mb-1 truncate max-w-[52px] text-center" title={label}>
{label}
</div>
{groupServers.map((server) => (
<SidebarItem
key={server.id}
id={server.id}
name={server.name}
icon={server.icon}
active={currentServerId === server.id}
onClick={() => handleServerClick(server.id)}
hasUnread={unreadServerIds.has(server.id)}
dimmed={isDimmed}
/>
))}
</React.Fragment>
);
})}
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
<SidebarItem