diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index 6016d8e0..abc5b9ed 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -1,9 +1,11 @@ -import React, { useState, useMemo } from 'react'; +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'; @@ -14,6 +16,7 @@ interface SidebarItemProps { icon?: string | null; active: boolean; onClick: () => void; + onContextMenu?: (e: React.MouseEvent) => void; type?: 'space' | 'dm' | 'action'; actionType?: 'add' | 'join' | 'explore'; hasUnread?: boolean; @@ -23,7 +26,7 @@ interface SidebarItemProps { tooltipText?: string; } -function SidebarItem({ id, name, icon, active, onClick, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText }: SidebarItemProps) { +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(); @@ -122,6 +125,7 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'space', actionTy className="relative flex items-center mb-1.5 w-full justify-center" onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + onContextMenu={onContextMenu} > {/* Pill Indicator */} {(type === 'space' || type === 'dm') && ( @@ -160,6 +164,77 @@ function InstanceDivider({ label, disconnected }: { label: string; disconnected: ); } +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 currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace); + const setShowDms = useUIStore((s) => s.setShowDms); + const navigate = useNavigate(); + + const isOwner = space?.ownerId === currentUserId; + + // Close on click-outside and scroll + useEffect(() => { + 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]); + + // Don't render for owners (no menu items) + if (isOwner || !space) return null; + + // Viewport-aware clamping + const menuWidth = 180; + const menuHeight = 40; + const clampedX = Math.min(x, window.innerWidth - menuWidth - 8); + const clampedY = Math.min(y, window.innerHeight - menuHeight - 8); + + return ReactDOM.createPortal( +
+ +
, + document.body, + ); +} + export function SpaceSidebar() { const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); @@ -177,6 +252,16 @@ export function SpaceSidebar() { 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); @@ -268,6 +353,7 @@ export function SpaceSidebar() { icon={space.icon} active={currentSpaceId === space.id} onClick={() => handleSpaceClick(space.id)} + onContextMenu={(e) => handleSpaceContextMenu(space.id, e)} hasUnread={unreadSpaceIds.has(space.id)} /> ))} @@ -292,6 +378,7 @@ export function SpaceSidebar() { icon={space.icon} active={currentSpaceId === space.id} onClick={() => handleSpaceClick(space.id)} + onContextMenu={(e) => handleSpaceContextMenu(space.id, e)} hasUnread={unreadSpaceIds.has(space.id)} dimmed={isDimmed} federationBadge @@ -332,6 +419,14 @@ export function SpaceSidebar() { actionType="explore" /> + {contextMenu && ( + + )} ); } diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index cbefdc32..f71b55f0 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -54,6 +54,7 @@ interface SpaceState { updateSpace: (spaceId: string, data: UpdateSpaceRequest) => Promise; deleteSpace: (spaceId: string) => Promise; joinSpace: (spaceId: string, inviteCode: string) => Promise; + leaveSpace: (spaceId: string) => Promise; joinByCode: (inviteCode: string, origin?: string) => Promise; generateInvite: (spaceId: string) => Promise; createChannel: (spaceId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise; @@ -234,6 +235,19 @@ export const useSpaceStore = create((set, get) => ({ })); }, + leaveSpace: async (spaceId: string) => { + const space = get().spaces.find(s => s.id === spaceId); + const origin = (space as TaggedSpace)?._instanceOrigin ?? ''; + const targetApi = getApiForOrigin(origin); + const userId = useAuthStore.getState().user?.id; + if (!userId) return; + await targetApi.spaces.removeMember(spaceId, userId); + set((state) => ({ + spaces: state.spaces.filter(s => s.id !== spaceId), + currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId, + })); + }, + joinSpace: async (spaceId: string, inviteCode: string) => { const space =await api.spaces.join(spaceId, { inviteCode }); set((state) => { @@ -253,6 +267,8 @@ export const useSpaceStore = create((set, get) => ({ const remoteApi = getApiForOrigin(origin); const space =await remoteApi.spaces.joinByCode(inviteCode); + if (space.icon) space.icon = resolveAssetUrl(space.icon, origin) ?? space.icon; + if (space.banner) space.banner = resolveAssetUrl(space.banner, origin) ?? space.banner; set((state) => { if (state.spaces.find(s => s.id === space.id)) return state; return { spaces: [...state.spaces, { ...space, _instanceOrigin: origin } as TaggedSpace] }; @@ -460,6 +476,12 @@ export const useSpaceStore = create((set, get) => ({ }, addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => { + // Normalize remote asset URLs before creating the tagged object + if (origin) { + if (space.icon) space.icon = resolveAssetUrl(space.icon, origin) ?? space.icon; + if (space.banner) space.banner = resolveAssetUrl(space.banner, origin) ?? space.banner; + } + const tagged: TaggedSpace = { id: space.id, name: space.name,