914 lines
44 KiB
TypeScript
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>
|
|
);
|
|
}
|