import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { useUIStore } from '../../stores/uiStore'; 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, SpaceFolder } from '@backspace/shared'; import { Mascot } from '../ui/Mascot'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { TransferOwnershipModal } from '../modals/TransferOwnershipModal'; import { MobileFolderSheet } from './MobileFolderSheet'; import { useInstanceStore } from '../../stores/instanceStore'; import { VoiceUserRow } from '../voice/VoiceUserRow'; import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems'; type ResolvedItem = | { type: 'space'; space: TaggedSpace } | { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[] }; export function MobileSpacesScreen() { const spaces = useSpaceStore((s) => s.spaces); const channels = useSpaceStore((s) => s.channels); const categories = useSpaceStore((s) => s.categories); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail); const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace); const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap); 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); const voiceUsers = useVoiceStore((s) => s.voiceUsers); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const participantMutes = useVoiceStore((s) => s.participantMutes); const speakingUserIds = useVoiceStore((s) => s.speakingUserIds); const members = useSpaceStore((s) => s.members); const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); 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); // Local state 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); const [openFolderId, setOpenFolderId] = useState(null); const [voiceJoinChannelId, setVoiceJoinChannelId] = useState(null); // Sync selected space with store's current space useEffect(() => { if (selectedSpaceId) { setCurrentSpace(selectedSpaceId); loadSpaceDetail(selectedSpaceId); } }, [selectedSpaceId, setCurrentSpace, loadSpaceDetail]); // Auto-select first space if none selected useEffect(() => { if (!selectedSpaceId && spaces.length > 0 && spaces[0]) { setSelectedSpaceId(spaces[0].id); } }, [selectedSpaceId, spaces]); // Check if loaded channels belong to selected space const spaceChannels = useMemo(() => { if (!selectedSpaceId) return []; return channels.filter(c => channelToSpaceMap.get(c.id) === selectedSpaceId); }, [channels, channelToSpaceMap, selectedSpaceId]); // Group channels by category const channelsByCategory = useMemo(() => { const map = new Map(); for (const ch of spaceChannels) { if (!ch.categoryId) continue; let arr = map.get(ch.categoryId); if (!arr) { arr = []; map.set(ch.categoryId, arr); } arr.push(ch); } for (const [key, arr] of map) { map.set(key, arr.sort((a, b) => a.position - b.position)); } return map; }, [spaceChannels]); const uncategorizedChannels = useMemo(() => spaceChannels .filter(c => !c.categoryId) .sort((a, b) => a.position - b.position), [spaceChannels] ); const sortedCategories = useMemo(() => [...categories].sort((a, b) => a.position - b.position), [categories] ); // Check if space has unread text channels const spaceHasUnread = (spaceId: string): boolean => { for (const chId of unreadChannels) { if (channelToSpaceMap.get(chId) === spaceId && !voiceChannelIds.has(chId)) { return true; } } return false; }; // ─── Folder-aware layout resolution ───────────────────────────────── const spaceMap = useMemo(() => new Map(spaces.map(s => [s.id, s])), [spaces]); const folderMap = useMemo(() => new Map(folders.map(f => [f.id, f])), [folders]); const resolvedLayout = useMemo((): ResolvedItem[] => { const result: ResolvedItem[] = []; const accountedSpaceIds = new Set(); if (spaceLayout && spaceLayout.length > 0) { for (const item of spaceLayout) { if (item.t === 's') { const space = spaceMap.get(item.id); if (space) { result.push({ type: 'space', space }); accountedSpaceIds.add(item.id); } } else if (item.t === 'f') { const folder = folderMap.get(item.id); if (folder) { const folderSpaces = folder.spaceIds .map(sid => spaceMap.get(sid)) .filter((s): s is TaggedSpace => !!s); if (folderSpaces.length > 0) { result.push({ type: 'folder', folder, spaces: folderSpaces }); for (const s of folderSpaces) accountedSpaceIds.add(s.id); } } } } } for (const space of spaces) { if (!accountedSpaceIds.has(space.id)) { result.push({ type: 'space', space }); } } return result; }, [spaceLayout, spaces, spaceMap, folderMap]); const handleSpaceSelect = (spaceId: string) => { setSelectedSpaceId(spaceId); setCollapsedCategories(new Set()); }; const handleChannelTap = (channel: Channel) => { if (channel.type === 'voice') { setVoiceJoinChannelId(channel.id); return; } if (selectedSpaceId) { setLastChannel(selectedSpaceId, channel.id); navigate(`/channels/${selectedSpaceId}/${channel.id}`); } pushMobileScreen('channel-chat', { channelId: channel.id, spaceId: selectedSpaceId || '', }); }; const toggleCategory = (categoryId: string) => { setCollapsedCategories(prev => { const next = new Set(prev); if (next.has(categoryId)) next.delete(categoryId); else next.add(categoryId); return next; }); }; // ─── 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); }; const handleUngroup = async (folderId: string) => { const folder = folders.find(f => f.id === folderId); if (!folder) return; const currentLayout = spaceLayout || spaces.map(s => ({ t: 's' as const, id: s.id })); const folderIdx = currentLayout.findIndex(item => item.t === 'f' && item.id === folderId); // Replace folder with its spaces const newLayout = [...currentLayout]; const spaceItems = folder.spaceIds.map(sid => ({ t: 's' as const, id: sid })); newLayout.splice(folderIdx, 1, ...spaceItems); const folderData: Record = {}; for (const f of folders) { if (f.id !== folderId) { folderData[f.id] = { name: f.name, color: f.color, spaceIds: f.spaceIds }; } } await updateSpaceLayout(newLayout, folderData); setOpenFolderId(null); addToast('Folder ungrouped', 'success', 3000); }; const handleUpdateFolder = async (folderId: string, updates: { name?: string | null; color?: string | null }) => { const folderData: Record = {}; for (const f of folders) { folderData[f.id] = { name: f.id === folderId && updates.name !== undefined ? updates.name : f.name, color: f.id === folderId && updates.color !== undefined ? updates.color : f.color, spaceIds: f.spaceIds, }; } const currentLayout = spaceLayout || spaces.map(s => ({ t: 's' as const, id: s.id })); await updateSpaceLayout(currentLayout, folderData); }; // ─── 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); const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE); const handleVoiceUserContextMenu = useCallback( (e: React.MouseEvent, userId: string, channelId: string) => { if (userId === user?.id) return; e.preventDefault(); e.stopPropagation(); const modItems = buildVoiceModMenuItems(userId, channelId); const items: ContextMenuItem[] = [...modItems]; if (modItems.length > 0) { items.push({ key: 'mod-end-sep', type: 'separator' }); } const isUserMuted = useVoiceStore.getState().participantMutes.get(userId) ?? false; items.push({ key: 'mute-user', type: 'checkbox', label: 'Mute User', checked: isUserMuted, onChange: (checked) => useVoiceStore.getState().setParticipantMute(userId, checked), }); items.push({ key: 'vol-sep', type: 'separator' }); items.push({ key: 'volume', type: 'custom', render: () => React.createElement(VolumeSliderItem, { userId }), }); openContextMenu({ x: e.clientX, y: e.clientY }, items); }, [user?.id, openContextMenu], ); const renderChannelItem = (channel: Channel) => { const isVoice = channel.type === 'voice'; const isActive = !isVoice && currentChannelId === channel.id; const isUnread = unreadChannels.has(channel.id) && !isActive; const isInVoice = currentVoiceChannelId === channel.id; const vUsers = voiceUsers.get(channel.id) || []; const canView = hasPermissionBit(channelPermissions.get(channel.id), PermissionBits.VIEW_CHANNEL); if (canView === false) return null; const spaceId = channelToSpaceMap.get(channel.id); return (
{/* Inline voice users */} {isVoice && vUsers.length > 0 && (
{vUsers.map((userId) => { const member = members.find(m => m.userId === userId); const displayName = member?.user.displayName ?? member?.user.username ?? userId; const avatar = member?.user.avatar ?? null; const avatarColor = member?.user.avatarColor; const wsStatus = voiceUserStates.get(userId); const isMuted = wsStatus?.isMuted ?? false; const isDeafened = wsStatus?.isDeafened ?? false; const hasCamera = wsStatus?.isCameraOn ?? false; const isScreenSharing = wsStatus?.isScreenSharing ?? false; const isSpaceMuted = spaceMutedUserIds.has(`${spaceId}:${userId}`); const isSpaceDeafened = spaceDeafenedUserIds.has(`${spaceId}:${userId}`); const isPermissionMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`); return (
openModal('userProfile', { userId: member?.user.homeUserId ?? userId })} onContextMenu={(e) => handleVoiceUserContextMenu(e, userId, channel.id)} >
); })}
)}
); }; return (
{/* Space strip */}
{/* Home / DMs button */}
{/* Space icons (folder-aware) */} {resolvedLayout.map((item) => { if (item.type === 'folder') { const folder = item.folder; const hasUnread = item.spaces.some(s => spaceHasUnread(s.id)); const isSelected = item.spaces.some(s => s.id === selectedSpaceId); return (
); } // Standalone space — full space icon rendering with federation badge and context menu const space = item.space; const isSelected = space.id === selectedSpaceId; const hasUnread = spaceHasUnread(space.id); const iconUrl = space.icon ? (space.icon.startsWith('http') || space.icon.startsWith('/') ? space.icon : `/api/uploads/${space.icon}`) : null; const grad = getSpaceGradient(space.id, space.name, space.avatarColor); return (
{/* Unread / selected pill */}
{(() => { const origin = (space as TaggedSpace)._instanceOrigin; if (!origin) return null; const instances = useInstanceStore.getState().instances; const inst = instances.find(i => i.origin === origin); const isDisconnected = inst ? inst.status !== 'connected' : false; return (
{isDisconnected ? (
) : ( )}
); })()}
); })}
{/* Add space button */}
{/* Channel list */}
{/* Space header */} {selectedSpace && (

{selectedSpace.name}

{canInvite && ( )} {canManageSpace && ( )}
)} {/* Channel list */}
{/* Uncategorized channels */} {uncategorizedChannels.map(renderChannelItem)} {/* Categorized channels */} {sortedCategories.map(category => { const catChannels = channelsByCategory.get(category.id) || []; if (catChannels.length === 0) return null; const isCollapsed = collapsedCategories.has(category.id); return (
{!isCollapsed && catChannels.map(renderChannelItem)}
); })} {/* Empty state */} {spaceChannels.length === 0 && selectedSpaceId && (

No channels yet.

)}
{/* Add Space bottom sheet */} {showAddSheet && ( <>
setShowAddSheet(false)} />
)} {/* Confirmation dialogs */} {leaveConfirmSpaceId && ( { try { await useSpaceStore.getState().leaveSpace(leaveConfirmSpaceId); if (selectedSpaceId === leaveConfirmSpaceId) setSelectedSpaceId(null); addToast('Left space', 'success', 3000); } catch { addToast('Failed to leave space', 'warning', 3000); } setLeaveConfirmSpaceId(null); }} onClose={() => setLeaveConfirmSpaceId(null)} /> )} {deleteChannelId && ( { try { await useSpaceStore.getState().deleteChannel(deleteChannelId); addToast('Channel deleted', 'success', 3000); } catch { addToast('Failed to delete channel', 'warning', 3000); } setDeleteChannelId(null); }} onClose={() => setDeleteChannelId(null)} /> )} {deleteCategoryId && ( { try { await useSpaceStore.getState().deleteCategory(deleteCategoryId); addToast('Category deleted', 'success', 3000); } catch { addToast('Failed to delete category', 'warning', 3000); } setDeleteCategoryId(null); }} onClose={() => setDeleteCategoryId(null)} /> )} {transferModalSpaceId && ( setTransferModalSpaceId(null)} /> )} {openFolderId && (() => { const folder = folders.find(f => f.id === openFolderId); if (!folder) return null; return ( setOpenFolderId(null)} onSelectSpace={(spaceId) => { handleSpaceSelect(spaceId); }} onUpdateFolder={handleUpdateFolder} onUngroup={handleUngroup} /> ); })()}
); }