Files
backspace/packages/web/src/components/layout/MobileSpacesScreen.tsx
T

914 lines
44 KiB
TypeScript

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<string | null>(currentSpaceId);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const [showAddSheet, setShowAddSheet] = useState(false);
const [leaveConfirmSpaceId, setLeaveConfirmSpaceId] = useState<string | null>(null);
const [deleteChannelId, setDeleteChannelId] = useState<string | null>(null);
const [deleteCategoryId, setDeleteCategoryId] = useState<string | null>(null);
const [transferModalSpaceId, setTransferModalSpaceId] = useState<string | null>(null);
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
const [voiceJoinChannelId, setVoiceJoinChannelId] = useState<string | null>(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<string, Channel[]>();
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<string>();
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<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
// 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<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
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<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
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<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
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<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
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: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM3 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 019.374 21c-2.331 0-4.512-.645-6.374-1.766z" /></svg>,
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: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" /></svg>,
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: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg>,
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: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" /></svg>,
onClick: () => handleRemoveFromFolder(spaceId),
});
}
items.push({ key: 'sep', type: 'separator' });
items.push({
key: 'transfer',
type: 'action',
label: 'Transfer Ownership',
hidden: !isOwner,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" /></svg>,
onClick: () => setTransferModalSpaceId(spaceId),
});
items.push({
key: 'leave',
type: 'action',
label: 'Leave Space',
hidden: isOwner,
danger: true,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>,
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: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
onClick: () => openModal('channelSettings', { channelId }),
},
{ key: 'ch-sep', type: 'separator' },
{
key: 'delete-channel',
type: 'action',
label: 'Delete Channel',
danger: true,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>,
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: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
onClick: () => openModal('categorySettings', { categoryId }),
},
{ key: 'cat-sep', type: 'separator' },
{
key: 'delete-category',
type: 'action',
label: 'Delete Category',
danger: true,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>,
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 (
<div key={channel.id}>
<button
onClick={() => handleChannelTap(channel)}
onContextMenu={(e) => handleChannelContextMenu(e, channel.id)}
data-context-menu={`channel-${channel.id}`}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
isActive ? 'bg-interactive-selected text-txt-primary' :
isInVoice ? 'bg-accent-mint/10 text-accent-mint' :
'text-txt-secondary hover:bg-interactive-hover hover:text-txt-primary'
}`}
>
{isVoice ? (
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
</svg>
) : (
<span className="text-sm shrink-0 opacity-60">#</span>
)}
<span className={`flex-1 text-sm truncate ${isUnread ? 'font-semibold text-txt-primary' : ''}`}>
{channel.name}
</span>
{isUnread && <span className="w-2 h-2 rounded-full bg-txt-primary shrink-0" />}
</button>
{/* Inline voice users */}
{isVoice && vUsers.length > 0 && (
<div className="ml-7 mt-0.5 space-y-0.5">
{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 (
<div
key={userId}
data-context-menu={`voice-user-${userId}`}
className="px-3 py-1 rounded-lg hover:bg-interactive-hover transition-colors"
onClick={() => openModal('userProfile', { userId: member?.user.homeUserId ?? userId })}
onContextMenu={(e) => handleVoiceUserContextMenu(e, userId, channel.id)}
>
<VoiceUserRow
userId={member?.user.homeUserId ?? userId}
displayName={displayName}
avatar={avatar}
avatarColor={avatarColor ?? undefined}
isMuted={isMuted}
isDeafened={isDeafened}
isCameraOn={hasCamera}
isScreenSharing={isScreenSharing}
isServerMuted={isSpaceMuted}
isServerDeafened={isSpaceDeafened}
isPermissionMuted={isPermissionMuted}
isLocallyMuted={userId !== user?.id && (participantMutes.get(userId) ?? false)}
isSpeaking={speakingUserIds.has(userId)}
size="compact"
/>
</div>
);
})}
</div>
)}
</div>
);
};
return (
<div className="flex h-full">
{/* Space strip */}
<div className="w-[60px] shrink-0 glass-strip flex flex-col items-center py-2 gap-1.5 overflow-y-auto no-scrollbar">
{/* Home / DMs button */}
<button
onClick={() => {
setMobileTab('dms');
navigate('/channels/@me');
}}
className="w-10 h-10 rounded-2xl bg-surface-elevated flex items-center justify-center text-txt-secondary hover:text-txt-primary hover:bg-accent-primary transition-all"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
</button>
<div className="w-8 h-px bg-border-soft my-0.5" />
{/* 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 (
<div key={`folder-${folder.id}`} className="relative">
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-1 flex items-center">
<div className={`bg-white rounded-r-full transition-all duration-200 w-full ${
isSelected ? 'h-8' : hasUnread ? 'h-2' : 'h-0'
}`} />
</div>
<button
onClick={() => setOpenFolderId(folder.id)}
onContextMenu={(e) => e.preventDefault()}
className={`w-10 h-10 rounded-2xl flex items-center justify-center transition-all ${
isSelected ? 'rounded-xl ring-2 ring-accent-primary/50' : 'hover:rounded-xl'
} bg-surface-elevated`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke={folder.color || 'currentColor'} strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
</button>
</div>
);
}
// 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 (
<div key={space.id} className="relative">
{/* Unread / selected pill */}
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-1 flex items-center">
<div
className={`bg-white rounded-r-full transition-all duration-200 w-full ${
isSelected ? 'h-8' : hasUnread ? 'h-2' : 'h-0'
}`}
/>
</div>
<button
onClick={() => handleSpaceSelect(space.id)}
onContextMenu={(e) => handleSpaceContextMenu(e, space.id)}
className={`w-10 h-10 rounded-2xl overflow-hidden flex items-center justify-center transition-all ${
isSelected ? 'rounded-xl ring-2 ring-accent-primary/50' : 'hover:rounded-xl'
}`}
style={!iconUrl ? { background: grad.gradient } : undefined}
>
{iconUrl ? (
<img src={iconUrl} alt={space.name} className="w-full h-full object-cover" />
) : (
<span className="text-[15px] font-bold text-white">
{space.name.charAt(0).toUpperCase()}
</span>
)}
</button>
{(() => {
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 (
<div className="absolute -bottom-0.5 -right-0.5 w-[14px] h-[14px] rounded-full bg-surface-base flex items-center justify-center">
{isDisconnected ? (
<div className="w-[8px] h-[8px] rounded-full bg-accent-amber" />
) : (
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary/80">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
)}
</div>
);
})()}
</div>
);
})}
<div className="w-8 h-px bg-border-soft my-0.5" />
{/* Add space button */}
<button
onClick={() => setShowAddSheet(true)}
className="w-10 h-10 rounded-2xl bg-surface-elevated flex items-center justify-center text-accent-mint hover:bg-accent-mint/10 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
</div>
{/* Channel list */}
<div className="flex-1 flex flex-col min-w-0 bg-surface-channel">
{/* Space header */}
{selectedSpace && (
<header className="h-12 flex items-center gap-2 px-3 border-b border-border-soft shrink-0">
<h1 className="flex-1 text-sm font-semibold text-txt-primary truncate">
{selectedSpace.name}
</h1>
{canInvite && (
<button
onClick={() => openModal('invite', { spaceId: selectedSpaceId })}
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM3 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 019.374 21c-2.331 0-4.512-.645-6.374-1.766z" />
</svg>
</button>
)}
{canManageSpace && (
<button
onClick={() => openModal('spaceSettings', { spaceId: selectedSpaceId })}
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
)}
</header>
)}
{/* Channel list */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{/* 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 (
<div key={category.id} className="mt-3">
<button
onClick={() => toggleCategory(category.id)}
onContextMenu={(e) => handleCategoryContextMenu(e, category.id)}
className="flex items-center gap-1 px-1 py-1 w-full text-left"
>
<svg
className={`w-3 h-3 text-txt-tertiary transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<span className="text-[11px] font-semibold uppercase tracking-wide text-txt-tertiary truncate">
{category.name}
</span>
</button>
{!isCollapsed && catChannels.map(renderChannelItem)}
</div>
);
})}
{/* Empty state */}
{spaceChannels.length === 0 && selectedSpaceId && (
<div className="flex flex-col items-center justify-center h-32 opacity-80">
<Mascot state="idle" className="w-20 h-20 mb-2" />
<p className="text-txt-tertiary text-sm">No channels yet.</p>
</div>
)}
</div>
</div>
{/* Add Space bottom sheet */}
{showAddSheet && (
<>
<div className="fixed inset-0 z-[300] bg-black/50" onClick={() => setShowAddSheet(false)} />
<div
className="fixed bottom-0 left-0 right-0 z-[301] rounded-t-2xl glass-bubble animate-slide-up-sheet"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1" />
<div className="py-2">
<button
onClick={() => { setShowAddSheet(false); openModal('createSpace'); }}
className="w-full flex items-center gap-3 px-5 py-3 text-left hover:bg-interactive-hover"
>
<svg className="w-5 h-5 text-accent-mint" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span className="text-sm text-txt-primary">Create Space</span>
</button>
<button
onClick={() => { setShowAddSheet(false); openModal('joinSpace'); }}
className="w-full flex items-center gap-3 px-5 py-3 text-left hover:bg-interactive-hover"
>
<svg className="w-5 h-5 text-accent-sky" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
<span className="text-sm text-txt-primary">Join Space</span>
</button>
<button
onClick={() => { setShowAddSheet(false); pushMobileScreen('explore'); }}
className="w-full flex items-center gap-3 px-5 py-3 text-left hover:bg-interactive-hover"
>
<svg className="w-5 h-5 text-accent-lavender" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<span className="text-sm text-txt-primary">Explore Spaces</span>
</button>
</div>
</div>
</>
)}
{/* Confirmation dialogs */}
{leaveConfirmSpaceId && (
<ConfirmDialog
isOpen={true}
title="Leave Space"
description="Are you sure you want to leave this space? You'll need a new invite to rejoin."
confirmLabel="Leave"
variant="danger"
onConfirm={async () => {
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 && (
<ConfirmDialog
isOpen={true}
title="Delete Channel"
description="Are you sure? This will permanently delete the channel and all its messages."
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
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 && (
<ConfirmDialog
isOpen={true}
title="Delete Category"
description="Are you sure? Channels in this category will be moved to uncategorized."
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
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 && (
<TransferOwnershipModal
spaceId={transferModalSpaceId}
onClose={() => setTransferModalSpaceId(null)}
/>
)}
{openFolderId && (() => {
const folder = folders.find(f => f.id === openFolderId);
if (!folder) return null;
return (
<MobileFolderSheet
folder={folder}
onClose={() => setOpenFolderId(null)}
onSelectSpace={(spaceId) => {
handleSpaceSelect(spaceId);
}}
onUpdateFolder={handleUpdateFolder}
onUngroup={handleUngroup}
/>
);
})()}
</div>
);
}