refactor: rename Server → Space across entire codebase

Renames all domain terminology from "Server" to "Space" throughout the
application — database schema, API routes, shared types, stores, components,
and UI strings. Files renamed: ServerSidebar → SpaceSidebar, CreateServer →
CreateSpace, JoinServer → JoinSpace, ServerSettings → SpaceSettings,
serverStore → spaceStore, routes/servers → routes/spaces.
This commit is contained in:
Jannis Braun
2026-03-08 20:08:24 +01:00
parent d18d9c51f7
commit fc06e25731
62 changed files with 1342 additions and 1342 deletions
@@ -1,23 +1,23 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useExploreStore, type TaggedExploreServer } from '../../stores/exploreStore';
import { useServerStore } from '../../stores/serverStore';
import { useExploreStore, type TaggedExploreSpace } from '../../stores/exploreStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { LoadingSpinner } from '../ui/LoadingSpinner';
import { getServerGradient } from '../../utils/gradients';
import { getSpaceGradient } from '../../utils/gradients';
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
export function ExplorePage() {
const navigate = useNavigate();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const servers = useExploreStore((s) => s.servers);
const spaces = useExploreStore((s) => s.spaces);
const myRequests = useExploreStore((s) => s.myRequests);
const isLoading = useExploreStore((s) => s.isLoading);
const discoveryEnabled = useExploreStore((s) => s.discoveryEnabled);
const error = useExploreStore((s) => s.error);
const searchQuery = useExploreStore((s) => s.searchQuery);
const setSearchQuery = useExploreStore((s) => s.setSearchQuery);
const fetchServers = useExploreStore((s) => s.fetchServers);
const fetchSpaces = useExploreStore((s) => s.fetchSpaces);
const fetchMyRequests = useExploreStore((s) => s.fetchMyRequests);
const [joinedCollapsed, setJoinedCollapsed] = useState(false);
@@ -26,18 +26,18 @@ export function ExplorePage() {
// Fetch on mount
useEffect(() => {
fetchServers();
fetchSpaces();
fetchMyRequests();
}, [fetchServers, fetchMyRequests]);
}, [fetchSpaces, fetchMyRequests]);
// Debounced search
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
fetchServers(value || undefined);
fetchSpaces(value || undefined);
}, 300);
}, [setSearchQuery, fetchServers]);
}, [setSearchQuery, fetchSpaces]);
useEffect(() => {
return () => {
@@ -45,23 +45,23 @@ export function ExplorePage() {
};
}, []);
const handleJoinSuccess = (serverId: string) => {
setCurrentServer(serverId);
navigate(`/channels/${serverId}`);
const handleJoinSuccess = (spaceId: string) => {
setCurrentSpace(spaceId);
navigate(`/channels/${spaceId}`);
};
const unjoinedServers = useMemo(
() => servers.filter(s => !s.joined),
[servers],
const unjoinedSpaces = useMemo(
() => spaces.filter(s => !s.joined),
[spaces],
);
const joinedServers = useMemo(
() => servers.filter(s => s.joined),
[servers],
const joinedSpaces = useMemo(
() => spaces.filter(s => s.joined),
[spaces],
);
const hasAnyServers = servers.length > 0;
const hasUnjoined = unjoinedServers.length > 0;
const hasJoined = joinedServers.length > 0;
const hasAnySpaces = spaces.length > 0;
const hasUnjoined = unjoinedSpaces.length > 0;
const hasJoined = joinedSpaces.length > 0;
return (
<div className="flex-1 flex flex-col bg-surface-chat h-full">
@@ -79,7 +79,7 @@ export function ExplorePage() {
<div className="relative flex-1 max-w-xs ml-2">
<input
type="text"
placeholder="Search servers..."
placeholder="Search spaces..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full bg-surface-base text-txt-primary text-sm px-3 py-1.5 rounded-[4px] outline-none placeholder:text-txt-tertiary/50 focus:ring-1 focus:ring-accent-primary transition-all"
@@ -105,11 +105,11 @@ export function ExplorePage() {
<div className="flex-1 overflow-y-auto">
{!discoveryEnabled && (
<div className="mx-6 mt-4 p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
Server discovery is disabled by the instance administrator.
Space discovery is disabled by the instance administrator.
</div>
)}
{isLoading && servers.length === 0 ? (
{isLoading && spaces.length === 0 ? (
<div className="flex-1 flex items-center justify-center h-64">
<LoadingSpinner />
</div>
@@ -117,47 +117,47 @@ export function ExplorePage() {
<div className="mx-6 mt-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-sm text-txt-danger">
{error}
</div>
) : !hasAnyServers ? (
/* True empty state — no discoverable servers at all */
) : !hasAnySpaces ? (
/* True empty state — no discoverable spaces at all */
<div className="flex flex-col items-center justify-center h-64 opacity-60">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary mb-3">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
</svg>
<p className="text-txt-tertiary text-sm">
{searchQuery
? 'No servers match your search.'
: 'No servers have been made discoverable yet.'}
? 'No spaces match your search.'
: 'No spaces have been made discoverable yet.'}
</p>
</div>
) : (
<div className="p-6 space-y-6">
{/* All-joined success banner (only when no unjoined servers remain) */}
{/* All-joined success banner (only when no unjoined spaces remain) */}
{!hasUnjoined && hasJoined && !searchQuery && (
<div className="flex items-center gap-2.5 px-4 py-2.5 bg-accent-mint/10 border border-accent-mint/20 rounded-lg">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-accent-mint flex-shrink-0">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<span className="text-[13px] text-accent-mint">
You're in all discoverable servers
You're in all discoverable spaces
</span>
</div>
)}
{/* Unjoined servers grid */}
{/* Unjoined spaces grid */}
{hasUnjoined && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{unjoinedServers.map((server) => (
<ServerCard
key={`${server.id}:${server._instanceOrigin}`}
server={server}
isPending={myRequests.some(r => r.serverId === server.id && r.status === 'pending')}
{unjoinedSpaces.map((space) => (
<SpaceCard
key={`${space.id}:${space._instanceOrigin}`}
space={space}
isPending={myRequests.some(r => r.spaceId === space.id && r.status === 'pending')}
onJoinSuccess={handleJoinSuccess}
/>
))}
</div>
)}
{/* Joined servers section */}
{/* Joined spaces section */}
{hasJoined && (
<div>
<button
@@ -177,16 +177,16 @@ export function ExplorePage() {
Joined
</span>
<span className="text-xs text-txt-tertiary/60">
{joinedServers.length}
{joinedSpaces.length}
</span>
</button>
{!joinedCollapsed && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{joinedServers.map((server) => (
<ServerCard
key={`${server.id}:${server._instanceOrigin}`}
server={server}
{joinedSpaces.map((space) => (
<SpaceCard
key={`${space.id}:${space._instanceOrigin}`}
space={space}
isPending={false}
onJoinSuccess={handleJoinSuccess}
/>
@@ -202,14 +202,14 @@ export function ExplorePage() {
);
}
function ServerCard({
server,
function SpaceCard({
space,
isPending,
onJoinSuccess,
}: {
server: TaggedExploreServer;
space: TaggedExploreSpace;
isPending: boolean;
onJoinSuccess: (serverId: string) => void;
onJoinSuccess: (spaceId: string) => void;
}) {
const publicJoin = useExploreStore((s) => s.publicJoin);
const requestJoin = useExploreStore((s) => s.requestJoin);
@@ -220,19 +220,19 @@ function ServerCard({
const [requestSent, setRequestSent] = useState(isPending);
const [joinError, setJoinError] = useState('');
const gradient = getServerGradient(server.id, server.name);
const isPublic = server.visibility === 'public';
const isJoined = server.joined === true;
const originLabel = server._instanceOrigin
? (() => { try { return new URL(server._instanceOrigin).host; } catch { return server._instanceOrigin; } })()
const gradient = getSpaceGradient(space.id, space.name);
const isPublic = space.visibility === 'public';
const isJoined = space.joined === true;
const originLabel = space._instanceOrigin
? (() => { try { return new URL(space._instanceOrigin).host; } catch { return space._instanceOrigin; } })()
: null;
const handlePublicJoin = async () => {
setJoining(true);
setJoinError('');
try {
const fullServer = await publicJoin(server);
onJoinSuccess(fullServer.id);
const fullSpace = await publicJoin(space);
onJoinSuccess(fullSpace.id);
} catch (err) {
setJoinError(err instanceof Error ? err.message : 'Failed to join');
setJoining(false);
@@ -243,7 +243,7 @@ function ServerCard({
setJoining(true);
setJoinError('');
try {
await requestJoin(server, requestMessage.trim() || undefined);
await requestJoin(space, requestMessage.trim() || undefined);
setRequestSent(true);
setShowRequestForm(false);
} catch (err) {
@@ -253,8 +253,8 @@ function ServerCard({
}
};
const handleViewServer = () => {
onJoinSuccess(server.id);
const handleViewSpace = () => {
onJoinSuccess(space.id);
};
return (
@@ -265,15 +265,15 @@ function ServerCard({
}`}>
{/* Banner / Icon area */}
<div className="h-32 relative flex items-center justify-center" style={{ background: gradient.gradient }}>
{server.icon ? (
{space.icon ? (
<img
src={server.icon.startsWith('http') ? server.icon : `/api/uploads/${server.icon}`}
alt={server.name}
src={space.icon.startsWith('http') ? space.icon : `/api/uploads/${space.icon}`}
alt={space.name}
className="w-16 h-16 rounded-2xl object-cover shadow-lg"
/>
) : (
<span className="text-3xl font-bold text-white/90 drop-shadow-md">
{server.name.charAt(0).toUpperCase()}
{space.name.charAt(0).toUpperCase()}
</span>
)}
@@ -312,11 +312,11 @@ function ServerCard({
{/* Content */}
<div className="p-4 flex flex-col flex-1">
<h3 className="text-[15px] font-bold text-txt-primary truncate mb-1">{server.name}</h3>
<h3 className="text-[15px] font-bold text-txt-primary truncate mb-1">{space.name}</h3>
{server.description ? (
{space.description ? (
<p className="text-[13px] text-txt-secondary line-clamp-2 mb-3 flex-1">
{server.description}
{space.description}
</p>
) : (
<p className="text-[13px] text-txt-tertiary italic mb-3 flex-1">No description</p>
@@ -327,7 +327,7 @@ function ServerCard({
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="opacity-60">
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
</svg>
{server.memberCount} {server.memberCount === 1 ? 'member' : 'members'}
{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'}
</span>
</div>
@@ -338,10 +338,10 @@ function ServerCard({
{isJoined ? (
<button
onClick={handleViewServer}
onClick={handleViewSpace}
className="w-full py-2 bg-accent-mint/15 hover:bg-accent-mint/25 text-accent-mint text-sm font-medium rounded transition-colors"
>
View Server
View Space
</button>
) : isPublic ? (
<button
@@ -355,7 +355,7 @@ function ServerCard({
Joining...
</span>
) : (
'Join Server'
'Join Space'
)}
</button>
) : requestSent ? (
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { FriendsPage } from './FriendsPage';
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import type { Friend, FriendRequest } from '@backspace/shared';
// Mock the api module
@@ -100,7 +100,7 @@ beforeEach(() => {
loadFriends: vi.fn(),
loadRequests: vi.fn(),
});
useServerStore.setState({
useSpaceStore.setState({
dmChannels: [],
});
});
@@ -177,7 +177,7 @@ describe('FriendsPage', () => {
friends: [friend],
requests: [],
});
useServerStore.setState({
useSpaceStore.setState({
addDmChannel: mockAddDmChannel,
});
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
@@ -16,7 +16,7 @@ export function FriendsPage() {
const [addUsername, setAddUsername] = useState('');
const [addStatus, setAddStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
const navigate = useNavigate();
const addDmChannel = useServerStore((s) => s.addDmChannel);
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
const {
friends,
@@ -55,7 +55,7 @@ export function FriendsPage() {
const handleOpenDm = async (friendId: string, instanceOrigin: string) => {
try {
// Check if a DM already exists with this user (on any instance)
const existing = useServerStore.getState().findExistingDmForUser({ id: friendId });
const existing = useSpaceStore.getState().findExistingDmForUser({ id: friendId });
if (existing) {
useUIStore.getState().setShowDms(true);
navigate(`/channels/@me/${existing.dm.id}`);
@@ -1,5 +1,5 @@
import React from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
interface MentionBadgeProps {
@@ -7,14 +7,14 @@ interface MentionBadgeProps {
}
export const MentionBadge = React.memo(function MentionBadge({ userId }: MentionBadgeProps) {
const members = useServerStore((s) => s.members);
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const member = members.find((m) => m.userId === userId);
const server = servers.find((s) => s.id === currentServerId);
const ownerId = server?.ownerId;
const space = spaces.find((s) => s.id === currentSpaceId);
const ownerId = space?.ownerId;
let displayName: string;
let color: string;
@@ -1,7 +1,7 @@
import React, { useMemo, useRef, useEffect } from 'react';
import type { MemberWithUser } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
const MAX_RESULTS = 8;
@@ -12,12 +12,12 @@ interface MentionPopoverProps {
}
export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopoverProps) {
const members = useServerStore((s) => s.members);
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const selectedRef = useRef<HTMLDivElement>(null);
const ownerId = servers.find((s) => s.id === currentServerId)?.ownerId;
const ownerId = spaces.find((s) => s.id === currentSpaceId)?.ownerId;
const filtered = useMemo(() => {
const q = query.toLowerCase();
+6 -6
View File
@@ -5,7 +5,7 @@ import { Avatar } from '../ui/Avatar';
import { ContextMenu } from '../ui/ContextMenu';
import { useAuthStore } from '../../stores/authStore';
import { useChatStore } from '../../stores/chatStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { Embed } from './Embed';
import { Username } from '../ui/Username';
@@ -44,13 +44,13 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const currentUser = useAuthStore((s) => s.user);
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useServerStore((s) => s.members);
const members = useSpaceStore((s) => s.members);
const openImagePreview = useUIStore((s) => s.openImagePreview);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const channelKey = message.channelId || (message as any).dmChannelId;
const isAuthor = isSelf(message.user, currentUser);
const channelPermissions = useServerStore((s) => s.channelPermissions);
const channelPermissions = useSpaceStore((s) => s.channelPermissions);
const myChPerms = channelPermissions.get(message.channelId);
const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES);
const canDelete = isAuthor || canManageMessages;
@@ -130,9 +130,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const displayIdentity = resolveDisplayIdentity(message.user, currentUser);
const displayName = displayIdentity.displayName ?? displayIdentity.username;
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const ownerId = servers.find(s => s.id === currentServerId)?.ownerId;
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const ownerId = spaces.find(s => s.id === currentSpaceId)?.ownerId;
const getMemberDisplayColor = (userId: string) => {
const member = members.find(m => m.userId === userId);
@@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useServerStore } from '../../stores/serverStore';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket';
import { MentionPopover } from './MentionPopover';
import { TypingIndicator } from './TypingIndicator';
@@ -27,7 +27,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const sendMessage = useChatStore((s) => s.sendMessage);
const replyTo = useChatStore((s) => s.replyTo);
const setReplyTo = useChatStore((s) => s.setReplyTo);
const members = useServerStore((s) => s.members);
const members = useSpaceStore((s) => s.members);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Auto-focus textarea on channel navigation
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { Message } from './Message';
import { useChatStore } from '../../stores/chatStore';
import { useServerStore, isDmChannel } from '../../stores/serverStore';
import { useSpaceStore, isDmChannel } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { useSocialStore } from '../../stores/socialStore';
import { Avatar } from '../ui/Avatar';
@@ -169,7 +169,7 @@ export function MessageList({ channelId }: MessageListProps) {
}
function WelcomeHeader({ channelId }: { channelId: string }) {
const dmChannels = useServerStore((s) => s.dmChannels);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const authUser = useAuthStore((s) => s.user);
const removeFriend = useSocialStore((s) => s.removeFriend);
const friends = useSocialStore((s) => s.friends);
@@ -1,17 +1,17 @@
import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ServerSidebar } from './ServerSidebar';
import { SpaceSidebar } from './SpaceSidebar';
import { ChannelSidebar } from './ChannelSidebar';
import { MainContent } from './MainContent';
import { RightPanel } from './RightPanel';
import { MobileNav } from './MobileNav';
import { ImagePreview } from '../chat/ImagePreview';
import { CreateServerModal } from '../modals/CreateServer';
import { JoinServerModal } from '../modals/JoinServer';
import { CreateSpaceModal } from '../modals/CreateSpace';
import { JoinSpaceModal } from '../modals/JoinSpace';
import { CreateChannelModal } from '../modals/CreateChannel';
import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings';
import { SpaceSettingsModal } from '../modals/SpaceSettings';
import { ChannelSettingsModal } from '../modals/ChannelSettingsModal';
import { NewDmModal } from '../modals/NewDmModal';
import { AddDmMemberModal } from '../modals/AddDmMemberModal';
@@ -25,14 +25,14 @@ import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useFederationToasts } from '../../hooks/useFederationToasts';
import { useLiveKit } from '../../hooks/useLiveKit';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useVoiceStore } from '../../stores/voiceStore';
import { AudioManager } from '../../audio/AudioManager';
export function AppLayout() {
const { serverId, channelId, inviteCode } = useParams<{ serverId?: string; channelId?: string; inviteCode?: string }>();
const { spaceId, channelId, inviteCode } = useParams<{ spaceId?: string; channelId?: string; inviteCode?: string }>();
const navigate = useNavigate();
// Global interaction handler to resume AudioContext
@@ -83,8 +83,8 @@ export function AppLayout() {
}, [outputDeviceId]);
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
@@ -96,7 +96,7 @@ export function AppLayout() {
const userProfilePopout = useUIStore((s) => s.userProfilePopout);
const closeUserProfile = useUIStore((s) => s.closeUserProfile);
const channels = useServerStore((s) => s.channels);
const channels = useSpaceStore((s) => s.channels);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
@@ -185,22 +185,22 @@ export function AppLayout() {
// Handle route params
useEffect(() => {
if (serverId === '@me') {
if (spaceId === '@me') {
setShowDms(true);
setCurrentServer(null);
} else if (serverId) {
setCurrentSpace(null);
} else if (spaceId) {
setShowDms(false);
setCurrentServer(serverId);
loadServerDetail(serverId);
setCurrentSpace(spaceId);
loadSpaceDetail(spaceId);
} else {
setShowDms(false);
setCurrentServer(null);
setCurrentSpace(null);
}
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
}, [spaceId, setCurrentSpace, loadSpaceDetail, setShowDms]);
useEffect(() => {
if (inviteCode) {
openModal('joinServer');
openModal('joinSpace');
}
}, [inviteCode, openModal]);
@@ -208,35 +208,35 @@ export function AppLayout() {
if (channelId) {
setCurrentChannel(channelId);
loadMessages(channelId);
if (serverId && serverId !== '@me') {
useUIStore.getState().setLastChannel(serverId, channelId);
if (spaceId && spaceId !== '@me') {
useUIStore.getState().setLastChannel(spaceId, channelId);
}
} else {
setCurrentChannel(null);
}
}, [channelId, serverId, setCurrentChannel, loadMessages]);
}, [channelId, spaceId, setCurrentChannel, loadMessages]);
// Auto-select last visited (or first) channel when opening a server without a channelId
useEffect(() => {
if (!serverId || serverId === '@me' || channelId) return;
if (!spaceId || spaceId === '@me' || channelId) return;
if (channels.length === 0) return;
const firstChannel = channels[0];
if (!firstChannel) return;
// Guard: only redirect when channels belong to the target server.
// `channels` is shared per-view state set by loadServerDetail (async).
// `channels` is shared per-view state set by loadSpaceDetail (async).
// Without this check, stale channels from a previously viewed server
// would cause a wrong redirect during the fetch window.
const { channelToServerMap } = useServerStore.getState();
if (channelToServerMap.get(firstChannel.id) !== serverId) return;
const { channelToSpaceMap } = useSpaceStore.getState();
if (channelToSpaceMap.get(firstChannel.id) !== spaceId) return;
const lastId = useUIStore.getState().lastChannelPerServer[serverId];
const lastId = useUIStore.getState().lastChannelPerSpace[spaceId];
const target = (lastId && channels.find((c) => c.id === lastId)) || firstChannel;
if (target) {
navigate(`/channels/${serverId}/${target.id}`, { replace: true });
navigate(`/channels/${spaceId}/${target.id}`, { replace: true });
}
}, [serverId, channelId, channels, navigate]);
}, [spaceId, channelId, channels, navigate]);
if (isLoading || !user) {
return (
@@ -254,9 +254,9 @@ export function AppLayout() {
return (
<div className="h-screen flex flex-col md:grid md:grid-cols-[312px_1fr] md:grid-rows-[minmax(0,1fr)] bg-surface-base overflow-hidden">
{/* Server sidebar - always visible on desktop, toggled on mobile */}
{/* Space sidebar - always visible on desktop, toggled on mobile */}
<div className={`fixed inset-y-0 left-0 z-40 flex w-[312px] transition-transform duration-200 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} md:static md:z-auto md:w-auto md:transform-none`}>
<ServerSidebar />
<SpaceSidebar />
<ChannelSidebar />
</div>
@@ -267,12 +267,12 @@ export function AppLayout() {
</div>
{/* Modals */}
<CreateServerModal />
<JoinServerModal />
<CreateSpaceModal />
<JoinSpaceModal />
<CreateChannelModal />
<InviteModal />
<UserSettingsModal />
<ServerSettingsModal />
<SpaceSettingsModal />
<ChannelSettingsModal />
<NewDmModal />
<AddDmMemberModal />
@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
@@ -18,16 +18,16 @@ import { parseFederatedUsername, isSelf } from '../../utils/identity';
import { joinVoiceChannel } from '../../utils/voice';
export function ChannelSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const dmChannels = useServerStore((s) => s.dmChannels);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const channels = useSpaceStore((s) => s.channels);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const unreadChannels = useChatStore((s) => s.unreadChannels);
const openModal = useUIStore((s) => s.openModal);
const user = useAuthStore((s) => s.user);
const members = useServerStore((s) => s.members);
const members = useSpaceStore((s) => s.members);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const isMuted = useVoiceStore((s) => s.isMuted);
@@ -72,26 +72,26 @@ export function ChannelSidebar() {
}
};
const serverPermissions = useServerStore((s) => s.serverPermissions);
const server = servers.find(s => s.id === currentServerId);
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const space = spaces.find(s => s.id === currentSpaceId);
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
const federationInstances = useInstanceStore((s) => s.instances);
const instanceLabel = useMemo(() => {
const origin = (server as any)?._instanceOrigin;
const origin = (space as any)?._instanceOrigin;
if (!origin) return null;
const inst = federationInstances.find(i => i.origin === origin);
if (inst) return inst.label;
try { return new URL(origin).host; } catch { return origin; }
}, [server, federationInstances]);
const canManageChannels = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_CHANNELS);
}, [space, federationInstances]);
const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS);
const textChannels = channels.filter(c => c.type === 'text');
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
const handleChannelClick = (channelId: string) => {
setCurrentChannel(channelId);
navigate(`/channels/${currentServerId || '@me'}/${channelId}`);
navigate(`/channels/${currentSpaceId || '@me'}/${channelId}`);
};
const handleHomeClick = () => {
@@ -102,11 +102,11 @@ export function ChannelSidebar() {
const handleVoiceJoin = (channelId: string) => {
// Don't re-join the same channel — prevents duplicate LiveKit connections
if (currentVoiceChannelId === channelId) {
navigate(`/channels/${currentServerId}/${channelId}`);
navigate(`/channels/${currentSpaceId}/${channelId}`);
return;
}
joinVoiceChannel(channelId);
navigate(`/channels/${currentServerId}/${channelId}`);
navigate(`/channels/${currentSpaceId}/${channelId}`);
};
// Floating bottom panel — shared between DM view and server view
@@ -130,7 +130,7 @@ export function ChannelSidebar() {
</div>
) : null;
if (!server) {
if (!space) {
return (
<>
<div className="w-60 md:w-full bg-surface-channel flex flex-col flex-shrink-0 select-none md:pl-[72px] border-r border-border-hard">
@@ -260,7 +260,7 @@ export function ChannelSidebar() {
navigate('/channels/@me');
setCurrentChannel(null);
}
useServerStore.getState().closeDm(dm.id);
useSpaceStore.getState().closeDm(dm.id);
}}
className="opacity-0 group-hover:opacity-100 text-txt-tertiary hover:text-txt-primary transition-opacity flex-shrink-0 ml-1"
title="Close DM"
@@ -287,14 +287,14 @@ export function ChannelSidebar() {
return (
<>
<div className="w-60 md:w-full bg-surface-channel flex flex-col flex-shrink-0 select-none md:pl-[72px] border-r border-border-hard">
{/* Server header */}
{/* Space header */}
<div className="h-12 flex items-center border-b border-border-hard z-10 group/header">
<button
onClick={() => openModal('serverSettings')}
onClick={() => openModal('spaceSettings')}
className="flex-1 h-full px-4 flex items-center justify-between hover:bg-interactive-hover transition-colors min-w-0"
>
<div className="min-w-0">
<span className="font-bold text-[15px] tracking-[-0.02em] text-txt-primary truncate leading-tight block">{server.name}</span>
<span className="font-bold text-[15px] tracking-[-0.02em] text-txt-primary truncate leading-tight block">{space.name}</span>
{instanceLabel && (
<span className="text-[10px] text-txt-tertiary font-medium truncate block leading-tight">
{instanceLabel}
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
@@ -20,9 +20,9 @@ import { joinVoiceChannel } from '../../utils/voice';
export function MainContent() {
// 1. ALL HOOKS AT THE TOP
const channels = useServerStore((s) => s.channels);
const channels = useSpaceStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const currentServerId = useServerStore((s) => s.currentServerId);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
const setVoiceFullscreen = useUIStore((s) => s.setVoiceFullscreen);
@@ -35,7 +35,7 @@ export function MainContent() {
const isExplorePage = location.pathname === '/explore';
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const outgoingCall = useVoiceStore((s) => s.outgoingCall);
const dmChannels = useServerStore((s) => s.dmChannels);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const authUser = useAuthStore((s) => s.user);
const openModal = useUIStore((s) => s.openModal);
@@ -64,7 +64,7 @@ export function MainContent() {
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
if (showDms || isExplorePage || !currentServerId) {
if (showDms || isExplorePage || !currentSpaceId) {
if (!currentChannelId) {
if (isExplorePage) return <ExplorePage />;
return <FriendsPage />;
@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import type { MemberWithUser } from '@backspace/shared';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
@@ -42,14 +42,14 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
}
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const server = servers.find(s => s.id === currentServerId);
const ownerId = server?.ownerId;
const space = spaces.find(s => s.id === currentSpaceId);
const ownerId = space?.ownerId;
const { roleGroups, offlineMembers } = useMemo(() => {
const online = members.filter(m => m.user.status !== 'offline');
@@ -2,13 +2,13 @@ import React from 'react';
import { MemberSidebar } from './MemberSidebar';
import { ActivityPanel } from './ActivityPanel';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
export function RightPanel() {
const showDms = useUIStore((s) => s.showDms);
const currentServerId = useServerStore((s) => s.currentServerId);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
if (showDms || !currentServerId) {
if (showDms || !currentSpaceId) {
return <ActivityPanel />;
}
@@ -1,11 +1,11 @@
import React, { useState, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { getServerGradient, HOME_GRADIENT } from '../../utils/gradients';
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
interface SidebarItemProps {
id: string;
@@ -13,13 +13,13 @@ interface SidebarItemProps {
icon?: string | null;
active: boolean;
onClick: () => void;
type?: 'server' | 'dm' | 'action';
type?: 'space' | 'dm' | 'action';
actionType?: 'add' | 'join' | 'explore';
hasUnread?: boolean;
dimmed?: boolean;
}
function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionType, hasUnread, dimmed }: SidebarItemProps) {
function SidebarItem({ id, name, icon, active, onClick, type = 'space', actionType, hasUnread, dimmed }: SidebarItemProps) {
const [isHovered, setIsHovered] = useState(false);
const firstLetter = name.charAt(0).toUpperCase();
@@ -41,11 +41,11 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionT
return { background: HOME_GRADIENT.gradient };
}
// Server type — if it has a custom icon image, no gradient needed
// Space type — if it has a custom icon image, no gradient needed
if (icon) return undefined;
const serverGrad = getServerGradient(id, name);
return { background: serverGrad.gradient };
const spaceGrad = getSpaceGradient(id, name);
return { background: spaceGrad.gradient };
}, [type, id, name, icon, isHovered]);
const getButtonClasses = () => {
@@ -73,7 +73,7 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionT
onMouseLeave={() => setIsHovered(false)}
>
{/* Pill Indicator */}
{(type === 'server' || type === 'dm') && (
{(type === 'space' || type === 'dm') && (
<div className="absolute -left-0 w-2 h-10 flex items-center">
<div
className={`bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1`}
@@ -112,12 +112,12 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionT
);
}
export function ServerSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const channelToServerMap = useServerStore((s) => s.channelToServerMap);
const dmChannels = useServerStore((s) => s.dmChannels);
export function SpaceSidebar() {
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const showDms = useUIStore((s) => s.showDms);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
@@ -128,11 +128,11 @@ export function ServerSidebar() {
const navigate = useNavigate();
const location = useLocation();
// Group servers by origin
const groupedServers = useMemo(() => {
const home = servers.filter(s => !(s as any)._instanceOrigin);
const remoteMap = new Map<string, typeof servers>();
for (const s of servers) {
// Group spaces by origin
const groupedSpaces = useMemo(() => {
const home = spaces.filter(s => !(s as any)._instanceOrigin);
const remoteMap = new Map<string, typeof spaces>();
for (const s of spaces) {
const origin = (s as any)._instanceOrigin;
if (!origin) continue;
const list = remoteMap.get(origin) || [];
@@ -140,7 +140,7 @@ export function ServerSidebar() {
remoteMap.set(origin, list);
}
return { home, remoteGroups: Array.from(remoteMap.entries()) };
}, [servers]);
}, [spaces]);
// Set of disconnected origins
const disconnectedOrigins = useMemo(() => {
@@ -153,15 +153,15 @@ export function ServerSidebar() {
return set;
}, [instances]);
// Compute which servers have unread channels
const unreadServerIds = useMemo(() => {
// Compute which spaces have unread channels
const unreadSpaceIds = useMemo(() => {
const ids = new Set<string>();
for (const channelId of unreadChannels) {
const serverId = channelToServerMap.get(channelId);
if (serverId) ids.add(serverId);
const spaceId = channelToSpaceMap.get(channelId);
if (spaceId) ids.add(spaceId);
}
return ids;
}, [unreadChannels, channelToServerMap]);
}, [unreadChannels, channelToSpaceMap]);
// Check if any DM channels are unread
const hasDmUnread = useMemo(() => {
@@ -171,28 +171,28 @@ export function ServerSidebar() {
return false;
}, [unreadChannels, dmChannels]);
const handleServerClick = (serverId: string) => {
const server = servers.find(s => s.id === serverId);
const origin = (server as any)?._instanceOrigin;
const handleSpaceClick = (spaceId: string) => {
const space = spaces.find(s => s.id === spaceId);
const origin = (space as any)?._instanceOrigin;
if (origin && disconnectedOrigins.has(origin)) {
const inst = instances.find(i => i.origin === origin);
addToast(`Reconnecting to ${inst?.label || 'remote instance'}...`, 'warning', 4000);
return;
}
setCurrentServer(serverId);
setCurrentSpace(spaceId);
setShowDms(false);
navigate(`/channels/${serverId}`);
navigate(`/channels/${spaceId}`);
};
const handleDmClick = () => {
setShowDms(true);
setCurrentServer(null);
setCurrentSpace(null);
setCurrentChannel(null);
navigate('/channels/@me');
};
const handleExploreClick = () => {
setCurrentServer(null);
setCurrentSpace(null);
setCurrentChannel(null);
navigate('/explore');
};
@@ -210,21 +210,21 @@ export function ServerSidebar() {
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
{/* Home servers */}
{groupedServers.home.map((server) => (
{/* Home spaces */}
{groupedSpaces.home.map((space) => (
<SidebarItem
key={server.id}
id={server.id}
name={server.name}
icon={server.icon}
active={currentServerId === server.id}
onClick={() => handleServerClick(server.id)}
hasUnread={unreadServerIds.has(server.id)}
key={space.id}
id={space.id}
name={space.name}
icon={space.icon}
active={currentSpaceId === space.id}
onClick={() => handleSpaceClick(space.id)}
hasUnread={unreadSpaceIds.has(space.id)}
/>
))}
{/* Remote instance groups */}
{groupedServers.remoteGroups.map(([origin, groupServers]) => {
{groupedSpaces.remoteGroups.map(([origin, groupSpaces]) => {
const inst = instances.find(i => i.origin === origin);
const label = inst?.label || (() => { try { return new URL(origin).host; } catch { return '?'; } })();
const isDimmed = disconnectedOrigins.has(origin);
@@ -234,15 +234,15 @@ export function ServerSidebar() {
<div className="text-[9px] text-txt-tertiary font-medium uppercase tracking-wider mb-1 truncate max-w-[52px] text-center" title={label}>
{label}
</div>
{groupServers.map((server) => (
{groupSpaces.map((space) => (
<SidebarItem
key={server.id}
id={server.id}
name={server.name}
icon={server.icon}
active={currentServerId === server.id}
onClick={() => handleServerClick(server.id)}
hasUnread={unreadServerIds.has(server.id)}
key={space.id}
id={space.id}
name={space.name}
icon={space.icon}
active={currentSpaceId === space.id}
onClick={() => handleSpaceClick(space.id)}
hasUnread={unreadSpaceIds.has(space.id)}
dimmed={isDimmed}
/>
))}
@@ -253,26 +253,26 @@ export function ServerSidebar() {
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
<SidebarItem
id="add-server"
name="Add a Server"
id="add-space"
name="Add a Space"
active={false}
onClick={() => openModal('createServer')}
onClick={() => openModal('createSpace')}
type="action"
actionType="add"
/>
<SidebarItem
id="join-server"
name="Join a Server"
id="join-space"
name="Join a Space"
active={false}
onClick={() => openModal('joinServer')}
onClick={() => openModal('joinSpace')}
type="action"
actionType="join"
/>
<SidebarItem
id="explore"
name="Explore Servers"
name="Explore Spaces"
active={location.pathname === '/explore'}
onClick={handleExploreClick}
type="action"
@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore, getApiForOrigin } from '../../stores/serverStore';
import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore';
import { api } from '../../api/client';
import type { User } from '@backspace/shared';
@@ -15,8 +15,8 @@ export function AddDmMemberModal() {
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
const dmChannels = useServerStore((s) => s.dmChannels);
const channelOriginMap = useServerStore((s) => s.channelOriginMap);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const channelOriginMap = useSpaceStore((s) => s.channelOriginMap);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { api } from '../../api/client';
import { PermissionBits, permissionsToString, stringToPermissions } from '../../utils/permissions';
@@ -17,8 +17,8 @@ export function ChannelSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const channels = useSpaceStore((s) => s.channels);
const [isPrivate, setIsPrivate] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -31,7 +31,7 @@ export function ChannelSettingsModal() {
// Fetch overrides when modal opens
useEffect(() => {
if (!isOpen || !channelId || !currentServerId) {
if (!isOpen || !channelId || !currentSpaceId) {
setIsFetching(false);
return;
}
@@ -41,9 +41,9 @@ export function ChannelSettingsModal() {
api.channels.getOverrides(channelId)
.then((overrides: ChannelOverride[]) => {
// Check if @everyone role (id === serverId) has VIEW_CHANNEL denied
// Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied
const everyoneOverride = overrides.find(
o => o.targetType === 'role' && o.targetId === currentServerId
o => o.targetType === 'role' && o.targetId === currentSpaceId
);
if (everyoneOverride) {
const denyBits = stringToPermissions(everyoneOverride.deny);
@@ -58,9 +58,9 @@ export function ChannelSettingsModal() {
.finally(() => {
setIsFetching(false);
});
}, [isOpen, channelId, currentServerId]);
}, [isOpen, channelId, currentSpaceId]);
if (!isOpen || !channel || !channelId || !currentServerId) return null;
if (!isOpen || !channel || !channelId || !currentSpaceId) return null;
const handleToggle = async () => {
setError('');
@@ -71,14 +71,14 @@ export function ChannelSettingsModal() {
// Make private: deny VIEW_CHANNEL for @everyone role
await api.channels.putOverride(channelId, {
targetType: 'role',
targetId: currentServerId,
targetId: currentSpaceId,
allow: '0',
deny: permissionsToString(PermissionBits.VIEW_CHANNEL),
});
setIsPrivate(true);
} else {
// Make public: remove the @everyone VIEW_CHANNEL deny override
await api.channels.deleteOverride(channelId, 'role', currentServerId);
await api.channels.deleteOverride(channelId, 'role', currentSpaceId);
setIsPrivate(false);
}
} catch (err) {
@@ -144,7 +144,7 @@ export function ChannelSettingsModal() {
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
<span>
This channel is hidden from members without explicit access. Users with the Administrator permission or server owners can always see all channels.
This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels.
</span>
</div>
)}
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
export function CreateChannelModal() {
const [name, setName] = useState('');
@@ -11,8 +11,8 @@ export function CreateChannelModal() {
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const createChannel = useServerStore((s) => s.createChannel);
const currentServerId = useServerStore((s) => s.currentServerId);
const createChannel = useSpaceStore((s) => s.createChannel);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const isOpen = activeModal === 'createChannel';
@@ -25,14 +25,14 @@ export function CreateChannelModal() {
return;
}
if (!currentServerId) {
setError('No server selected');
if (!currentSpaceId) {
setError('No space selected');
return;
}
setIsLoading(true);
try {
await createChannel(currentServerId, name.trim(), type, topic.trim() || undefined);
await createChannel(currentSpaceId, name.trim(), type, topic.trim() || undefined);
closeModal();
setName('');
setTopic('');
@@ -1,44 +1,44 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { useNavigate } from 'react-router-dom';
export function CreateServerModal() {
export function CreateSpaceModal() {
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const createServer = useServerStore((s) => s.createServer);
const createSpace = useSpaceStore((s) => s.createSpace);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const navigate = useNavigate();
const isOpen = activeModal === 'createServer';
const isOpen = activeModal === 'createSpace';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Server name is required');
setError('Space name is required');
return;
}
setIsLoading(true);
try {
const server = await createServer(name.trim());
const space = await createSpace(name.trim());
closeModal();
setName('');
navigate(`/channels/${server.id}`);
navigate(`/channels/${space.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create server');
setError(err instanceof Error ? err.message : 'Failed to create space');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Server">
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Space">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
@@ -47,14 +47,14 @@ export function CreateServerModal() {
)}
<div className="mb-4">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Server Name
Space Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
placeholder="My Awesome Server"
placeholder="My Awesome Space"
autoFocus
/>
</div>
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InviteModal } from './InviteModal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
// Mock the stores by spying on their getState
beforeEach(() => {
@@ -12,9 +12,9 @@ beforeEach(() => {
activeModal: null,
modalData: {},
});
useServerStore.setState({
currentServerId: null,
servers: [],
useSpaceStore.setState({
currentSpaceId: null,
spaces: [],
});
});
@@ -28,8 +28,8 @@ describe('InviteModal', () => {
it('calls generateInvite and displays the invite URL when opened', async () => {
const mockGenerateInvite = vi.fn().mockResolvedValue('test-invite-code');
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -54,8 +54,8 @@ describe('InviteModal', () => {
it('displays an error when generateInvite fails', async () => {
const mockGenerateInvite = vi.fn().mockRejectedValue(new Error('Not authorized'));
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -69,8 +69,8 @@ describe('InviteModal', () => {
it('Copy button is disabled while loading', () => {
const mockGenerateInvite = vi.fn().mockReturnValue(new Promise(() => {})); // never resolves
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -91,8 +91,8 @@ describe('InviteModal', () => {
});
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
export function InviteModal() {
const [inviteCode, setInviteCode] = useState('');
@@ -10,20 +10,20 @@ export function InviteModal() {
const [error, setError] = useState('');
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const generateInvite = useServerStore((s) => s.generateInvite);
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const currentServer = servers.find(s => s.id === currentServerId);
const instanceOrigin = currentServer?._instanceOrigin ?? '';
const generateInvite = useSpaceStore((s) => s.generateInvite);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpace = spaces.find(s => s.id === currentSpaceId);
const instanceOrigin = currentSpace?._instanceOrigin ?? '';
const isOpen = activeModal === 'invite';
const inviteUrl = inviteCode ? `${instanceOrigin || window.location.origin}/join/${inviteCode}` : '';
useEffect(() => {
if (isOpen && currentServerId) {
if (isOpen && currentSpaceId) {
setIsLoading(true);
setError('');
generateInvite(currentServerId)
generateInvite(currentSpaceId)
.then(code => {
setInviteCode(code);
setIsLoading(false);
@@ -33,7 +33,7 @@ export function InviteModal() {
setIsLoading(false);
});
}
}, [isOpen, currentServerId, generateInvite]);
}, [isOpen, currentSpaceId, generateInvite]);
const handleCopy = async () => {
if (!inviteUrl) return;
@@ -55,7 +55,7 @@ export function InviteModal() {
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
<p className="text-txt-secondary text-sm mb-4">
Share this invite link with friends to let them join your server.
Share this invite link with friends to let them join your space.
</p>
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
@@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { JoinServerModal } from './JoinServer';
import { JoinSpaceModal } from './JoinSpace';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
@@ -18,41 +18,41 @@ vi.mock('react-router-dom', async () => {
beforeEach(() => {
mockNavigate.mockClear();
useUIStore.setState({ activeModal: null });
useServerStore.setState({
servers: [],
currentServerId: null,
useSpaceStore.setState({
spaces: [],
currentSpaceId: null,
});
});
function renderModal() {
return render(
<MemoryRouter>
<JoinServerModal />
<JoinSpaceModal />
</MemoryRouter>
);
}
describe('JoinServerModal', () => {
it('does not render when activeModal is not "joinServer"', () => {
describe('JoinSpaceModal', () => {
it('does not render when activeModal is not "joinSpace"', () => {
useUIStore.setState({ activeModal: null });
renderModal();
expect(screen.queryByText('Join a Server')).not.toBeInTheDocument();
expect(screen.queryByText('Join a Space')).not.toBeInTheDocument();
});
it('renders the form when opened', () => {
useUIStore.setState({ activeModal: 'joinServer' });
useUIStore.setState({ activeModal: 'joinSpace' });
renderModal();
expect(screen.getByText('Join a Server')).toBeInTheDocument();
expect(screen.getByText('Join a Space')).toBeInTheDocument();
expect(screen.getByPlaceholderText('e.g. abc123')).toBeInTheDocument();
expect(screen.getByText('Join Server')).toBeInTheDocument();
expect(screen.getByText('Join Space')).toBeInTheDocument();
});
it('shows validation error when submitting empty code', async () => {
const user = userEvent.setup();
useUIStore.setState({ activeModal: 'joinServer' });
useUIStore.setState({ activeModal: 'joinSpace' });
renderModal();
const submitButton = screen.getByText('Join Server');
const submitButton = screen.getByText('Join Space');
await user.click(submitButton);
expect(screen.getByText('Invite code is required')).toBeInTheDocument();
@@ -60,9 +60,9 @@ describe('JoinServerModal', () => {
it('calls joinByCode with the entered invite code and navigates on success', async () => {
const user = userEvent.setup();
const mockJoinByCode = vi.fn().mockResolvedValue({ id: 'new-server-id', name: 'Test Server' });
useUIStore.setState({ activeModal: 'joinServer' });
useServerStore.setState({ joinByCode: mockJoinByCode });
const mockJoinByCode = vi.fn().mockResolvedValue({ id: 'new-space-id', name: 'Test Space' });
useUIStore.setState({ activeModal: 'joinSpace' });
useSpaceStore.setState({ joinByCode: mockJoinByCode });
renderModal();
@@ -71,7 +71,7 @@ describe('JoinServerModal', () => {
await user.type(input, 'my-invite-code');
// Click join
const submitButton = screen.getByText('Join Server');
const submitButton = screen.getByText('Join Space');
await user.click(submitButton);
// joinByCode should be called with the code
@@ -79,9 +79,9 @@ describe('JoinServerModal', () => {
expect(mockJoinByCode).toHaveBeenCalledWith('my-invite-code');
});
// Should navigate to the new server
// Should navigate to the new space
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/channels/new-server-id');
expect(mockNavigate).toHaveBeenCalledWith('/channels/new-space-id');
});
// Modal should close (activeModal becomes null)
@@ -91,15 +91,15 @@ describe('JoinServerModal', () => {
it('shows error message when joinByCode fails', async () => {
const user = userEvent.setup();
const mockJoinByCode = vi.fn().mockRejectedValue(new Error('Invalid invite code'));
useUIStore.setState({ activeModal: 'joinServer' });
useServerStore.setState({ joinByCode: mockJoinByCode });
useUIStore.setState({ activeModal: 'joinSpace' });
useSpaceStore.setState({ joinByCode: mockJoinByCode });
renderModal();
const input = screen.getByPlaceholderText('e.g. abc123');
await user.type(input, 'bad-code');
const submitButton = screen.getByText('Join Server');
const submitButton = screen.getByText('Join Space');
await user.click(submitButton);
await waitFor(() => {
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore, NotConnectedError } from '../../stores/serverStore';
import { useSpaceStore, NotConnectedError } from '../../stores/spaceStore';
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore';
import { useNavigate, useParams } from 'react-router-dom';
@@ -9,7 +9,7 @@ import { parseInviteInput } from '../../utils/inviteParser';
type JoinPhase = 'input' | 'connect' | 'fallback';
export function JoinServerModal() {
export function JoinSpaceModal() {
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
@@ -23,13 +23,13 @@ export function JoinServerModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const joinByCode = useServerStore((s) => s.joinByCode);
const joinByCode = useSpaceStore((s) => s.joinByCode);
const connectToRemote = useInstanceStore((s) => s.connectToRemote);
const loginToRemote = useInstanceStore((s) => s.loginToRemote);
const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
const isOpen = activeModal === 'joinServer';
const isOpen = activeModal === 'joinSpace';
// Pre-fill from URL param and reset state on open/close
useEffect(() => {
@@ -49,9 +49,9 @@ export function JoinServerModal() {
}, [isOpen, urlInviteCode]);
const joinAndNavigate = async (code: string, origin?: string) => {
const server = await joinByCode(code, origin || undefined);
const space = await joinByCode(code, origin || undefined);
closeModal();
navigate(`/channels/${server.id}`);
navigate(`/channels/${space.id}`);
};
// Phase 1: Submit invite code/URL
@@ -78,7 +78,7 @@ export function JoinServerModal() {
setPhase('connect');
setError('');
} else {
setError(err instanceof Error ? err.message : 'Failed to join server');
setError(err instanceof Error ? err.message : 'Failed to join space');
}
} finally {
setIsLoading(false);
@@ -128,7 +128,7 @@ export function JoinServerModal() {
} catch { /* ignore */ }
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Server">
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Space">
{/* Error display (shared across all phases) */}
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
@@ -140,7 +140,7 @@ export function JoinServerModal() {
{phase === 'input' && (
<form onSubmit={handleSubmit}>
<p className="text-txt-secondary text-sm mb-4">
Enter an invite code or link to join a server.
Enter an invite code or link to join a space.
</p>
<div className="mb-4">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
@@ -168,7 +168,7 @@ export function JoinServerModal() {
disabled={isLoading || !inviteCode.trim()}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Joining...' : 'Join Server'}
{isLoading ? 'Joining...' : 'Join Space'}
</button>
</div>
</form>
@@ -178,7 +178,7 @@ export function JoinServerModal() {
{phase === 'connect' && (
<form onSubmit={handleConnect}>
<p className="text-txt-secondary text-sm mb-4">
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this server.
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this space.
</p>
<div className="mb-4 space-y-2">
<div>
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Modal } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { api } from '../../api/client';
import type { User } from '@backspace/shared';
@@ -14,7 +14,7 @@ export function NewDmModal() {
const [error, setError] = useState('');
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const addDmChannel = useServerStore((s) => s.addDmChannel);
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -59,7 +59,7 @@ export function NewDmModal() {
const handleSelectUser = async (user: User) => {
setError('');
try {
const existing = useServerStore.getState().findExistingDmForUser(user);
const existing = useSpaceStore.getState().findExistingDmForUser(user);
if (existing) {
closeModal();
useUIStore.getState().setShowDms(true);
@@ -1,14 +1,14 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { useSettingsStore } from '../../stores/settingsStore';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { useNavigate } from 'react-router-dom';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import type { InstanceStreamingLimits, ServerVisibility, JoinRequest } from '@backspace/shared';
import type { InstanceStreamingLimits, SpaceVisibility, JoinRequest } from '@backspace/shared';
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
const VALID_FRAMERATES = [30, 45, 60] as const;
@@ -215,40 +215,40 @@ function StreamingLimitsPanel() {
);
}
function DiscoveryPanel({ serverId }: { serverId: string }) {
const servers = useServerStore((s) => s.servers);
const updateServer = useServerStore((s) => s.updateServer);
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
const spaces = useSpaceStore((s) => s.spaces);
const updateSpace = useSpaceStore((s) => s.updateSpace);
const discoveryEnabled = useSettingsStore((s) => s.streamingLimits?.discoveryEnabled ?? true);
const server = servers.find(s => s.id === serverId);
const space = spaces.find(s => s.id === spaceId);
const [visibility, setVisibility] = useState<ServerVisibility>(
(server?.visibility as ServerVisibility) ?? 'private'
const [visibility, setVisibility] = useState<SpaceVisibility>(
(space?.visibility as SpaceVisibility) ?? 'private'
);
const [description, setDescription] = useState(server?.description ?? '');
const [description, setDescription] = useState(space?.description ?? '');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
if (server) {
setVisibility((server.visibility as ServerVisibility) ?? 'private');
setDescription(server.description ?? '');
if (space) {
setVisibility((space.visibility as SpaceVisibility) ?? 'private');
setDescription(space.description ?? '');
}
}, [server]);
}, [space]);
if (!server) return null;
if (!space) return null;
const hasChanges =
visibility !== ((server.visibility as ServerVisibility) ?? 'private') ||
description !== (server.description ?? '');
visibility !== ((space.visibility as SpaceVisibility) ?? 'private') ||
description !== (space.description ?? '');
const handleSave = async () => {
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
await api.servers.update(serverId, { visibility, description: description.trim() });
await api.spaces.update(spaceId, { visibility, description: description.trim() });
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
@@ -259,12 +259,12 @@ function DiscoveryPanel({ serverId }: { serverId: string }) {
};
const handleReset = () => {
setVisibility((server.visibility as ServerVisibility) ?? 'private');
setDescription(server.description ?? '');
setVisibility((space.visibility as SpaceVisibility) ?? 'private');
setDescription(space.description ?? '');
setSaveError('');
};
const visibilityOptions: { value: ServerVisibility; label: string; desc: string }[] = [
const visibilityOptions: { value: SpaceVisibility; label: string; desc: string }[] = [
{ value: 'private', label: 'Private', desc: 'Only people with an invite link can join' },
{ value: 'request', label: 'Request to Join', desc: 'Visible in Explore — people can request to join' },
{ value: 'public', label: 'Public', desc: 'Visible in Explore — anyone can join instantly' },
@@ -274,7 +274,7 @@ function DiscoveryPanel({ serverId }: { serverId: string }) {
<div className="space-y-4">
{!discoveryEnabled && (
<div className="p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
Server discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
Space discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
</div>
)}
@@ -348,14 +348,14 @@ function DiscoveryPanel({ serverId }: { serverId: string }) {
)}
{/* Pending Join Requests — only shown when visibility is 'request' */}
{(visibility === 'request' || (server.visibility as ServerVisibility) === 'request') && (
<JoinRequestsSection serverId={serverId} />
{(visibility === 'request' || (space.visibility as SpaceVisibility) === 'request') && (
<JoinRequestsSection spaceId={spaceId} />
)}
</div>
);
}
function JoinRequestsSection({ serverId }: { serverId: string }) {
function JoinRequestsSection({ spaceId }: { spaceId: string }) {
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [loading, setLoading] = useState(true);
const [actionError, setActionError] = useState('');
@@ -363,7 +363,7 @@ function JoinRequestsSection({ serverId }: { serverId: string }) {
useEffect(() => {
let cancelled = false;
setLoading(true);
api.explore.getJoinRequests(serverId, 'pending')
api.explore.getJoinRequests(spaceId, 'pending')
.then(({ requests: reqs }) => {
if (!cancelled) {
setRequests(reqs);
@@ -374,12 +374,12 @@ function JoinRequestsSection({ serverId }: { serverId: string }) {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [serverId]);
}, [spaceId]);
const handleDecide = async (requestId: string, action: 'accept' | 'decline') => {
setActionError('');
try {
await api.explore.decideJoinRequest(serverId, requestId, action);
await api.explore.decideJoinRequest(spaceId, requestId, action);
setRequests(prev => prev.filter(r => r.id !== requestId));
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Action failed');
@@ -459,55 +459,55 @@ function JoinRequestsSection({ serverId }: { serverId: string }) {
);
}
export function ServerSettingsModal() {
export function SpaceSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const members = useServerStore((s) => s.members);
const roles = useServerStore((s) => s.roles);
const updateServer = useServerStore((s) => s.updateServer);
const deleteServer = useServerStore((s) => s.deleteServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const spaces = useSpaceStore((s) => s.spaces);
const members = useSpaceStore((s) => s.members);
const roles = useSpaceStore((s) => s.roles);
const updateSpace = useSpaceStore((s) => s.updateSpace);
const deleteSpace = useSpaceStore((s) => s.deleteSpace);
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
const currentUser = useAuthStore((s) => s.user);
const isAdmin = useSettingsStore((s) => s.isAdmin);
const navigate = useNavigate();
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'streaming'>('overview');
const [serverName, setServerName] = useState('');
const [spaceName, setSpaceName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
const serverPermissions = useServerStore((s) => s.serverPermissions);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const isOpen = activeModal === 'serverSettings';
const server = servers.find(s => s.id === currentServerId);
const isOwnerUser = server?.ownerId === currentUser?.id;
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
const canManageServer = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_SERVER);
const canManageRoles = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_ROLES);
const isOpen = activeModal === 'spaceSettings';
const space = spaces.find(s => s.id === currentSpaceId);
const isOwnerUser = space?.ownerId === currentUser?.id;
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE);
const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES);
// Assignable roles: exclude @everyone (where role.id === serverId)
const assignableRoles = roles.filter(r => r.id !== currentServerId);
// Assignable roles: exclude @everyone (where role.id === spaceId)
const assignableRoles = roles.filter(r => r.id !== currentSpaceId);
React.useEffect(() => {
if (server) {
setServerName(server.name);
if (space) {
setSpaceName(space.name);
}
}, [server]);
}, [space]);
if (!server || !currentServerId) return null;
if (!space || !currentSpaceId) return null;
const handleSave = async () => {
setError('');
setIsLoading(true);
try {
await updateServer(currentServerId, { name: serverName.trim() });
await updateSpace(currentSpaceId, { name: spaceName.trim() });
setIsLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update server');
setError(err instanceof Error ? err.message : 'Failed to update space');
setIsLoading(false);
}
};
@@ -518,11 +518,11 @@ export function ServerSettingsModal() {
return;
}
try {
await deleteServer(currentServerId);
await deleteSpace(currentSpaceId);
closeModal();
navigate('/channels/@me');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete server');
setError(err instanceof Error ? err.message : 'Failed to delete space');
}
};
@@ -548,13 +548,13 @@ export function ServerSettingsModal() {
if (!roleIds) return;
try {
await api.servers.updateMember(currentServerId, userId, { roleIds: Array.from(roleIds) });
await api.spaces.updateMember(currentSpaceId, userId, { roleIds: Array.from(roleIds) });
setPendingRoleChanges(prev => {
const next = new Map(prev);
next.delete(userId);
return next;
});
await loadServerDetail(currentServerId);
await loadSpaceDetail(currentSpaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update roles');
}
@@ -570,15 +570,15 @@ export function ServerSettingsModal() {
const handleKick = async (userId: string) => {
try {
await api.servers.removeMember(currentServerId, userId);
await loadServerDetail(currentServerId);
await api.spaces.removeMember(currentSpaceId, userId);
await loadSpaceDetail(currentSpaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to kick member');
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Server Settings" maxWidth="max-w-xl">
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
<div className="flex gap-4">
{/* Tabs */}
<div className="w-32 flex-shrink-0 space-y-1">
@@ -590,7 +590,7 @@ export function ServerSettingsModal() {
>
Overview
</button>
{canManageServer && (
{canManageSpace && (
<button
onClick={() => setTab('discovery')}
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
@@ -630,18 +630,18 @@ export function ServerSettingsModal() {
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Server Name
Space Name
</label>
<input
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
value={spaceName}
onChange={(e) => setSpaceName(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
disabled={!canManageServer}
disabled={!canManageSpace}
/>
</div>
{canManageServer && (
{canManageSpace && (
<>
<button
onClick={handleSave}
@@ -657,7 +657,7 @@ export function ServerSettingsModal() {
onClick={handleDelete}
className="px-4 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded transition-colors"
>
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Server'}
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Space'}
</button>
</div>
</>
@@ -665,15 +665,15 @@ export function ServerSettingsModal() {
</div>
)}
{tab === 'discovery' && canManageServer && currentServerId && (
<DiscoveryPanel serverId={currentServerId} />
{tab === 'discovery' && canManageSpace && currentSpaceId && (
<DiscoveryPanel spaceId={currentSpaceId} />
)}
{tab === 'members' && (
<div className="space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin">
{members.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
const isOwner = member.userId === server.ownerId;
const isOwner = member.userId === space.ownerId;
const memberRoleIds = getMemberRoleIds(member);
const hasPendingChanges = pendingRoleChanges.has(member.userId);
@@ -696,7 +696,7 @@ export function ServerSettingsModal() {
Owner
</span>
)}
{member.roles?.filter(r => r.id !== currentServerId).map(r => (
{member.roles?.filter(r => r.id !== currentSpaceId).map(r => (
<span
key={r.id}
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
@@ -705,7 +705,7 @@ export function ServerSettingsModal() {
{r.name}
</span>
))}
{!isOwner && (!member.roles || member.roles.filter(r => r.id !== currentServerId).length === 0) && (
{!isOwner && (!member.roles || member.roles.filter(r => r.id !== currentSpaceId).length === 0) && (
<span className="text-[10px] text-txt-tertiary">No roles</span>
)}
</div>
@@ -4,7 +4,7 @@ import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
import { api } from '../../api/client';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { getAvatarGradient } from '../../utils/gradients';
import { parseFederatedUsername } from '../../utils/identity';
@@ -17,7 +17,7 @@ interface UserProfilePopoutProps {
export function UserProfilePopout({ user, onClose, position }: UserProfilePopoutProps) {
const navigate = useNavigate();
const addDmChannel = useServerStore((s) => s.addDmChannel);
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
const { baseName, domain } = parseFederatedUsername(user.username);
const displayName = user.displayName ?? baseName;
@@ -30,7 +30,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
const handleSendMessage = async () => {
try {
const existing = useServerStore.getState().findExistingDmForUser(user);
const existing = useSpaceStore.getState().findExistingDmForUser(user);
if (existing) {
useUIStore.getState().setShowDms(true);
onClose();
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket';
import { getAvatarGradient } from '../../utils/gradients';
import { parseFederatedUsername } from '../../utils/identity';
@@ -27,7 +27,7 @@ export function IncomingCallModal() {
};
}, [incomingCall, setIncomingCall]);
const dmChannels = useServerStore((s) => s.dmChannels);
const dmChannels = useSpaceStore((s) => s.dmChannels);
if (!incomingCall) return null;
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useVoiceStore } from '../../stores/voiceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { Avatar } from '../ui/Avatar';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
@@ -111,8 +111,8 @@ export function PictureInPicture() {
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
const pipCollapsed = useUIStore((s) => s.pipCollapsed);
const setPipCollapsed = useUIStore((s) => s.setPipCollapsed);
const channelToServerMap = useServerStore((s) => s.channelToServerMap);
const channels = useServerStore((s) => s.channels);
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
const channels = useSpaceStore((s) => s.channels);
// Drag state
const [position, setPosition] = useState<{ x: number; y: number }>({ x: -1, y: -1 });
@@ -291,16 +291,16 @@ export function PictureInPicture() {
if (activeDmCall) {
navigate(`/channels/@me/${activeDmCall.dmChannelId}`);
} else if (currentVoiceChannelId) {
const serverId = channelToServerMap.get(currentVoiceChannelId);
if (serverId) {
navigate(`/channels/${serverId}/${currentVoiceChannelId}`);
const spaceId = channelToSpaceMap.get(currentVoiceChannelId);
if (spaceId) {
navigate(`/channels/${spaceId}/${currentVoiceChannelId}`);
}
}
} else {
// Drag ended — snap to edge
snapToEdge(position.x, position.y);
}
}, [isDragging, activeDmCall, currentVoiceChannelId, channelToServerMap, navigate, snapToEdge, position]);
}, [isDragging, activeDmCall, currentVoiceChannelId, channelToSpaceMap, navigate, snapToEdge, position]);
const handleClose = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@@ -2,7 +2,7 @@ import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
const EMPTY_VOICE_USERS: string[] = [];
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { Avatar } from '../ui/Avatar';
interface VoiceChannelProps {
@@ -23,7 +23,7 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
const local = s.participants.find(p => p.isLocal);
return local?.userId ?? null;
});
const members = useServerStore((s) => s.members);
const members = useSpaceStore((s) => s.members);
const isActive = currentVoiceChannel === channelId;
return (
@@ -3,7 +3,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useUIStore } from '../../stores/uiStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { getChannelOrigin } from '../../stores/serverStore';
import { getChannelOrigin } from '../../stores/spaceStore';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
@@ -21,7 +21,7 @@ export function VoiceControls() {
const connectionError = useVoiceStore((s) => s.connectionError);
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
const connectionQuality = useVoiceStore((s) => s.connectionQuality);
const channels = useServerStore((s) => s.channels);
const channels = useSpaceStore((s) => s.channels);
const [showScreenShareSettings, setShowScreenShareSettings] = useState(false);
const [showConnectionInfo, setShowConnectionInfo] = useState(false);