import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore';
import { Tooltip } from '../ui/Tooltip';
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
interface SidebarItemProps {
id: string;
name: string;
icon?: string | null;
active: boolean;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
type?: 'space' | 'dm' | 'action';
actionType?: 'add' | 'join' | 'explore';
hasUnread?: boolean;
dimmed?: boolean;
federationBadge?: boolean;
federationDisconnected?: boolean;
tooltipText?: string;
}
function SidebarItem({ id, name, icon, active, onClick, onContextMenu, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText }: SidebarItemProps) {
const [isHovered, setIsHovered] = useState(false);
const firstLetter = name.charAt(0).toUpperCase();
const getPillHeight = () => {
if (active) return 'h-8';
if (isHovered) return 'h-4';
if (hasUnread && !active) return 'h-2';
return 'h-2 scale-0';
};
const backgroundStyle = useMemo((): React.CSSProperties | undefined => {
if (type === 'action') {
return {
background: isHovered ? 'rgba(134, 239, 172, 0.12)' : 'rgba(255, 255, 255, 0.04)',
};
}
if (type === 'dm') {
return { background: HOME_GRADIENT.gradient };
}
// Space type — if it has a custom icon image, no gradient needed
if (icon) return undefined;
const spaceGrad = getSpaceGradient(id, name);
return { background: spaceGrad.gradient };
}, [type, id, name, icon, isHovered]);
const getButtonClasses = () => {
const base = 'w-10 h-10 flex items-center justify-center duration-200 overflow-hidden [transition:border-radius_0.2s,background_0.2s,color_0.2s]';
if (type === 'dm') {
return `${base} text-white ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`;
}
if (type === 'action') {
return `${base} rounded-[20px] hover:rounded-[13px] text-accent-mint`;
}
if (icon) {
return `${base} ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`;
}
return `${base} text-white ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`;
};
const buttonContent = (
);
const innerContent = (
{buttonContent}
{federationBadge && (
{federationDisconnected ? (
) : (
)}
)}
);
return (
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onContextMenu={onContextMenu}
>
{/* Pill Indicator */}
{(type === 'space' || type === 'dm') && (
)}
{tooltipText ? (
{innerContent}
) : (
innerContent
)}
);
}
function InstanceDivider({ label, disconnected }: { label: string; disconnected: boolean }) {
return (
);
}
function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: number; y: number; onClose: () => void }) {
const menuRef = useRef(null);
const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId));
const currentUserId = useAuthStore((s) => s.user?.id);
const leaveSpace = useSpaceStore((s) => s.leaveSpace);
const generateInvite = useSpaceStore((s) => s.generateInvite);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const setShowDms = useUIStore((s) => s.setShowDms);
const addToast = useUIStore((s) => s.addToast);
const navigate = useNavigate();
const [showTransferModal, setShowTransferModal] = useState(false);
const isOwner = space?.ownerId === currentUserId;
// Close on click-outside and scroll
useEffect(() => {
if (showTransferModal) return; // Don't close when transfer modal is open
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleScroll = () => onClose();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('scroll', handleScroll, true);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose, showTransferModal]);
if (!space) return null;
const handleInvite = async () => {
try {
const code = await generateInvite(spaceId);
const origin = (space as any)._instanceOrigin || window.location.origin;
const url = `${origin}/invite/${code}`;
await navigator.clipboard.writeText(url);
addToast('Invite link copied to clipboard', 'success', 3000);
} catch {
addToast('Failed to generate invite', 'warning', 3000);
}
onClose();
};
if (showTransferModal) {
return (
);
}
// Viewport-aware clamping — always 2 items (Invite + Transfer or Invite + Leave)
const menuWidth = 200;
const menuHeight = 2 * 32 + 8;
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
return ReactDOM.createPortal(
{isOwner ? (
) : (
)}
,
document.body,
);
}
function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) {
const modalRef = useRef(null);
const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId));
const members = useSpaceStore((s) => s.members);
const currentUserId = useAuthStore((s) => s.user?.id);
const transferOwnership = useSpaceStore((s) => s.transferOwnership);
const addToast = useUIStore((s) => s.addToast);
const [search, setSearch] = useState('');
const [selectedUserId, setSelectedUserId] = useState(null);
const [transferring, setTransferring] = useState(false);
// Filter members: exclude self, filter by search
const filteredMembers = useMemo(() => {
const spaceMembers = members.filter(m => m.userId !== currentUserId);
if (!search.trim()) return spaceMembers;
const q = search.toLowerCase();
return spaceMembers.filter(m =>
m.user.displayName?.toLowerCase().includes(q) ||
m.user.username.toLowerCase().includes(q)
);
}, [members, currentUserId, search]);
const selectedMember = selectedUserId ? members.find(m => m.userId === selectedUserId) : null;
// Close on click-outside and escape
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (selectedUserId) {
setSelectedUserId(null);
} else {
onClose();
}
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose, selectedUserId]);
if (!space) return null;
const handleTransfer = async () => {
if (!selectedUserId) return;
setTransferring(true);
try {
await transferOwnership(spaceId, selectedUserId);
addToast(`Ownership transferred to ${selectedMember?.user.displayName || selectedMember?.user.username}`, 'success', 3000);
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Failed to transfer ownership', 'warning', 3000);
} finally {
setTransferring(false);
}
};
return ReactDOM.createPortal(
{/* Header */}
Transfer Ownership
Choose a member to become the new owner of {space.name}
{selectedUserId && selectedMember ? (
/* Confirm step */
Transfer ownership of {space.name} to{' '}
{selectedMember.user.displayName || selectedMember.user.username}?
You will become a regular member.
) : (
/* Member list */
<>
setSearch(e.target.value)}
placeholder="Search members..."
className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50"
autoFocus
/>
{filteredMembers.length === 0 ? (
No members found
) : (
filteredMembers.map((member) => {
const avatarUrl = member.user.avatar
? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
: null;
return (
);
})
)}
>
)}
,
document.body,
);
}
export function SpaceSidebar() {
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
const dmChannels = useSpaceStore((s) => s.dmChannels);
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 floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const unreadChannels = useChatStore((s) => s.unreadChannels);
const instances = useInstanceStore((s) => s.instances);
const navigate = useNavigate();
const location = useLocation();
// Single context menu state
const [contextMenu, setContextMenu] = useState<{ spaceId: string; x: number; y: number } | null>(null);
const handleSpaceContextMenu = useCallback((spaceId: string, e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ spaceId, x: e.clientX, y: e.clientY });
}, []);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
// Group spaces by origin
const groupedSpaces = useMemo(() => {
const home = spaces.filter(s => !(s as any)._instanceOrigin);
const remoteMap = new Map();
for (const s of spaces) {
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()) };
}, [spaces]);
// Set of disconnected origins
const disconnectedOrigins = useMemo(() => {
const set = new Set();
for (const inst of instances) {
if (inst.status === 'disconnected' || inst.status === 'error') {
set.add(inst.origin);
}
}
return set;
}, [instances]);
// Compute which spaces have unread channels
const unreadSpaceIds = useMemo(() => {
const ids = new Set();
for (const channelId of unreadChannels) {
const spaceId = channelToSpaceMap.get(channelId);
if (spaceId) ids.add(spaceId);
}
return ids;
}, [unreadChannels, channelToSpaceMap]);
// Check if any DM channels are unread
const hasDmUnread = useMemo(() => {
for (const dm of dmChannels) {
if (unreadChannels.has(dm.id)) return true;
}
return false;
}, [unreadChannels, dmChannels]);
const handleSpaceClick = (spaceId: string) => {
const space = spaces.find(s => s.id === spaceId);
const origin = (space 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;
}
setCurrentSpace(spaceId);
setShowDms(false);
navigate(`/channels/${spaceId}`);
};
const handleDmClick = () => {
setShowDms(true);
setCurrentSpace(null);
setCurrentChannel(null);
navigate('/channels/@me');
};
const handleExploreClick = () => {
setCurrentSpace(null);
setCurrentChannel(null);
navigate('/explore');
};
return (
);
}