diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index 31357841..98055e3b 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -175,11 +175,13 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb 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(); @@ -198,7 +200,7 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb document.removeEventListener('scroll', handleScroll, true); document.removeEventListener('keydown', handleKeyDown); }; - }, [onClose]); + }, [onClose, showTransferModal]); if (!space) return null; @@ -215,10 +217,18 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb onClose(); }; - // Viewport-aware clamping - const menuWidth = 180; - const itemCount = isOwner ? 1 : 2; - const menuHeight = itemCount * 32 + 8; + 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); @@ -237,7 +247,17 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb Invite People - {!isOwner && ( + {isOwner ? ( + + ) : ( + + + + ) : ( + /* 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); @@ -381,6 +567,7 @@ export function SpaceSidebar() { onClick={() => handleSpaceClick(space.id)} onContextMenu={(e) => handleSpaceContextMenu(space.id, e)} hasUnread={unreadSpaceIds.has(space.id)} + tooltipText={space.name} /> ))} diff --git a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx index 875608a2..39242b1f 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { ImageCropModal } from '../../ui/ImageCropModal'; import { getSpaceGradient } from '../../../utils/gradients'; import { useSpaceStore } from '../../../stores/spaceStore'; @@ -47,6 +47,15 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { const [confirmDelete, setConfirmDelete] = useState(false); const fileInputRef = useRef(null); + // Transfer ownership state + const members = useSpaceStore((s) => s.members); + const transferOwnership = useSpaceStore((s) => s.transferOwnership); + const addToast = useUIStore((s) => s.addToast); + const [showTransfer, setShowTransfer] = useState(false); + const [transferSearch, setTransferSearch] = useState(''); + const [transferTargetId, setTransferTargetId] = useState(null); + const [transferring, setTransferring] = useState(false); + useEffect(() => { if (space) { setSpaceName(space.name); @@ -218,6 +227,35 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { } }; + // Transfer ownership logic + const transferCandidates = useMemo(() => { + const candidates = members.filter(m => m.userId !== currentUser?.id); + if (!transferSearch.trim()) return candidates; + const q = transferSearch.toLowerCase(); + return candidates.filter(m => + m.user.displayName?.toLowerCase().includes(q) || + m.user.username.toLowerCase().includes(q) + ); + }, [members, currentUser?.id, transferSearch]); + + const transferTarget = transferTargetId ? members.find(m => m.userId === transferTargetId) : null; + + const handleTransfer = async () => { + if (!transferTargetId) return; + setTransferring(true); + try { + await transferOwnership(spaceId, transferTargetId); + addToast(`Ownership transferred to ${transferTarget?.user.displayName || transferTarget?.user.username}`, 'success', 3000); + setShowTransfer(false); + setTransferTargetId(null); + setTransferSearch(''); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Failed to transfer ownership', 'warning', 3000); + } finally { + setTransferring(false); + } + }; + const displayIconSrc = iconPreview ?? (iconFilename === '' ? null : currentIconUrl); const displayIconName = space.name; const displayBannerSrc = bannerPreview ?? (bannerFilename === '' ? null : currentBannerUrl); @@ -380,14 +418,117 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { {isOwner && (
Danger Zone
-
-

Permanently delete this space and all its data. This cannot be undone.

- +
+ {/* Transfer Ownership */} +
+

Transfer ownership to another member. You will become a regular member.

+ {showTransfer ? ( + transferTargetId && transferTarget ? ( + /* Confirm step */ +
+
+

+ Transfer ownership to{' '} + {transferTarget.user.displayName || transferTarget.user.username}? +

+
+
+ + +
+
+ ) : ( + /* Member picker */ +
+ setTransferSearch(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 + /> +
+ {transferCandidates.length === 0 ? ( +

No members found

+ ) : ( + transferCandidates.map((member) => { + const avatarUrl = member.user.avatar + ? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`) + : null; + return ( + + ); + }) + )} +
+ +
+ ) + ) : ( + + )} +
+ + {/* Divider */} +
+ + {/* Delete Space */} +
+

Permanently delete this space and all its data. This cannot be undone.

+ +
)} diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 0caee697..554aef0d 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -68,6 +68,7 @@ interface SpaceState { populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[]) => void; addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void; removeInstanceSpaces: (origin: string) => void; + transferOwnership: (spaceId: string, newOwnerId: string) => Promise; findExistingDmForUser: (targetUser: { id: string; homeUserId?: string | null }) => { dm: DmChannel; origin: string } | null; } @@ -525,6 +526,24 @@ export const useSpaceStore = create((set, get) => ({ })); }, + transferOwnership: async (spaceId: string, newOwnerId: string) => { + const space = get().spaces.find(s => s.id === spaceId); + const origin = (space as TaggedSpace)?._instanceOrigin ?? ''; + const client = getApiForOrigin(origin); + const updated = await client.spaces.transferOwnership(spaceId, newOwnerId); + if (origin && updated.icon) { + updated.icon = resolveAssetUrl(updated.icon, origin) ?? updated.icon; + } + if (origin && updated.banner) { + updated.banner = resolveAssetUrl(updated.banner, origin) ?? updated.banner; + } + set((state) => ({ + spaces: state.spaces.map(s => + s.id === spaceId ? { ...s, ...updated, _instanceOrigin: origin } as TaggedSpace : s + ), + })); + }, + findExistingDmForUser: (targetUser) => { const { dmChannels, channelOriginMap } = get(); const me = useAuthStore.getState().user;