diff --git a/packages/web/src/components/layout/MobileSpacesScreen.tsx b/packages/web/src/components/layout/MobileSpacesScreen.tsx index dc0ccb14..b6a0b34d 100644 --- a/packages/web/src/components/layout/MobileSpacesScreen.tsx +++ b/packages/web/src/components/layout/MobileSpacesScreen.tsx @@ -1,14 +1,18 @@ import React, { useState, useMemo, useEffect } from 'react'; import { useUIStore } from '../../stores/uiStore'; -import { useSpaceStore } from '../../stores/spaceStore'; +import { useSpaceStore, getMyUserIdForOrigin } from '../../stores/spaceStore'; +import type { TaggedSpace } from '../../stores/spaceStore'; import { useChatStore } from '../../stores/chatStore'; import { useVoiceStore } from '../../stores/voiceStore'; import { useAuthStore } from '../../stores/authStore'; +import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore'; import { useNavigate } from 'react-router-dom'; import { getSpaceGradient } from '../../utils/gradients'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import type { Channel } from '@backspace/shared'; import { Mascot } from '../ui/Mascot'; +import { ConfirmDialog } from '../ui/ConfirmDialog'; +import { TransferOwnershipModal } from '../modals/TransferOwnershipModal'; export function MobileSpacesScreen() { const spaces = useSpaceStore((s) => s.spaces); @@ -21,6 +25,9 @@ export function MobileSpacesScreen() { const voiceChannelIds = useSpaceStore((s) => s.voiceChannelIds); const spacePermissions = useSpaceStore((s) => s.spacePermissions); const channelPermissions = useSpaceStore((s) => s.channelPermissions); + const folders = useSpaceStore((s) => s.folders); + const spaceLayout = useSpaceStore((s) => s.spaceLayout); + const updateSpaceLayout = useSpaceStore((s) => s.updateSpaceLayout); const unreadChannels = useChatStore((s) => s.unreadChannels); const currentChannelId = useChatStore((s) => s.currentChannelId); @@ -33,6 +40,9 @@ export function MobileSpacesScreen() { const setMobileTab = useUIStore((s) => s.setMobileTab); const openModal = useUIStore((s) => s.openModal); const setLastChannel = useUIStore((s) => s.setLastChannel); + const addToast = useUIStore((s) => s.addToast); + + const openContextMenu = useContextMenuStore((s) => s.open); const navigate = useNavigate(); const user = useAuthStore((s) => s.user); @@ -41,6 +51,10 @@ export function MobileSpacesScreen() { const [selectedSpaceId, setSelectedSpaceId] = useState(currentSpaceId); const [collapsedCategories, setCollapsedCategories] = useState>(new Set()); const [showAddSheet, setShowAddSheet] = useState(false); + const [leaveConfirmSpaceId, setLeaveConfirmSpaceId] = useState(null); + const [deleteChannelId, setDeleteChannelId] = useState(null); + const [deleteCategoryId, setDeleteCategoryId] = useState(null); + const [transferModalSpaceId, setTransferModalSpaceId] = useState(null); // Sync selected space with store's current space useEffect(() => { @@ -129,6 +143,230 @@ export function MobileSpacesScreen() { }); }; + // ─── Folder handlers ────────────────────────────────────────────────── + + const handleCreateFolder = async (spaceId: string) => { + const currentLayout = spaceLayout || spaces.map(s => ({ t: 's' as const, id: s.id })); + // Remove the space from its current position + const newLayout = currentLayout.filter(item => !(item.t === 's' && item.id === spaceId)); + // Create a new folder ID + const folderId = `folder-${Date.now()}`; + // Insert the folder where the space was + const insertIdx = currentLayout.findIndex(item => item.t === 's' && item.id === spaceId); + newLayout.splice(insertIdx >= 0 ? insertIdx : newLayout.length, 0, { t: 'f', id: folderId }); + + const folderData: Record = {}; + // Preserve existing folders + for (const f of folders) { + folderData[f.id] = { name: f.name, color: f.color, spaceIds: f.spaceIds }; + } + // Add new folder + folderData[folderId] = { name: null, color: null, spaceIds: [spaceId] }; + + await updateSpaceLayout(newLayout, folderData); + addToast('Folder created', 'success', 3000); + }; + + const handleMoveToFolder = async (spaceId: string, folderId: string) => { + const currentLayout = spaceLayout || spaces.map(s => ({ t: 's' as const, id: s.id })); + const newLayout = currentLayout.filter(item => !(item.t === 's' && item.id === spaceId)); + + const folderData: Record = {}; + for (const f of folders) { + folderData[f.id] = { + name: f.name, + color: f.color, + spaceIds: f.id === folderId ? [...f.spaceIds, spaceId] : f.spaceIds, + }; + } + + await updateSpaceLayout(newLayout, folderData); + addToast('Moved to folder', 'success', 3000); + }; + + const handleRemoveFromFolder = async (spaceId: string) => { + const currentLayout = spaceLayout || spaces.map(s => ({ t: 's' as const, id: s.id })); + + // Find which folder contains this space + const containingFolder = folders.find(f => f.spaceIds.includes(spaceId)); + if (!containingFolder) return; + + // Find the folder's position in layout and insert the space after it + const folderIdx = currentLayout.findIndex(item => item.t === 'f' && item.id === containingFolder.id); + const newLayout = [...currentLayout]; + newLayout.splice(folderIdx + 1, 0, { t: 's', id: spaceId }); + + const folderData: Record = {}; + for (const f of folders) { + folderData[f.id] = { + name: f.name, + color: f.color, + spaceIds: f.id === containingFolder.id + ? f.spaceIds.filter(sid => sid !== spaceId) + : f.spaceIds, + }; + } + + // If folder is now empty, remove it from layout + if (folderData[containingFolder.id]!.spaceIds.length === 0) { + const idx = newLayout.findIndex(item => item.t === 'f' && item.id === containingFolder.id); + if (idx >= 0) newLayout.splice(idx, 1); + delete folderData[containingFolder.id]; + } + + await updateSpaceLayout(newLayout, folderData); + addToast('Removed from folder', 'success', 3000); + }; + + // ─── Context menu handlers ──────────────────────────────────────────── + + const handleSpaceContextMenu = (e: React.MouseEvent, spaceId: string) => { + e.preventDefault(); + e.stopPropagation(); + const space = spaces.find(s => s.id === spaceId); + if (!space) return; + const isOwner = space.ownerId === getMyUserIdForOrigin((space as TaggedSpace)._instanceOrigin ?? ''); + + // Check if space is in a folder + const inFolder = folders.some(f => f.spaceIds.includes(spaceId)); + const availableFolders = folders.filter(f => !f.spaceIds.includes(spaceId)); + + const items: ContextMenuItem[] = [ + { + key: 'invite', + type: 'action', + label: 'Invite People', + icon: , + onClick: async () => { + try { + const code = await useSpaceStore.getState().generateInvite(spaceId); + const origin = (space as TaggedSpace)._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); + } + }, + }, + { + key: 'create-folder', + type: 'action', + label: 'Create Folder', + hidden: inFolder, + icon: , + onClick: () => handleCreateFolder(spaceId), + }, + ]; + + // Move to Folder submenu (only for standalone spaces when folders exist) + if (!inFolder && availableFolders.length > 0) { + items.push({ + key: 'move-to-folder', + type: 'submenu', + label: 'Move to Folder', + icon: , + children: availableFolders.map(f => ({ + key: f.id, + type: 'action' as const, + label: f.name || 'Unnamed Folder', + onClick: () => handleMoveToFolder(spaceId, f.id), + })), + }); + } + + // Remove from Folder (only for spaces in a folder) + if (inFolder) { + items.push({ + key: 'remove-from-folder', + type: 'action', + label: 'Remove from Folder', + icon: , + onClick: () => handleRemoveFromFolder(spaceId), + }); + } + + items.push({ key: 'sep', type: 'separator' }); + + items.push({ + key: 'transfer', + type: 'action', + label: 'Transfer Ownership', + hidden: !isOwner, + icon: , + onClick: () => setTransferModalSpaceId(spaceId), + }); + + items.push({ + key: 'leave', + type: 'action', + label: 'Leave Space', + hidden: isOwner, + danger: true, + icon: , + onClick: () => setLeaveConfirmSpaceId(spaceId), + }); + + openContextMenu({ x: e.clientX, y: e.clientY }, items); + }; + + const handleChannelContextMenu = (e: React.MouseEvent, channelId: string) => { + e.preventDefault(); + e.stopPropagation(); + const canManageChannels = hasPermissionBit( + channelPermissions.get(channelId), + PermissionBits.MANAGE_CHANNELS + ); + if (!canManageChannels) return; + + const items: ContextMenuItem[] = [ + { + key: 'channel-settings', + type: 'action', + label: 'Channel Settings', + icon: , + onClick: () => openModal('channelSettings', { channelId }), + }, + { key: 'ch-sep', type: 'separator' }, + { + key: 'delete-channel', + type: 'action', + label: 'Delete Channel', + danger: true, + icon: , + onClick: () => setDeleteChannelId(channelId), + }, + ]; + + openContextMenu({ x: e.clientX, y: e.clientY }, items); + }; + + const handleCategoryContextMenu = (e: React.MouseEvent, categoryId: string) => { + e.preventDefault(); + e.stopPropagation(); + const canManageChannels = hasPermissionBit(myPerms, PermissionBits.MANAGE_CHANNELS); + if (!canManageChannels) return; + + openContextMenu({ x: e.clientX, y: e.clientY }, [ + { + key: 'category-settings', + type: 'action', + label: 'Category Settings', + icon: , + onClick: () => openModal('categorySettings', { categoryId }), + }, + { key: 'cat-sep', type: 'separator' }, + { + key: 'delete-category', + type: 'action', + label: 'Delete Category', + danger: true, + icon: , + onClick: () => setDeleteCategoryId(categoryId), + }, + ]); + }; + const selectedSpace = spaces.find(s => s.id === selectedSpaceId); const myPerms = selectedSpaceId ? spacePermissions.get(selectedSpaceId) : undefined; const canInvite = hasPermissionBit(myPerms, PermissionBits.CREATE_INVITE); @@ -147,6 +385,7 @@ export function MobileSpacesScreen() {