fix: portal-based space context menu, fix pill indicators and federated icon normalization

Replace ContextMenu wrapper with a single portal-based SpaceContextMenu
that renders via createPortal to document.body. Fixes pill indicator
positioning, menu overflow clipping, multiple-menu-open bugs, and
inconsistent DOM structure between owner/non-owner spaces. Also normalize
federated space icons in addSpaceFromReady() for discovery page joins.
This commit is contained in:
Jannis Braun
2026-03-11 17:34:47 +01:00
parent 1889d45a07
commit 3eabfb9da9
2 changed files with 119 additions and 2 deletions
@@ -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 { useNavigate, useLocation } from 'react-router-dom';
import { useSpaceStore } from '../../stores/spaceStore'; 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 { useAuthStore } from '../../stores/authStore';
import { Tooltip } from '../ui/Tooltip'; import { Tooltip } from '../ui/Tooltip';
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
@@ -14,6 +16,7 @@ interface SidebarItemProps {
icon?: string | null; icon?: string | null;
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
type?: 'space' | 'dm' | 'action'; type?: 'space' | 'dm' | 'action';
actionType?: 'add' | 'join' | 'explore'; actionType?: 'add' | 'join' | 'explore';
hasUnread?: boolean; hasUnread?: boolean;
@@ -23,7 +26,7 @@ interface SidebarItemProps {
tooltipText?: string; 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 [isHovered, setIsHovered] = useState(false);
const firstLetter = name.charAt(0).toUpperCase(); 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" className="relative flex items-center mb-1.5 w-full justify-center"
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
onContextMenu={onContextMenu}
> >
{/* Pill Indicator */} {/* Pill Indicator */}
{(type === 'space' || type === 'dm') && ( {(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<HTMLDivElement>(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(
<div
ref={menuRef}
className="fixed z-[9999] min-w-[160px] bg-surface-overlay rounded-lg border border-white/[0.07] shadow-lg py-1 animate-in fade-in zoom-in-95 duration-100"
style={{ left: clampedX, top: clampedY }}
>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-accent-rose hover:bg-accent-rose/10 transition-colors"
onClick={() => {
if (currentSpaceId === spaceId) {
navigate('/channels/@me');
setCurrentSpace(null);
setShowDms(true);
}
leaveSpace(spaceId);
onClose();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M10.09 15.59L11.5 17l5-5-5-5-1.41 1.41L12.67 11H3v2h9.67l-2.58 2.59zM19 3H5a2 2 0 00-2 2v4h2V5h14v14H5v-4H3v4a2 2 0 002 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</svg>
Leave Space
</button>
</div>,
document.body,
);
}
export function SpaceSidebar() { export function SpaceSidebar() {
const spaces = useSpaceStore((s) => s.spaces); const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
@@ -177,6 +252,16 @@ export function SpaceSidebar() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); 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 // Group spaces by origin
const groupedSpaces = useMemo(() => { const groupedSpaces = useMemo(() => {
const home = spaces.filter(s => !(s as any)._instanceOrigin); const home = spaces.filter(s => !(s as any)._instanceOrigin);
@@ -268,6 +353,7 @@ export function SpaceSidebar() {
icon={space.icon} icon={space.icon}
active={currentSpaceId === space.id} active={currentSpaceId === space.id}
onClick={() => handleSpaceClick(space.id)} onClick={() => handleSpaceClick(space.id)}
onContextMenu={(e) => handleSpaceContextMenu(space.id, e)}
hasUnread={unreadSpaceIds.has(space.id)} hasUnread={unreadSpaceIds.has(space.id)}
/> />
))} ))}
@@ -292,6 +378,7 @@ export function SpaceSidebar() {
icon={space.icon} icon={space.icon}
active={currentSpaceId === space.id} active={currentSpaceId === space.id}
onClick={() => handleSpaceClick(space.id)} onClick={() => handleSpaceClick(space.id)}
onContextMenu={(e) => handleSpaceContextMenu(space.id, e)}
hasUnread={unreadSpaceIds.has(space.id)} hasUnread={unreadSpaceIds.has(space.id)}
dimmed={isDimmed} dimmed={isDimmed}
federationBadge federationBadge
@@ -332,6 +419,14 @@ export function SpaceSidebar() {
actionType="explore" actionType="explore"
/> />
{contextMenu && (
<SpaceContextMenu
spaceId={contextMenu.spaceId}
x={contextMenu.x}
y={contextMenu.y}
onClose={closeContextMenu}
/>
)}
</nav> </nav>
); );
} }
+22
View File
@@ -54,6 +54,7 @@ interface SpaceState {
updateSpace: (spaceId: string, data: UpdateSpaceRequest) => Promise<void>; updateSpace: (spaceId: string, data: UpdateSpaceRequest) => Promise<void>;
deleteSpace: (spaceId: string) => Promise<void>; deleteSpace: (spaceId: string) => Promise<void>;
joinSpace: (spaceId: string, inviteCode: string) => Promise<void>; joinSpace: (spaceId: string, inviteCode: string) => Promise<void>;
leaveSpace: (spaceId: string) => Promise<void>;
joinByCode: (inviteCode: string, origin?: string) => Promise<Space>; joinByCode: (inviteCode: string, origin?: string) => Promise<Space>;
generateInvite: (spaceId: string) => Promise<string>; generateInvite: (spaceId: string) => Promise<string>;
createChannel: (spaceId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>; createChannel: (spaceId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
@@ -234,6 +235,19 @@ export const useSpaceStore = create<SpaceState>((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) => { joinSpace: async (spaceId: string, inviteCode: string) => {
const space =await api.spaces.join(spaceId, { inviteCode }); const space =await api.spaces.join(spaceId, { inviteCode });
set((state) => { set((state) => {
@@ -253,6 +267,8 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
const remoteApi = getApiForOrigin(origin); const remoteApi = getApiForOrigin(origin);
const space =await remoteApi.spaces.joinByCode(inviteCode); 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) => { set((state) => {
if (state.spaces.find(s => s.id === space.id)) return state; if (state.spaces.find(s => s.id === space.id)) return state;
return { spaces: [...state.spaces, { ...space, _instanceOrigin: origin } as TaggedSpace] }; return { spaces: [...state.spaces, { ...space, _instanceOrigin: origin } as TaggedSpace] };
@@ -460,6 +476,12 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
}, },
addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => { 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 = { const tagged: TaggedSpace = {
id: space.id, id: space.id,
name: space.name, name: space.name,