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 -1
View File
@@ -37,7 +37,7 @@ export function App() {
}
/>
<Route
path="/channels/:serverId/:channelId?"
path="/channels/:spaceId/:channelId?"
element={
<ProtectedRoute>
<AppLayout />
+49 -49
View File
@@ -3,22 +3,22 @@ import type {
RegisterRequest,
LoginRequest,
User,
Server,
ServerWithChannelsAndMembers,
Space,
SpaceWithChannelsAndMembers,
Channel,
MessageWithUser,
MemberWithUser,
Attachment,
DmChannel,
DmMessageWithUser,
CreateServerRequest,
UpdateServerRequest,
CreateSpaceRequest,
UpdateSpaceRequest,
CreateChannelRequest,
UpdateChannelRequest,
CreateMessageRequest,
UpdateMessageRequest,
UpdateUserRequest,
JoinServerRequest,
JoinSpaceRequest,
UpdateMemberRequest,
LiveKitTokenResponse,
CreateDmRequest,
@@ -28,7 +28,7 @@ import type {
InstanceStreamingLimits,
InstanceInfoResponse,
VerifyPasswordResponse,
ExploreServer,
ExploreSpace,
JoinRequest,
} from '@backspace/shared';
@@ -45,23 +45,23 @@ export class BackspaceApiClient {
verifyPassword: (password: string) => Promise<VerifyPasswordResponse>;
};
readonly servers: {
list: () => Promise<Server[]>;
get: (id: string) => Promise<ServerWithChannelsAndMembers>;
create: (data: CreateServerRequest) => Promise<Server>;
update: (id: string, data: UpdateServerRequest) => Promise<Server>;
readonly spaces: {
list: () => Promise<Space[]>;
get: (id: string) => Promise<SpaceWithChannelsAndMembers>;
create: (data: CreateSpaceRequest) => Promise<Space>;
update: (id: string, data: UpdateSpaceRequest) => Promise<Space>;
delete: (id: string) => Promise<{ success: boolean }>;
invite: (id: string) => Promise<{ inviteCode: string }>;
join: (id: string, data: JoinServerRequest) => Promise<Server>;
joinByCode: (inviteCode: string) => Promise<Server>;
join: (id: string, data: JoinSpaceRequest) => Promise<Space>;
joinByCode: (inviteCode: string) => Promise<Space>;
members: (id: string) => Promise<MemberWithUser[]>;
updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) => Promise<MemberWithUser>;
removeMember: (serverId: string, userId: string) => Promise<{ success: boolean }>;
updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) => Promise<MemberWithUser>;
removeMember: (spaceId: string, userId: string) => Promise<{ success: boolean }>;
};
readonly channels: {
list: (serverId: string) => Promise<Channel[]>;
create: (serverId: string, data: CreateChannelRequest) => Promise<Channel>;
list: (spaceId: string) => Promise<Channel[]>;
create: (spaceId: string, data: CreateChannelRequest) => Promise<Channel>;
update: (id: string, data: UpdateChannelRequest) => Promise<Channel>;
delete: (id: string) => Promise<{ success: boolean }>;
messages: (id: string, before?: string, limit?: number) => Promise<MessageWithUser[]>;
@@ -118,11 +118,11 @@ export class BackspaceApiClient {
};
readonly explore: {
list: (q?: string, limit?: number, offset?: number) => Promise<{ servers: ExploreServer[]; total: number; totalAll: number; discoveryEnabled: boolean }>;
publicJoin: (serverId: string) => Promise<ServerWithChannelsAndMembers>;
requestJoin: (serverId: string, message?: string) => Promise<JoinRequest>;
getJoinRequests: (serverId: string, status?: string) => Promise<{ requests: JoinRequest[] }>;
decideJoinRequest: (serverId: string, requestId: string, action: 'accept' | 'decline') => Promise<JoinRequest>;
list: (q?: string, limit?: number, offset?: number) => Promise<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>;
publicJoin: (spaceId: string) => Promise<SpaceWithChannelsAndMembers>;
requestJoin: (spaceId: string, message?: string) => Promise<JoinRequest>;
getJoinRequests: (spaceId: string, status?: string) => Promise<{ requests: JoinRequest[] }>;
decideJoinRequest: (spaceId: string, requestId: string, action: 'accept' | 'decline') => Promise<JoinRequest>;
myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>;
};
@@ -199,26 +199,26 @@ export class BackspaceApiClient {
request<VerifyPasswordResponse>('POST', '/users/@me/verify-password', { password }),
};
this.servers = {
list: () => request<Server[]>('GET', '/servers'),
get: (id: string) => request<ServerWithChannelsAndMembers>('GET', `/servers/${id}`),
create: (data: CreateServerRequest) => request<Server>('POST', '/servers', data),
update: (id: string, data: UpdateServerRequest) => request<Server>('PATCH', `/servers/${id}`, data),
delete: (id: string) => request<{ success: boolean }>('DELETE', `/servers/${id}`),
invite: (id: string) => request<{ inviteCode: string }>('POST', `/servers/${id}/invite`),
join: (id: string, data: JoinServerRequest) => request<Server>('POST', `/servers/${id}/join`, data),
joinByCode: (inviteCode: string) => request<Server>('POST', '/servers/join', { inviteCode }),
members: (id: string) => request<MemberWithUser[]>('GET', `/servers/${id}/members`),
updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) =>
request<MemberWithUser>('PATCH', `/servers/${serverId}/members/${userId}`, data),
removeMember: (serverId: string, userId: string) =>
request<{ success: boolean }>('DELETE', `/servers/${serverId}/members/${userId}`),
this.spaces = {
list: () => request<Space[]>('GET', '/spaces'),
get: (id: string) => request<SpaceWithChannelsAndMembers>('GET', `/spaces/${id}`),
create: (data: CreateSpaceRequest) => request<Space>('POST', '/spaces', data),
update: (id: string, data: UpdateSpaceRequest) => request<Space>('PATCH', `/spaces/${id}`, data),
delete: (id: string) => request<{ success: boolean }>('DELETE', `/spaces/${id}`),
invite: (id: string) => request<{ inviteCode: string }>('POST', `/spaces/${id}/invite`),
join: (id: string, data: JoinSpaceRequest) => request<Space>('POST', `/spaces/${id}/join`, data),
joinByCode: (inviteCode: string) => request<Space>('POST', '/spaces/join', { inviteCode }),
members: (id: string) => request<MemberWithUser[]>('GET', `/spaces/${id}/members`),
updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) =>
request<MemberWithUser>('PATCH', `/spaces/${spaceId}/members/${userId}`, data),
removeMember: (spaceId: string, userId: string) =>
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/members/${userId}`),
};
this.channels = {
list: (serverId: string) => request<Channel[]>('GET', `/servers/${serverId}/channels`),
create: (serverId: string, data: CreateChannelRequest) =>
request<Channel>('POST', `/servers/${serverId}/channels`, data),
list: (spaceId: string) => request<Channel[]>('GET', `/spaces/${spaceId}/channels`),
create: (spaceId: string, data: CreateChannelRequest) =>
request<Channel>('POST', `/spaces/${spaceId}/channels`, data),
update: (id: string, data: UpdateChannelRequest) => request<Channel>('PATCH', `/channels/${id}`, data),
delete: (id: string) => request<{ success: boolean }>('DELETE', `/channels/${id}`),
messages: (id: string, before?: string, limit = 50) => {
@@ -305,21 +305,21 @@ export class BackspaceApiClient {
if (q) params.set('q', q);
params.set('limit', String(limit));
params.set('offset', String(offset));
return request<{ servers: ExploreServer[]; total: number; totalAll: number; discoveryEnabled: boolean }>(
'GET', `/servers/explore?${params}`
return request<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>(
'GET', `/spaces/explore?${params}`
);
},
publicJoin: (serverId: string) =>
request<ServerWithChannelsAndMembers>('POST', `/servers/${serverId}/public-join`),
requestJoin: (serverId: string, message?: string) =>
request<JoinRequest>('POST', `/servers/${serverId}/request-join`, message ? { message } : {}),
getJoinRequests: (serverId: string, status?: string) => {
publicJoin: (spaceId: string) =>
request<SpaceWithChannelsAndMembers>('POST', `/spaces/${spaceId}/public-join`),
requestJoin: (spaceId: string, message?: string) =>
request<JoinRequest>('POST', `/spaces/${spaceId}/request-join`, message ? { message } : {}),
getJoinRequests: (spaceId: string, status?: string) => {
const params = new URLSearchParams();
if (status) params.set('status', status);
return request<{ requests: JoinRequest[] }>('GET', `/servers/${serverId}/join-requests?${params}`);
return request<{ requests: JoinRequest[] }>('GET', `/spaces/${spaceId}/join-requests?${params}`);
},
decideJoinRequest: (serverId: string, requestId: string, action: 'accept' | 'decline') =>
request<JoinRequest>('PATCH', `/servers/${serverId}/join-requests/${requestId}`, { action }),
decideJoinRequest: (spaceId: string, requestId: string, action: 'accept' | 'decline') =>
request<JoinRequest>('PATCH', `/spaces/${spaceId}/join-requests/${requestId}`, { action }),
myJoinRequests: (status?: string) => {
const params = new URLSearchParams();
if (status) params.set('status', status);
@@ -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);
+2 -2
View File
@@ -12,7 +12,7 @@ import {
LocalAudioTrack,
LocalTrackPublication,
} from 'livekit-client';
import { getApiForOrigin, getChannelOrigin, useServerStore } from '../stores/serverStore';
import { getApiForOrigin, getChannelOrigin, useSpaceStore } from '../stores/spaceStore';
import { wsSend } from './useWebSocket';
import { useVoiceStore } from '../stores/voiceStore';
import { AudioManager } from '../audio/AudioManager';
@@ -154,7 +154,7 @@ export function useLiveKit() {
const processParticipant = (p: Participant, isLocal: boolean) => {
if (!p.identity) return;
const { userId, username } = parseIdentity(p.identity);
const memberMatch = useServerStore.getState().members.find(m => m.userId === userId);
const memberMatch = useSpaceStore.getState().members.find(m => m.userId === userId);
const homeUserId = memberMatch?.user.homeUserId ?? null;
let audioTrack: MediaStreamTrack | null = null;
let videoTrack: MediaStreamTrack | null = null;
+37 -37
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useAuthStore } from '../stores/authStore';
import { useServerStore, getChannelOrigin } from '../stores/serverStore';
import { useSpaceStore, getChannelOrigin } from '../stores/spaceStore';
import { useChatStore } from '../stores/chatStore';
import { useVoiceStore } from '../stores/voiceStore';
import { useSocialStore } from '../stores/socialStore';
@@ -81,7 +81,7 @@ const HOME_ORIGIN = '';
function handleEvent(origin: string, event: ServerEvent): void {
const isHome = origin === HOME_ORIGIN;
const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
const { populateFromReady, loadSpaceDetail, currentSpaceId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useSpaceStore.getState();
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
@@ -95,17 +95,17 @@ function handleEvent(origin: string, event: ServerEvent): void {
// Normalize asset URLs for remote origins before dispatching to stores
if (!isHome) {
for (const server of event.servers) {
if (server.icon) server.icon = resolveAssetUrl(server.icon, origin) ?? server.icon;
if ((server as any).members) {
for (const member of (server as any).members) {
for (const space of event.spaces) {
if (space.icon) space.icon = resolveAssetUrl(space.icon, origin) ?? space.icon;
if ((space as any).members) {
for (const member of (space as any).members) {
if (member.user) normalizeUserAssets(member.user, origin);
}
}
}
}
populateFromReady(origin, event.servers, event.folders, event.dmChannels);
populateFromReady(origin, event.spaces, event.folders, event.dmChannels);
// Mark remote instance as connected in instanceStore
if (!isHome) {
@@ -114,16 +114,16 @@ function handleEvent(origin: string, event: ServerEvent): void {
});
}
if (isHome && currentServerId) {
loadServerDetail(currentServerId);
if (isHome && currentSpaceId) {
loadSpaceDetail(currentSpaceId);
}
// For remote instances: if user was viewing one of these servers, load its details
// (fixes race condition on page reload — route params effect fires before remote WS connects)
if (!isHome) {
const { currentServerId: curServerId, loadServerDetail: loadDetail } = useServerStore.getState();
if (curServerId && event.servers.some((s: any) => s.id === curServerId)) {
loadDetail(curServerId);
const { currentSpaceId: curSpaceId, loadSpaceDetail: loadDetail } = useSpaceStore.getState();
if (curSpaceId && event.spaces.some((s: any) => s.id === curSpaceId)) {
loadDetail(curSpaceId);
const { currentChannelId, loadMessages } = useChatStore.getState();
if (currentChannelId) {
loadMessages(currentChannelId, true);
@@ -138,7 +138,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
reloadMessages(currentChannelId, true);
}
// Initialize unread tracking from ready payload
const { channelLastMessageIds } = useServerStore.getState();
const { channelLastMessageIds } = useSpaceStore.getState();
if (event.readStates) {
setReadStates(event.readStates, channelLastMessageIds);
}
@@ -273,7 +273,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'dm_message_created': {
if (!isHome) normalizeMessageAssets(event.message as any, origin);
addRealtimeMessage(event.message.dmChannelId, event.message as any);
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useServerStore.getState();
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useSpaceStore.getState();
const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId);
if (!knownDm) {
addDmCh({
@@ -417,27 +417,27 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'dm_member_added': {
if (!isHome) normalizeUserAssets(event.user, origin);
const { addDmMember } = useServerStore.getState();
const { addDmMember } = useSpaceStore.getState();
addDmMember(event.dmChannelId, event.user);
break;
}
case 'dm_member_removed': {
const { removeDmMember } = useServerStore.getState();
const { removeDmMember } = useSpaceStore.getState();
removeDmMember(event.dmChannelId, event.userId);
break;
}
// ─── Channel/server events (all origins) ────────────────────────────────
// ─── Channel/space events (all origins) ────────────────────────────────
case 'channel_created': {
const { currentServerId: curServerId, channels: curChannels, setChannels, channelToServerMap, channelPermissions, channelOriginMap } = useServerStore.getState();
if (event.serverId === curServerId) {
const { currentSpaceId: curSpaceId, channels: curChannels, setChannels, channelToSpaceMap, channelPermissions, channelOriginMap } = useSpaceStore.getState();
if (event.spaceId === curSpaceId) {
if (!curChannels.find(c => c.id === event.channel.id)) {
setChannels([...curChannels, event.channel].sort((a, b) => a.position - b.position));
}
}
channelToServerMap.set(event.channel.id, event.serverId);
channelToSpaceMap.set(event.channel.id, event.spaceId);
channelOriginMap.set(event.channel.id, origin);
if (event.channel.myPermissions) {
channelPermissions.set(event.channel.id, event.channel.myPermissions);
@@ -446,15 +446,15 @@ function handleEvent(origin: string, event: ServerEvent): void {
}
case 'channel_updated': {
const { currentServerId: curServerId2, channels: curChannels2, setChannels: setChannels2, channelPermissions: chPermsMap2 } = useServerStore.getState();
if (event.serverId === curServerId2) {
const { currentSpaceId: curSpaceId2, channels: curChannels2, setChannels: setChannels2, channelPermissions: chPermsMap2 } = useSpaceStore.getState();
if (event.spaceId === curSpaceId2) {
const exists = curChannels2.some(c => c.id === event.channel.id);
if (exists) {
setChannels2(curChannels2.map(c => c.id === event.channel.id ? event.channel : c).sort((a, b) => a.position - b.position));
} else {
setChannels2([...curChannels2, event.channel].sort((a, b) => a.position - b.position));
const { channelToServerMap: ctsMmap, channelOriginMap: coMap } = useServerStore.getState();
ctsMmap.set(event.channel.id, event.serverId);
const { channelToSpaceMap: ctsMmap, channelOriginMap: coMap } = useSpaceStore.getState();
ctsMmap.set(event.channel.id, event.spaceId);
coMap.set(event.channel.id, origin);
}
}
@@ -465,8 +465,8 @@ function handleEvent(origin: string, event: ServerEvent): void {
}
case 'channel_deleted': {
const { currentServerId: curServerId3, channels: curChannels3, setChannels: setChannels3, channelPermissions: chPermsMap3, channelToServerMap: ctsMap3, channelOriginMap: coMap3 } = useServerStore.getState();
if (event.serverId === curServerId3) {
const { currentSpaceId: curSpaceId3, channels: curChannels3, setChannels: setChannels3, channelPermissions: chPermsMap3, channelToSpaceMap: ctsMap3, channelOriginMap: coMap3 } = useSpaceStore.getState();
if (event.spaceId === curSpaceId3) {
setChannels3(curChannels3.filter(c => c.id !== event.channelId));
}
chPermsMap3.delete(event.channelId);
@@ -475,7 +475,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
{
const { currentChannelId } = useChatStore.getState();
if (currentChannelId === event.channelId) {
const { channels: remainingChannels } = useServerStore.getState();
const { channels: remainingChannels } = useSpaceStore.getState();
const firstText = remainingChannels.find(c => c.type === 'text');
if (firstText) {
useChatStore.getState().setCurrentChannel(firstText.id);
@@ -487,12 +487,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
}
case 'server_updated': {
if (!isHome && event.server.icon) {
event.server.icon = resolveAssetUrl(event.server.icon, origin) ?? event.server.icon;
case 'space_updated': {
if (!isHome && event.space.icon) {
event.space.icon = resolveAssetUrl(event.space.icon, origin) ?? event.space.icon;
}
const { servers: currentServers, setServers } = useServerStore.getState();
setServers(currentServers.map(s => s.id === event.server.id ? { ...s, ...event.server } : s));
const { spaces: currentSpaces, setSpaces } = useSpaceStore.getState();
setSpaces(currentSpaces.map(s => s.id === event.space.id ? { ...s, ...event.space } : s));
break;
}
@@ -506,16 +506,16 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'join_request_accepted': {
if (!isHome) break;
// Add the server to our server list
const { addServerFromReady } = useServerStore.getState();
addServerFromReady(origin, event.server);
console.log('[WebSocket] Join request accepted for server', event.server.name);
// Add the space to our space list
const { addSpaceFromReady } = useSpaceStore.getState();
addSpaceFromReady(origin, event.space);
console.log('[WebSocket] Join request accepted for space', event.space.name);
break;
}
case 'join_request_declined': {
if (!isHome) break;
console.log('[WebSocket] Join request declined for server', event.request.serverId);
console.log('[WebSocket] Join request declined for space', event.request.spaceId);
break;
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import type { User } from '@backspace/shared';
import { api } from '../api/client';
import { useChatStore } from './chatStore';
import { useServerStore } from './serverStore';
import { useSpaceStore } from './spaceStore';
import { useSocialStore } from './socialStore';
import { useVoiceStore } from './voiceStore';
import { useInstanceStore } from './instanceStore';
@@ -57,7 +57,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
localStorage.removeItem('backspace_token');
// Clear all user-scoped state to prevent data leaking between sessions
useChatStore.getState().clearAllMessages();
useServerStore.getState().populateFromReady('', [], [], []);
useSpaceStore.getState().populateFromReady('', [], [], []);
useSocialStore.getState().reset();
useVoiceStore.getState().clearAllVoiceUsers();
useInstanceStore.getState().reset();
+3 -3
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import type { MessageWithUser, Reaction, ReadState } from '@backspace/shared';
import { wsSend } from '../hooks/useWebSocket';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useServerStore } from './serverStore';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from './spaceStore';
import { useAuthStore } from './authStore';
import { normalizeMessageAssets } from '../utils/assetUrls';
@@ -135,7 +135,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
const isDm = isDmChannel(channelId);
// For server channels, bail if we don't know which instance owns this channel yet.
// The remote WS ready handler will call loadMessages once the map is populated.
if (!isDm && !useServerStore.getState().channelOriginMap.has(channelId)) return;
if (!isDm && !useSpaceStore.getState().channelOriginMap.has(channelId)) return;
set({ isLoading: true, loadError: null });
try {
const origin = getChannelOrigin(channelId);
@@ -229,7 +229,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
// For DMs, update lastMessage on the DM channel so sidebar re-sorts
if (isDm) {
const { dmChannels, setDmChannels } = useServerStore.getState();
const { dmChannels, setDmChannels } = useSpaceStore.getState();
const updatedDms = dmChannels.map(dm =>
dm.id === channelId
? { ...dm, lastMessage: { id: tempId, dmChannelId: channelId, userId: currentUser.id, content, createdAt: Date.now() } }
+31 -31
View File
@@ -1,17 +1,17 @@
import { create } from 'zustand';
import type { ExploreServer, JoinRequest, ServerWithChannelsAndMembers } from '@backspace/shared';
import type { ExploreSpace, JoinRequest, SpaceWithChannelsAndMembers } from '@backspace/shared';
import { api } from '../api/client';
import { useInstanceStore } from './instanceStore';
import { useServerStore } from './serverStore';
import { useSpaceStore } from './spaceStore';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface TaggedExploreServer extends ExploreServer {
export interface TaggedExploreSpace extends ExploreSpace {
_instanceOrigin: string; // '' = home instance
}
interface ExploreState {
servers: TaggedExploreServer[];
spaces: TaggedExploreSpace[];
myRequests: JoinRequest[];
searchQuery: string;
isLoading: boolean;
@@ -19,10 +19,10 @@ interface ExploreState {
totalAll: number;
error: string | null;
fetchServers: (query?: string) => Promise<void>;
fetchSpaces: (query?: string) => Promise<void>;
fetchMyRequests: () => Promise<void>;
publicJoin: (server: TaggedExploreServer) => Promise<ServerWithChannelsAndMembers>;
requestJoin: (server: TaggedExploreServer, message?: string) => Promise<JoinRequest>;
publicJoin: (space: TaggedExploreSpace) => Promise<SpaceWithChannelsAndMembers>;
requestJoin: (space: TaggedExploreSpace, message?: string) => Promise<JoinRequest>;
setSearchQuery: (q: string) => void;
reset: () => void;
}
@@ -38,7 +38,7 @@ function getApiForOrigin(origin: string) {
// ─── Store ──────────────────────────────────────────────────────────────────
export const useExploreStore = create<ExploreState>((set, get) => ({
servers: [],
spaces: [],
myRequests: [],
searchQuery: '',
isLoading: false,
@@ -46,7 +46,7 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
totalAll: 0,
error: null,
fetchServers: async (query?: string) => {
fetchSpaces: async (query?: string) => {
set({ isLoading: true, error: null });
try {
@@ -61,22 +61,22 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
),
]);
const fulfilled = results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<{ servers: ExploreServer[]; total: number; totalAll?: number; discoveryEnabled: boolean; origin: string }>[];
const fulfilled = results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<{ spaces: ExploreSpace[]; total: number; totalAll?: number; discoveryEnabled: boolean; origin: string }>[];
const rejected = results.filter(r => r.status === 'rejected');
// If ALL instances failed, surface an error
if (fulfilled.length === 0 && rejected.length > 0) {
set({ isLoading: false, error: 'Failed to reach any server for discovery' });
set({ isLoading: false, error: 'Failed to reach any instance for discovery' });
return;
}
const allServers: TaggedExploreServer[] = [];
const seen = new Set<string>(); // dedup by serverId+origin
const allSpaces: TaggedExploreSpace[] = [];
const seen = new Set<string>(); // dedup by spaceId+origin
let homeDiscoveryEnabled = true;
let totalAllSum = 0;
for (const result of fulfilled) {
const { servers, discoveryEnabled, totalAll, origin } = result.value;
const { spaces, discoveryEnabled, totalAll, origin } = result.value;
// Track home instance discovery state
if (!origin) {
@@ -85,16 +85,16 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
totalAllSum += totalAll ?? 0;
for (const server of servers) {
const key = `${server.id}:${origin}`;
for (const space of spaces) {
const key = `${space.id}:${origin}`;
if (seen.has(key)) continue;
seen.add(key);
allServers.push({ ...server, _instanceOrigin: origin, joined: server.joined ?? false });
allSpaces.push({ ...space, _instanceOrigin: origin, joined: space.joined ?? false });
}
}
set({
servers: allServers,
spaces: allSpaces,
discoveryEnabled: homeDiscoveryEnabled,
totalAll: totalAllSum,
isLoading: false,
@@ -102,7 +102,7 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to fetch servers',
error: err instanceof Error ? err.message : 'Failed to fetch spaces',
});
}
},
@@ -116,28 +116,28 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
}
},
publicJoin: async (server: TaggedExploreServer) => {
const client = getApiForOrigin(server._instanceOrigin);
const fullServer = await client.explore.publicJoin(server.id);
publicJoin: async (space: TaggedExploreSpace) => {
const client = getApiForOrigin(space._instanceOrigin);
const fullSpace = await client.explore.publicJoin(space.id);
// Add to server store
useServerStore.getState().addServerFromReady(server._instanceOrigin, fullServer);
// Add to space store
useSpaceStore.getState().addSpaceFromReady(space._instanceOrigin, fullSpace);
// Mark as joined in explore list
set((state) => ({
servers: state.servers.map(s =>
s.id === server.id && s._instanceOrigin === server._instanceOrigin
spaces: state.spaces.map(s =>
s.id === space.id && s._instanceOrigin === space._instanceOrigin
? { ...s, joined: true }
: s
),
}));
return fullServer;
return fullSpace;
},
requestJoin: async (server: TaggedExploreServer, message?: string) => {
const client = getApiForOrigin(server._instanceOrigin);
const request = await client.explore.requestJoin(server.id, message);
requestJoin: async (space: TaggedExploreSpace, message?: string) => {
const client = getApiForOrigin(space._instanceOrigin);
const request = await client.explore.requestJoin(space.id, message);
set((state) => ({
myRequests: [...state.myRequests, request],
@@ -149,7 +149,7 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
setSearchQuery: (q: string) => set({ searchQuery: q }),
reset: () => set({
servers: [],
spaces: [],
myRequests: [],
searchQuery: '',
isLoading: false,
+7 -7
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse } from '@backspace/shared';
import { BackspaceApiClient, createApiClient, api } from '../api/client';
import { useAuthStore } from './authStore';
import { setApiForOriginResolver, setUserIdForOriginResolver, useServerStore } from './serverStore';
import { setApiForOriginResolver, setUserIdForOriginResolver, useSpaceStore } from './spaceStore';
import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -282,8 +282,8 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
return { instances: updated };
});
// Remove servers from this instance from the server store
useServerStore.getState().removeInstanceServers(origin);
// Remove spaces from this instance from the space store
useSpaceStore.getState().removeInstanceSpaces(origin);
// Sync updated list to remaining instances (fire-and-forget)
get().syncInstanceList().catch(() => {});
@@ -479,10 +479,10 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
},
reset: () => {
// Clean up server store for each connected remote instance before tearing down
// Clean up space store for each connected remote instance before tearing down
const { instances } = get();
for (const inst of instances) {
useServerStore.getState().removeInstanceServers(inst.origin);
useSpaceStore.getState().removeInstanceSpaces(inst.origin);
}
// Tear down all remote WebSocket connections
@@ -494,9 +494,9 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
}));
// ─── API client resolution ───────────────────────────────────────────────────
// Register the resolver with serverStore so getApiForOrigin() works everywhere.
// Register the resolver with spaceStore so getApiForOrigin() works everywhere.
// Placed after store creation so useInstanceStore is definitely initialized.
// This breaks the circular dependency: chatStore → serverStore ← instanceStore
// This breaks the circular dependency: chatStore → spaceStore ← instanceStore
// instead of: chatStore → instanceStore → useWebSocket → chatStore (cycle).
setApiForOriginResolver((origin: string): BackspaceApiClient => {
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User, UpdateServerRequest } from '@backspace/shared';
import type { Space, Channel, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest } from '@backspace/shared';
import { api, BackspaceApiClient } from '../api/client';
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
import { isSelf } from '../utils/identity';
@@ -8,7 +8,7 @@ import { useAuthStore } from './authStore';
// ─── Instance-aware types ─────────────────────────────────────────────────────
/** Server augmented with instance origin tracking (client-only, not in shared types). */
export type TaggedServer = Server & { _instanceOrigin: string };
export type TaggedSpace = Space & { _instanceOrigin: string };
// ─── Error types ─────────────────────────────────────────────────────────────
@@ -22,21 +22,21 @@ export class NotConnectedError extends Error {
// ─── Store interface ──────────────────────────────────────────────────────────
interface ServerState {
servers: TaggedServer[];
currentServerId: string | null;
interface SpaceState {
spaces: TaggedSpace[];
currentSpaceId: string | null;
channels: Channel[];
members: MemberWithUser[];
roles: Role[];
folders: ServerFolder[];
folders: SpaceFolder[];
dmChannels: DmChannel[];
channelToServerMap: Map<string, string>;
channelToSpaceMap: Map<string, string>;
channelLastMessageIds: Map<string, string>;
serverPermissions: Map<string, string>; // serverId → myPermissions decimal string
spacePermissions: Map<string, string>; // spaceId → myPermissions decimal string
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
setServers: (servers: TaggedServer[]) => void;
setCurrentServer: (serverId: string | null) => void;
setSpaces: (spaces: TaggedSpace[]) => void;
setCurrentSpace: (spaceId: string | null) => void;
setChannels: (channels: Channel[]) => void;
setMembers: (members: MemberWithUser[]) => void;
setRoles: (roles: Role[]) => void;
@@ -46,44 +46,44 @@ interface ServerState {
addDmMember: (dmChannelId: string, user: User) => void;
removeDmMember: (dmChannelId: string, userId: string) => void;
closeDm: (id: string) => Promise<void>;
loadServers: () => Promise<void>;
loadServerDetail: (serverId: string) => Promise<void>;
loadSpaces: () => Promise<void>;
loadSpaceDetail: (spaceId: string) => Promise<void>;
loadDmChannels: () => Promise<void>;
createServer: (name: string, icon?: string) => Promise<Server>;
updateServer: (serverId: string, data: UpdateServerRequest) => Promise<void>;
deleteServer: (serverId: string) => Promise<void>;
joinServer: (serverId: string, inviteCode: string) => Promise<void>;
joinByCode: (inviteCode: string, origin?: string) => Promise<Server>;
generateInvite: (serverId: string) => Promise<string>;
createChannel: (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
createSpace: (name: string, icon?: string) => Promise<Space>;
updateSpace: (spaceId: string, data: UpdateSpaceRequest) => Promise<void>;
deleteSpace: (spaceId: string) => Promise<void>;
joinSpace: (spaceId: string, inviteCode: string) => Promise<void>;
joinByCode: (inviteCode: string, origin?: string) => Promise<Space>;
generateInvite: (spaceId: string) => Promise<string>;
createChannel: (spaceId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>;
addServer: (server: Server) => void;
removeServer: (serverId: string) => void;
addSpace: (space: Space) => void;
removeSpace: (spaceId: string) => void;
updateMemberPresence: (userId: string, status: string) => void;
addMember: (member: MemberWithUser) => void;
removeMember: (userId: string) => void;
populateFromReady: (origin: string, servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => void;
addServerFromReady: (origin: string, server: ServerWithChannelsAndMembers) => void;
removeInstanceServers: (origin: string) => void;
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[]) => void;
addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void;
removeInstanceSpaces: (origin: string) => void;
findExistingDmForUser: (targetUser: { id: string; homeUserId?: string | null }) => { dm: DmChannel; origin: string } | null;
}
export const useServerStore = create<ServerState>((set, get) => ({
servers: [],
currentServerId: null,
export const useSpaceStore = create<SpaceState>((set, get) => ({
spaces: [],
currentSpaceId: null,
channels: [],
members: [],
roles: [],
folders: [],
dmChannels: [],
channelToServerMap: new Map(),
channelToSpaceMap: new Map(),
channelLastMessageIds: new Map(),
serverPermissions: new Map(),
spacePermissions: new Map(),
channelPermissions: new Map(),
channelOriginMap: new Map(),
setServers: (servers) => set({ servers }),
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
setSpaces: (spaces) => set({ spaces }),
setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }),
setChannels: (channels) => set({ channels }),
setMembers: (members) => set({ members }),
setRoles: (roles) => set({ roles }),
@@ -129,26 +129,26 @@ export const useServerStore = create<ServerState>((set, get) => ({
}));
},
loadServers: async () => {
loadSpaces: async () => {
try {
const servers = await api.servers.list();
const spaces =await api.spaces.list();
set((state) => ({
servers: servers.map(s => ({ ...s, _instanceOrigin: '' })) as TaggedServer[],
spaces: spaces.map(s => ({ ...s, _instanceOrigin: '' })) as TaggedSpace[],
}));
} catch {
// Silently fail - will be populated from WS ready
}
},
loadServerDetail: async (serverId: string) => {
loadSpaceDetail: async (spaceId: string) => {
try {
// Resolve the correct API client based on the server's instance origin
const server = get().servers.find(s => s.id === serverId);
if (!server) return; // Not populated yet — remote WS ready will trigger reload
const origin = server._instanceOrigin ?? '';
const space =get().spaces.find(s => s.id === spaceId);
if (!space) return; // Not populated yet — remote WS ready will trigger reload
const origin = space._instanceOrigin ?? '';
const client = getApiForOrigin(origin);
const detail = await client.servers.get(serverId);
const detail = await client.spaces.get(spaceId);
// Normalize remote asset URLs (avatars, server icon)
if (origin) {
if (detail.icon) detail.icon = resolveAssetUrl(detail.icon, origin) ?? detail.icon;
@@ -157,7 +157,7 @@ export const useServerStore = create<ServerState>((set, get) => ({
}
}
set({
currentServerId: serverId,
currentSpaceId: spaceId,
channels: detail.channels.sort((a, b) => a.position - b.position),
members: detail.members,
roles: detail.roles.sort((a, b) => b.position - a.position),
@@ -176,33 +176,33 @@ export const useServerStore = create<ServerState>((set, get) => ({
}
},
createServer: async (name: string, icon?: string) => {
const server = await api.servers.create({ name, icon });
const tagged: TaggedServer = { ...server, _instanceOrigin: '' };
set((state) => ({ servers: [...state.servers, tagged] }));
return server;
createSpace: async (name: string, icon?: string) => {
const space =await api.spaces.create({ name, icon });
const tagged: TaggedSpace = { ...space, _instanceOrigin: '' };
set((state) => ({ spaces: [...state.spaces, tagged] }));
return space;
},
updateServer: async (serverId: string, data: UpdateServerRequest) => {
const updated = await api.servers.update(serverId, data);
updateSpace: async (spaceId: string, data: UpdateSpaceRequest) => {
const updated = await api.spaces.update(spaceId, data);
set((state) => ({
servers: state.servers.map(s => s.id === serverId ? { ...s, ...updated } : s),
spaces: state.spaces.map(s => s.id === spaceId ? { ...s, ...updated } : s),
}));
},
deleteServer: async (serverId: string) => {
await api.servers.delete(serverId);
deleteSpace: async (spaceId: string) => {
await api.spaces.delete(spaceId);
set((state) => ({
servers: state.servers.filter(s => s.id !== serverId),
currentServerId: state.currentServerId === serverId ? null : state.currentServerId,
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
}));
},
joinServer: async (serverId: string, inviteCode: string) => {
const server = await api.servers.join(serverId, { inviteCode });
joinSpace: async (spaceId: string, inviteCode: string) => {
const space =await api.spaces.join(spaceId, { inviteCode });
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, { ...server, _instanceOrigin: '' } as TaggedServer] };
if (state.spaces.find(s => s.id === space.id)) return state;
return { spaces: [...state.spaces, { ...space, _instanceOrigin: '' } as TaggedSpace] };
});
},
@@ -216,33 +216,33 @@ export const useServerStore = create<ServerState>((set, get) => ({
if (!connected) throw new NotConnectedError(origin);
const remoteApi = getApiForOrigin(origin);
const server = await remoteApi.servers.joinByCode(inviteCode);
const space =await remoteApi.spaces.joinByCode(inviteCode);
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, { ...server, _instanceOrigin: origin } as TaggedServer] };
if (state.spaces.find(s => s.id === space.id)) return state;
return { spaces: [...state.spaces, { ...space, _instanceOrigin: origin } as TaggedSpace] };
});
return server;
return space;
}
// Home instance
const server = await api.servers.joinByCode(inviteCode);
const space =await api.spaces.joinByCode(inviteCode);
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, { ...server, _instanceOrigin: '' } as TaggedServer] };
if (state.spaces.find(s => s.id === space.id)) return state;
return { spaces: [...state.spaces, { ...space, _instanceOrigin: '' } as TaggedSpace] };
});
return server;
return space;
},
generateInvite: async (serverId: string) => {
const server = get().servers.find(s => s.id === serverId);
const origin = server?._instanceOrigin ?? '';
generateInvite: async (spaceId: string) => {
const space =get().spaces.find(s => s.id === spaceId);
const origin = space?._instanceOrigin ?? '';
const client = getApiForOrigin(origin);
const result = await client.servers.invite(serverId);
const result = await client.spaces.invite(spaceId);
return result.inviteCode;
},
createChannel: async (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => {
const channel = await api.channels.create(serverId, { name, type, topic });
createChannel: async (spaceId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => {
const channel = await api.channels.create(spaceId, { name, type, topic });
set((state) => {
if (state.channels.some(c => c.id === channel.id)) return state;
return { channels: [...state.channels, channel].sort((a, b) => a.position - b.position) };
@@ -257,17 +257,17 @@ export const useServerStore = create<ServerState>((set, get) => ({
}));
},
addServer: (server: Server) => {
addSpace: (space: Space) => {
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, { ...server, _instanceOrigin: '' } as TaggedServer] };
if (state.spaces.find(s => s.id === space.id)) return state;
return { spaces: [...state.spaces, { ...space, _instanceOrigin: '' } as TaggedSpace] };
});
},
removeServer: (serverId: string) => {
removeSpace: (spaceId: string) => {
set((state) => ({
servers: state.servers.filter(s => s.id !== serverId),
currentServerId: state.currentServerId === serverId ? null : state.currentServerId,
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
}));
},
@@ -291,11 +291,11 @@ export const useServerStore = create<ServerState>((set, get) => ({
}));
},
populateFromReady: (origin: string, servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => {
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[]) => {
const isHome = !origin;
// Tag all incoming servers with their instance origin
const taggedServers: TaggedServer[] = servers.map(s => ({
const taggedSpaces: TaggedSpace[] = spaces.map(s => ({
id: s.id,
name: s.name,
icon: s.icon,
@@ -308,13 +308,13 @@ export const useServerStore = create<ServerState>((set, get) => ({
}));
// Merge by origin: keep servers from other origins, replace all from this origin
const existingFromOtherOrigins = get().servers.filter(s => s._instanceOrigin !== origin);
const mergedServers = [...existingFromOtherOrigins, ...taggedServers];
const existingFromOtherOrigins = get().spaces.filter(s => s._instanceOrigin !== origin);
const mergedSpaces = [...existingFromOtherOrigins, ...taggedSpaces];
// Build/merge maps for incoming channels
const channelToServerMap = new Map(get().channelToServerMap);
const channelToSpaceMap = new Map(get().channelToSpaceMap);
const channelLastMessageIds = new Map(get().channelLastMessageIds);
const serverPermissions = new Map(get().serverPermissions);
const spacePermissions = new Map(get().spacePermissions);
const channelPermissions = new Map(get().channelPermissions);
const channelOriginMap = new Map(get().channelOriginMap);
@@ -322,42 +322,42 @@ export const useServerStore = create<ServerState>((set, get) => ({
if (isHome) {
for (const [key, val] of get().channelOriginMap) {
if (val === origin) {
channelToServerMap.delete(key);
channelToSpaceMap.delete(key);
channelLastMessageIds.delete(key);
channelPermissions.delete(key);
channelOriginMap.delete(key);
}
}
// Also clear server permissions for this origin
for (const s of get().servers) {
for (const s of get().spaces) {
if (s._instanceOrigin === origin) {
serverPermissions.delete(s.id);
spacePermissions.delete(s.id);
}
}
} else {
// Remote: clear entries that belonged to this origin
for (const [key, val] of get().channelOriginMap) {
if (val === origin) {
channelToServerMap.delete(key);
channelToSpaceMap.delete(key);
channelLastMessageIds.delete(key);
channelPermissions.delete(key);
channelOriginMap.delete(key);
}
}
for (const s of get().servers) {
for (const s of get().spaces) {
if (s._instanceOrigin === origin) {
serverPermissions.delete(s.id);
spacePermissions.delete(s.id);
}
}
}
// Populate maps from incoming servers
for (const srv of servers) {
for (const srv of spaces) {
if (srv.myPermissions) {
serverPermissions.set(srv.id, srv.myPermissions);
spacePermissions.set(srv.id, srv.myPermissions);
}
for (const ch of srv.channels) {
channelToServerMap.set(ch.id, srv.id);
channelToSpaceMap.set(ch.id, srv.id);
channelOriginMap.set(ch.id, origin);
if (ch.lastMessageId) {
channelLastMessageIds.set(ch.id, ch.lastMessageId);
@@ -390,12 +390,12 @@ export const useServerStore = create<ServerState>((set, get) => ({
});
const mergedDms = [...existingDmsFromOtherOrigins, ...incomingDms];
const update: Partial<ServerState> = {
servers: mergedServers,
const update: Partial<SpaceState> = {
spaces: mergedSpaces,
dmChannels: mergedDms,
channelToServerMap,
channelToSpaceMap,
channelLastMessageIds,
serverPermissions,
spacePermissions,
channelPermissions,
channelOriginMap,
};
@@ -408,30 +408,30 @@ export const useServerStore = create<ServerState>((set, get) => ({
set(update as any);
},
addServerFromReady: (origin: string, server: ServerWithChannelsAndMembers) => {
const tagged: TaggedServer = {
id: server.id,
name: server.name,
icon: server.icon,
ownerId: server.ownerId,
inviteCode: server.inviteCode,
visibility: server.visibility,
description: server.description,
createdAt: server.createdAt,
addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => {
const tagged: TaggedSpace = {
id: space.id,
name: space.name,
icon: space.icon,
ownerId: space.ownerId,
inviteCode: space.inviteCode,
visibility: space.visibility,
description: space.description,
createdAt: space.createdAt,
_instanceOrigin: origin,
};
const channelToServerMap = new Map(get().channelToServerMap);
const channelToSpaceMap = new Map(get().channelToSpaceMap);
const channelLastMessageIds = new Map(get().channelLastMessageIds);
const serverPermissions = new Map(get().serverPermissions);
const spacePermissions = new Map(get().spacePermissions);
const channelPermissions = new Map(get().channelPermissions);
const channelOriginMap = new Map(get().channelOriginMap);
if (server.myPermissions) {
serverPermissions.set(server.id, server.myPermissions);
if (space.myPermissions) {
spacePermissions.set(space.id, space.myPermissions);
}
for (const ch of server.channels) {
channelToServerMap.set(ch.id, server.id);
for (const ch of space.channels) {
channelToSpaceMap.set(ch.id, space.id);
channelOriginMap.set(ch.id, origin);
if (ch.lastMessageId) {
channelLastMessageIds.set(ch.id, ch.lastMessageId);
@@ -442,10 +442,10 @@ export const useServerStore = create<ServerState>((set, get) => ({
}
set((state) => ({
servers: [...state.servers.filter(s => s.id !== server.id), tagged],
channelToServerMap,
spaces: [...state.spaces.filter(s => s.id !== space.id), tagged],
channelToSpaceMap,
channelLastMessageIds,
serverPermissions,
spacePermissions,
channelPermissions,
channelOriginMap,
}));
@@ -470,40 +470,40 @@ export const useServerStore = create<ServerState>((set, get) => ({
return null;
},
removeInstanceServers: (origin: string) => {
removeInstanceSpaces: (origin: string) => {
set((state) => {
const remainingServers = state.servers.filter(s => s._instanceOrigin !== origin);
const remainingSpaces = state.spaces.filter(s => s._instanceOrigin !== origin);
// Clean up maps for channels that belonged to this origin
const channelToServerMap = new Map(state.channelToServerMap);
const channelToSpaceMap = new Map(state.channelToSpaceMap);
const channelLastMessageIds = new Map(state.channelLastMessageIds);
const channelPermissions = new Map(state.channelPermissions);
const channelOriginMap = new Map(state.channelOriginMap);
const serverPermissions = new Map(state.serverPermissions);
const spacePermissions = new Map(state.spacePermissions);
for (const [channelId, chOrigin] of state.channelOriginMap) {
if (chOrigin === origin) {
channelToServerMap.delete(channelId);
channelToSpaceMap.delete(channelId);
channelLastMessageIds.delete(channelId);
channelPermissions.delete(channelId);
channelOriginMap.delete(channelId);
}
}
for (const s of state.servers) {
for (const s of state.spaces) {
if (s._instanceOrigin === origin) {
serverPermissions.delete(s.id);
spacePermissions.delete(s.id);
}
}
return {
servers: remainingServers,
channelToServerMap,
spaces: remainingSpaces,
channelToSpaceMap,
channelLastMessageIds,
channelPermissions,
channelOriginMap,
serverPermissions,
currentServerId: remainingServers.find(s => s.id === state.currentServerId)
? state.currentServerId
spacePermissions,
currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId)
? state.currentSpaceId
: null,
};
});
@@ -516,7 +516,7 @@ export const useServerStore = create<ServerState>((set, get) => ({
* from the WS ready event and DM/server channel IDs never overlap.
*/
export function isDmChannel(channelId: string): boolean {
const dmChannels = useServerStore.getState().dmChannels;
const dmChannels = useSpaceStore.getState().dmChannels;
if (dmChannels.length > 0) {
return dmChannels.some(dm => dm.id === channelId);
}
@@ -532,12 +532,12 @@ export function isDmChannel(channelId: string): boolean {
* '' = home instance, 'https://...' = remote instance.
*/
export function getChannelOrigin(channelId: string): string {
return useServerStore.getState().channelOriginMap.get(channelId) ?? '';
return useSpaceStore.getState().channelOriginMap.get(channelId) ?? '';
}
// ─── API client resolution ────────────────────────────────────────────────────
// The actual resolver is registered by instanceStore on import, avoiding a
// circular dependency (instanceStore → useWebSocket → chatStore → serverStore).
// circular dependency (instanceStore → useWebSocket → chatStore → spaceStore).
let _getApiForOrigin: ((origin: string) => BackspaceApiClient) | null = null;
+9 -9
View File
@@ -3,12 +3,12 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import type { User } from '@backspace/shared';
type ModalType =
| 'createServer'
| 'joinServer'
| 'createSpace'
| 'joinSpace'
| 'createChannel'
| 'invite'
| 'userSettings'
| 'serverSettings'
| 'spaceSettings'
| 'channelSettings'
| 'imagePreview'
| 'newDm'
@@ -46,8 +46,8 @@ interface UIState {
closeUserProfile: () => void;
addToast: (message: string, type?: 'info' | 'warning' | 'success', duration?: number) => void;
removeToast: (id: string) => void;
lastChannelPerServer: Record<string, string>;
setLastChannel: (serverId: string, channelId: string) => void;
lastChannelPerSpace: Record<string, string>;
setLastChannel: (spaceId: string, channelId: string) => void;
voiceChatOpen: boolean;
voiceFullscreen: boolean;
pipCollapsed: boolean;
@@ -112,9 +112,9 @@ export const useUIStore = create<UIState>()(
},
removeToast: (id) => set((state) => ({ toasts: state.toasts.filter(t => t.id !== id) })),
lastChannelPerServer: {},
setLastChannel: (serverId, channelId) => set((state) => ({
lastChannelPerServer: { ...state.lastChannelPerServer, [serverId]: channelId },
lastChannelPerSpace: {},
setLastChannel: (spaceId, channelId) => set((state) => ({
lastChannelPerSpace: { ...state.lastChannelPerSpace, [spaceId]: channelId },
})),
voiceChatOpen: false,
@@ -130,7 +130,7 @@ export const useUIStore = create<UIState>()(
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
memberListOpen: state.memberListOpen,
lastChannelPerServer: state.lastChannelPerServer,
lastChannelPerSpace: state.lastChannelPerSpace,
}),
}
)
+2 -2
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { ParticipantInfo } from '../hooks/useLiveKit';
import { AudioManager } from '../audio/AudioManager';
import { useServerStore, getChannelOrigin, getMyUserIdForOrigin } from './serverStore';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceStore';
export interface ScreenShareConfig {
height: 1080 | 720 | 540;
@@ -278,7 +278,7 @@ export const useVoiceStore = create<VoiceState>()(
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
clearVoiceUsersForOrigin: (origin: string) => {
const { channelOriginMap } = useServerStore.getState();
const { channelOriginMap } = useSpaceStore.getState();
set((state) => {
const newVoiceUsers = new Map(state.voiceUsers);
for (const [channelId] of newVoiceUsers) {
+1 -1
View File
@@ -130,7 +130,7 @@
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
/* Server strip glass — no border-radius, rightward shadow */
/* Space strip glass — no border-radius, rightward shadow */
.glass-strip {
backdrop-filter: blur(20px) saturate(120%);
-webkit-backdrop-filter: blur(20px) saturate(120%);
+1 -1
View File
@@ -1,4 +1,4 @@
import { getApiForOrigin } from '../stores/serverStore';
import { getApiForOrigin } from '../stores/spaceStore';
/**
* Resolve a relative asset filename to an absolute URL for remote origins.
+5 -5
View File
@@ -19,8 +19,8 @@ const AVATAR_GRADIENTS: GradientEntry[] = [
{ gradient: 'linear-gradient(135deg, #f59e0b, #eab308)', glow: '#f59e0b' }, // amber
];
// ── Server icon gradients (server fallbacks) ──
const SERVER_GRADIENTS: GradientEntry[] = [
// ── Space icon gradients (space fallbacks) ──
const SPACE_GRADIENTS: GradientEntry[] = [
{ gradient: 'linear-gradient(135deg, #ef4444, #f97316)', glow: '#f97316' }, // red-orange
{ gradient: 'linear-gradient(135deg, #ec4899, #f472b6)', glow: '#ec4899' }, // pink
{ gradient: 'linear-gradient(135deg, #14b8a6, #06b6d4)', glow: '#06b6d4' }, // teal-cyan
@@ -51,8 +51,8 @@ export function getAvatarGradient(id?: string | null, name?: string): GradientEn
return AVATAR_GRADIENTS[hashString(key) % AVATAR_GRADIENTS.length]!;
}
/** Deterministic gradient for a server icon. Prefers ID for stability; falls back to name. */
export function getServerGradient(id?: string | null, name?: string): GradientEntry {
/** Deterministic gradient for a space icon. Prefers ID for stability; falls back to name. */
export function getSpaceGradient(id?: string | null, name?: string): GradientEntry {
const key = id || name || 'unknown';
return SERVER_GRADIENTS[hashString(key) % SERVER_GRADIENTS.length]!;
return SPACE_GRADIENTS[hashString(key) % SPACE_GRADIENTS.length]!;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useVoiceStore } from '../stores/voiceStore';
import { getChannelOrigin, getMyUserIdForOrigin } from '../stores/serverStore';
import { getChannelOrigin, getMyUserIdForOrigin } from '../stores/spaceStore';
import { wsSend } from '../hooks/useWebSocket';
/**