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
+31 -24
View File
@@ -8,7 +8,9 @@ import { useChatStore } from '../../stores/chatStore';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Embed } from './Embed';
import { Username } from '../ui/Username';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { isSelf, resolveDisplayIdentity } from '../../utils/identity';
interface MessageProps {
message: MessageWithUser;
@@ -47,7 +49,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const openUserProfile = useUIStore((s) => s.openUserProfile);
const channelKey = message.channelId || (message as any).dmChannelId;
const isAuthor = currentUser?.id === message.userId;
const isAuthor = isSelf(message.user, currentUser);
const channelPermissions = useServerStore((s) => s.channelPermissions);
const myChPerms = channelPermissions.get(message.channelId);
const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES);
@@ -121,7 +123,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
}
};
const displayName = message.user.displayName ?? message.user.username;
// Resolve display identity: replicated-self messages show home user's avatar/name
const displayIdentity = resolveDisplayIdentity(message.user, currentUser);
const displayName = displayIdentity.displayName ?? displayIdentity.username;
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
@@ -164,10 +168,10 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
{isFirstInGroup || message.replyTo ? (
<div className="mt-0.5">
<Avatar
src={message.user.avatar}
src={displayIdentity.avatar}
name={displayName}
size={40}
user={message.user}
user={displayIdentity}
className="hover:drop-shadow-md transition-all active:translate-y-[1px]"
/>
</div>
@@ -180,29 +184,32 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
{/* Content */}
<div className="flex-1 min-w-0">
{message.replyTo && (
<div className="flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply">
<Avatar src={message.replyTo.user.avatar} name={message.replyTo.user.username} size={16} user={message.replyTo.user} />
<span
className="text-[14px] font-bold text-txt-primary hover:underline"
style={message.replyTo ? replyRoleColor(message.replyTo) : undefined}
>
{message.replyTo.user.displayName ?? message.replyTo.user.username}
</span>
<span className="text-[14px] text-txt-message truncate max-w-[400px] hover:text-txt-primary">
{message.replyTo.content}
</span>
</div>
)}
{message.replyTo && (() => {
const replyIdentity = resolveDisplayIdentity(message.replyTo.user, currentUser);
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">
<Avatar src={replyIdentity.avatar} name={replyDisplayName} size={16} user={replyIdentity} />
<Username
username={replyDisplayName}
className="text-[14px] font-bold text-txt-primary hover:underline"
style={replyRoleColor(message.replyTo)}
/>
<span className="text-[14px] text-txt-message truncate max-w-[400px] hover:text-txt-primary">
{message.replyTo.content}
</span>
</div>
);
})()}
{(isFirstInGroup || message.replyTo) && (
<div className="flex items-baseline gap-2 mb-0.5">
<span
onClick={handleUsernameClick}
className="font-semibold cursor-pointer hover:underline text-[15px] leading-tight"
style={roleColor}
>
{displayName}
<span onClick={handleUsernameClick}>
<Username
username={displayName}
className="font-semibold cursor-pointer hover:underline text-[15px] leading-tight"
style={roleColor}
/>
</span>
<span className="text-[11px] text-txt-tertiary leading-tight hover:cursor-default">
{formatTime(message.createdAt)}
@@ -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
+248 -49
View File
@@ -1,91 +1,290 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useServerStore, NotConnectedError } from '../../stores/serverStore';
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore';
import { useNavigate, useParams } from 'react-router-dom';
import { parseInviteInput } from '../../utils/inviteParser';
type JoinPhase = 'input' | 'connect' | 'fallback';
export function JoinServerModal() {
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [phase, setPhase] = useState<JoinPhase>('input');
const [parsedCode, setParsedCode] = useState('');
const [parsedOrigin, setParsedOrigin] = useState('');
const [password, setPassword] = useState('');
const [fallbackUsername, setFallbackUsername] = useState('');
const [fallbackPassword, setFallbackPassword] = useState('');
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const joinByCode = useServerStore((s) => s.joinByCode);
const connectToRemote = useInstanceStore((s) => s.connectToRemote);
const loginToRemote = useInstanceStore((s) => s.loginToRemote);
const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
const isOpen = activeModal === 'joinServer';
// Pre-fill from URL param and reset state on open/close
useEffect(() => {
if (isOpen && urlInviteCode) {
setInviteCode(urlInviteCode);
if (isOpen) {
if (urlInviteCode) setInviteCode(urlInviteCode);
} else {
// Reset all state when modal closes
setInviteCode('');
setError('');
setPhase('input');
setParsedCode('');
setParsedOrigin('');
setPassword('');
setFallbackUsername('');
setFallbackPassword('');
}
}, [isOpen, urlInviteCode]);
const joinAndNavigate = async (code: string, origin?: string) => {
const server = await joinByCode(code, origin || undefined);
closeModal();
navigate(`/channels/${server.id}`);
};
// Phase 1: Submit invite code/URL
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const code = inviteCode.trim();
if (!code) {
setError('Invite code is required');
let parsed: { code: string; origin?: string };
try {
parsed = parseInviteInput(inviteCode);
} catch (err) {
setError((err as Error).message);
return;
}
setParsedCode(parsed.code);
setParsedOrigin(parsed.origin || '');
setIsLoading(true);
try {
const server = await joinByCode(code);
closeModal();
setInviteCode('');
navigate(`/channels/${server.id}`);
await joinAndNavigate(parsed.code, parsed.origin);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join server');
if (err instanceof NotConnectedError) {
setPhase('connect');
setError('');
} else {
setError(err instanceof Error ? err.message : 'Failed to join server');
}
} finally {
setIsLoading(false);
}
};
// Phase 2: Connect to remote instance with password, then join
const handleConnect = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await connectToRemote(parsedOrigin, password, user?.displayName || undefined);
await joinAndNavigate(parsedCode, parsedOrigin);
} catch (err) {
if (err instanceof DifferentPasswordError) {
setPhase('fallback');
setFallbackUsername(err.remoteUsername);
setFallbackPassword('');
setError('');
} else {
setError((err as Error).message);
}
} finally {
setIsLoading(false);
}
};
// Phase 3: Fallback login with different credentials, then join
const handleFallbackLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await loginToRemote(parsedOrigin, fallbackUsername, fallbackPassword);
await joinAndNavigate(parsedCode, parsedOrigin);
} catch (err) {
setError((err as Error).message);
} finally {
setIsLoading(false);
}
};
let hostDisplay = '';
try {
if (parsedOrigin) hostDisplay = new URL(parsedOrigin).host;
} catch { /* ignore */ }
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Server">
<form onSubmit={handleSubmit}>
<p className="text-txt-secondary text-sm mb-4">
Enter an invite code to join an existing server.
</p>
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
{/* Error display (shared across all phases) */}
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
</div>
)}
{/* Phase: input — enter invite code or URL */}
{phase === 'input' && (
<form onSubmit={handleSubmit}>
<p className="text-txt-secondary text-sm mb-4">
Enter an invite code or link to join a server.
</p>
<div className="mb-4">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Invite Code or Link
</label>
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
placeholder="e.g. abc123 or https://instance.com/join/abc123"
autoFocus
/>
</div>
)}
<div className="mb-4">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Invite Code
</label>
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
placeholder="e.g. abc123"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Joining...' : 'Join Server'}
</button>
</div>
</form>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || !inviteCode.trim()}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Joining...' : 'Join Server'}
</button>
</div>
</form>
)}
{/* Phase: connect — password prompt to connect to remote instance */}
{phase === 'connect' && (
<form onSubmit={handleConnect}>
<p className="text-txt-secondary text-sm mb-4">
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this server.
</p>
<div className="mb-4 space-y-2">
<div>
<label className="block text-xs text-txt-tertiary mb-1">
Enter your password to connect
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your account password"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
disabled={isLoading}
autoFocus
/>
<div className="text-xs text-txt-tertiary mt-1">
Your password is verified locally, then used to create or access your account on the remote instance.
</div>
</div>
</div>
<div className="flex justify-between">
<button
type="button"
onClick={() => { setPhase('input'); setPassword(''); setError(''); }}
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Back
</button>
<div className="flex gap-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || !password}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Connecting...' : 'Connect & Join'}
</button>
</div>
</div>
</form>
)}
{/* Phase: fallback — different password on remote instance */}
{phase === 'fallback' && (
<form onSubmit={handleFallbackLogin}>
<div className="mb-3 p-2 bg-accent-amber/10 border border-accent-amber/30 rounded text-xs text-accent-amber">
An account already exists on {hostDisplay} with a different password. Enter the credentials you used on that instance.
</div>
<div className="mb-4 space-y-3">
<div>
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
<input
type="text"
value={fallbackUsername}
onChange={(e) => setFallbackUsername(e.target.value)}
placeholder="Your username on this instance"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
disabled={isLoading}
/>
</div>
<div>
<label className="block text-xs text-txt-tertiary mb-1">Password for this instance</label>
<input
type="password"
value={fallbackPassword}
onChange={(e) => setFallbackPassword(e.target.value)}
placeholder="Password on the remote instance"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
disabled={isLoading}
autoFocus
/>
</div>
</div>
<div className="flex justify-between">
<button
type="button"
onClick={() => { setPhase('connect'); setFallbackPassword(''); setError(''); }}
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Back
</button>
<div className="flex gap-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || !fallbackUsername || !fallbackPassword}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Logging in...' : 'Login & Join'}
</button>
</div>
</div>
</form>
)}
</Modal>
);
}
@@ -0,0 +1,29 @@
import React from 'react';
import { useUIStore } from '../../stores/uiStore';
const borderColors = {
info: 'border-l-accent-sky',
warning: 'border-l-accent-amber',
success: 'border-l-accent-mint',
} as const;
export function ToastContainer() {
const toasts = useUIStore((s) => s.toasts);
const removeToast = useUIStore((s) => s.removeToast);
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-6 right-6 z-[300] flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={`glass-pill border-l-2 ${borderColors[toast.type]} rounded-[10px] px-4 py-2.5 max-w-[320px] animate-slide-up pointer-events-auto cursor-pointer`}
onClick={() => removeToast(toast.id)}
>
<span className="text-sm text-txt-primary leading-snug">{toast.message}</span>
</div>
))}
</div>
);
}
@@ -2,6 +2,7 @@ import React from 'react';
import { useNavigate } from 'react-router-dom';
import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
import { api } from '../../api/client';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
@@ -77,12 +78,15 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
{/* Name & info — flows naturally after avatar */}
<div>
<div className="text-[16px] font-semibold text-txt-primary leading-tight">
{displayName}
</div>
<div className="text-[13px] text-txt-tertiary">
@{user.username}
</div>
<Username
username={displayName}
className="text-[16px] font-semibold text-txt-primary leading-tight"
/>
{user.username.includes('@') ? (
<Username username={user.username} className="text-[13px] text-txt-tertiary" />
) : (
<div className="text-[13px] text-txt-tertiary">@{user.username}</div>
)}
{user.customStatus && (
<div className="text-[13px] text-txt-secondary italic mt-1">
{user.customStatus}
@@ -0,0 +1,25 @@
import React from 'react';
import { Tooltip } from './Tooltip';
interface UsernameProps {
username: string;
className?: string;
style?: React.CSSProperties;
}
export function Username({ username, className, style }: UsernameProps) {
const atIndex = username.indexOf('@');
if (atIndex === -1) {
return <span className={className} style={style}>{username}</span>;
}
const name = username.slice(0, atIndex);
const domain = username.slice(atIndex + 1);
return (
<Tooltip content={username} position="top">
<span className={className} style={style}>
{name}
<span className="text-txt-tertiary text-[0.8em] ml-0.5 font-normal">@{domain}</span>
</span>
</Tooltip>
);
}