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 ( ); }