feat: overhaul remote instance indicators in space sidebar

Replace truncated text labels with globe pill dividers and add
federation badges to remote space icons. Remote spaces now show
rich Tooltips with "SpaceName · host" instead of native title attrs.
Disconnected instances display amber warning indicators throughout.
This commit is contained in:
Jannis Braun
2026-03-09 14:06:56 +01:00
parent 22e7616c70
commit 7e2986ca01
@@ -4,6 +4,7 @@ import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore'; import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useInstanceStore } from '../../stores/instanceStore'; import { useInstanceStore } from '../../stores/instanceStore';
import { Tooltip } from '../ui/Tooltip';
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
@@ -17,9 +18,12 @@ interface SidebarItemProps {
actionType?: 'add' | 'join' | 'explore'; actionType?: 'add' | 'join' | 'explore';
hasUnread?: boolean; hasUnread?: boolean;
dimmed?: boolean; dimmed?: boolean;
federationBadge?: boolean;
federationDisconnected?: boolean;
tooltipText?: string;
} }
function SidebarItem({ id, name, icon, active, onClick, type = 'space', actionType, hasUnread, dimmed }: SidebarItemProps) { function SidebarItem({ id, name, icon, active, onClick, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText }: SidebarItemProps) {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const firstLetter = name.charAt(0).toUpperCase(); const firstLetter = name.charAt(0).toUpperCase();
@@ -66,22 +70,8 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'space', actionTy
return `${base} text-white ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`; return `${base} text-white ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`;
}; };
return ( const buttonContent = (
<div <button onClick={onClick} className={`${getButtonClasses()} ${dimmed ? 'opacity-40 saturate-50' : ''}`} style={backgroundStyle} title={tooltipText ? undefined : name}>
className="relative flex items-center mb-1.5 w-full justify-center"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Pill Indicator */}
{(type === 'space' || type === 'dm') && (
<div className="absolute -left-0 w-2 h-10 flex items-center">
<div
className={`bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1`}
/>
</div>
)}
<button onClick={onClick} className={`${getButtonClasses()} ${dimmed ? 'opacity-40 saturate-50' : ''}`} style={backgroundStyle} title={name}>
{type === 'dm' ? ( {type === 'dm' ? (
<span className="text-[17px] font-bold">B</span> <span className="text-[17px] font-bold">B</span>
) : type === 'action' ? ( ) : type === 'action' ? (
@@ -108,7 +98,65 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'space', actionTy
<span className="text-[15px] font-bold">{firstLetter}</span> <span className="text-[15px] font-bold">{firstLetter}</span>
)} )}
</button> </button>
);
const innerContent = (
<div className="relative">
{buttonContent}
{federationBadge && (
<div className="absolute -bottom-0.5 -right-0.5 w-[14px] h-[14px] rounded-full bg-surface-base flex items-center justify-center">
{federationDisconnected ? (
<div className="w-[8px] h-[8px] rounded-full bg-accent-amber" />
) : (
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary/80">
<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>
)}
</div> </div>
)}
</div>
);
return (
<div
className="relative flex items-center mb-1.5 w-full justify-center"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Pill Indicator */}
{(type === 'space' || type === 'dm') && (
<div className="absolute -left-0 w-2 h-10 flex items-center">
<div
className={`bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1`}
/>
</div>
)}
{tooltipText ? (
<Tooltip content={tooltipText} position="right" delay={300}>
{innerContent}
</Tooltip>
) : (
innerContent
)}
</div>
);
}
function InstanceDivider({ label, disconnected }: { label: string; disconnected: boolean }) {
return (
<Tooltip content={label} position="right" delay={300}>
<div className="relative flex items-center justify-center w-full my-2">
<div className="absolute inset-x-5 h-[1px] bg-interactive-muted/30 rounded-full" />
<div className={`relative z-[1] w-5 h-5 rounded-full bg-surface-base flex items-center justify-center ${
disconnected ? 'text-accent-amber' : 'text-txt-tertiary'
}`}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="opacity-60">
<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>
</div>
</div>
</Tooltip>
); );
} }
@@ -226,14 +274,15 @@ export function SpaceSidebar() {
{/* Remote instance groups */} {/* Remote instance groups */}
{groupedSpaces.remoteGroups.map(([origin, groupSpaces]) => { {groupedSpaces.remoteGroups.map(([origin, groupSpaces]) => {
const inst = instances.find(i => i.origin === origin); const inst = instances.find(i => i.origin === origin);
const label = inst?.label || (() => { try { return new URL(origin).host; } catch { return '?'; } })(); const hostLabel = (() => { try { return new URL(origin).host; } catch { return '?'; } })();
const label = inst?.label || hostLabel;
const isDimmed = disconnectedOrigins.has(origin); const isDimmed = disconnectedOrigins.has(origin);
return ( return (
<React.Fragment key={origin}> <React.Fragment key={origin}>
<div className="w-8 h-[2px] bg-interactive-muted/50 rounded-full my-1" /> <InstanceDivider
<div className="text-[9px] text-txt-tertiary font-medium uppercase tracking-wider mb-1 truncate max-w-[52px] text-center" title={label}> label={isDimmed ? `${label} (disconnected)` : label}
{label} disconnected={isDimmed}
</div> />
{groupSpaces.map((space) => ( {groupSpaces.map((space) => (
<SidebarItem <SidebarItem
key={space.id} key={space.id}
@@ -244,6 +293,9 @@ export function SpaceSidebar() {
onClick={() => handleSpaceClick(space.id)} onClick={() => handleSpaceClick(space.id)}
hasUnread={unreadSpaceIds.has(space.id)} hasUnread={unreadSpaceIds.has(space.id)}
dimmed={isDimmed} dimmed={isDimmed}
federationBadge
federationDisconnected={isDimmed}
tooltipText={`${space.name} \u00b7 ${hostLabel}`}
/> />
))} ))}
</React.Fragment> </React.Fragment>