diff --git a/packages/server/src/routes/explore.ts b/packages/server/src/routes/explore.ts index 359eefbc..c0892b89 100644 --- a/packages/server/src/routes/explore.ts +++ b/packages/server/src/routes/explore.ts @@ -211,14 +211,6 @@ export async function exploreRoutes(app: FastifyInstance): Promise { const params: (string | number)[] = []; - // Exclude servers the user is already in — do this in SQL for correct total/pagination - if (myServerIds.size > 0) { - const placeholders = [...myServerIds].map(() => '?').join(','); - querySql += ` AND s.id NOT IN (${placeholders})`; - countSql += ` AND s.id NOT IN (${placeholders})`; - params.push(...myServerIds); - } - if (q) { const likePattern = `%${q}%`; querySql += ` AND (s.name LIKE ? COLLATE NOCASE OR s.description LIKE ? COLLATE NOCASE)`; @@ -247,6 +239,7 @@ export async function exploreRoutes(app: FastifyInstance): Promise { visibility: r.visibility as ExploreServer['visibility'], memberCount: r.member_count, createdAt: r.created_at, + joined: myServerIds.has(r.id), })); return reply.code(200).send({ servers, total: totalRow.total, totalAll: totalAllRow.total, discoveryEnabled: true }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8fc52f3f..9880508b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -49,6 +49,7 @@ export interface ExploreServer { visibility: ServerVisibility; memberCount: number; createdAt: number; + joined?: boolean; } export interface JoinRequest { diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 16ff87eb..8e391317 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -118,7 +118,7 @@ export class BackspaceApiClient { }; readonly explore: { - list: (q?: string, limit?: number, offset?: number) => Promise<{ servers: ExploreServer[]; total: number; discoveryEnabled: boolean }>; + list: (q?: string, limit?: number, offset?: number) => Promise<{ servers: ExploreServer[]; total: number; totalAll: number; discoveryEnabled: boolean }>; publicJoin: (serverId: string) => Promise; requestJoin: (serverId: string, message?: string) => Promise; getJoinRequests: (serverId: string, status?: string) => Promise<{ requests: JoinRequest[] }>; @@ -305,7 +305,7 @@ 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; discoveryEnabled: boolean }>( + return request<{ servers: ExploreServer[]; total: number; totalAll: number; discoveryEnabled: boolean }>( 'GET', `/servers/explore?${params}` ); }, diff --git a/packages/web/src/components/chat/ExplorePage.tsx b/packages/web/src/components/chat/ExplorePage.tsx index 7b3cca45..2b5f1815 100644 --- a/packages/web/src/components/chat/ExplorePage.tsx +++ b/packages/web/src/components/chat/ExplorePage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +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'; @@ -15,13 +15,14 @@ export function ExplorePage() { const myRequests = useExploreStore((s) => s.myRequests); const isLoading = useExploreStore((s) => s.isLoading); const discoveryEnabled = useExploreStore((s) => s.discoveryEnabled); - const totalAll = useExploreStore((s) => s.totalAll); 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 fetchMyRequests = useExploreStore((s) => s.fetchMyRequests); + const [joinedCollapsed, setJoinedCollapsed] = useState(false); + const debounceRef = useRef | null>(null); // Fetch on mount @@ -51,6 +52,19 @@ export function ExplorePage() { navigate(`/channels/${serverId}`); }; + const unjoinedServers = useMemo( + () => servers.filter(s => !s.joined), + [servers], + ); + const joinedServers = useMemo( + () => servers.filter(s => s.joined), + [servers], + ); + + const hasAnyServers = servers.length > 0; + const hasUnjoined = unjoinedServers.length > 0; + const hasJoined = joinedServers.length > 0; + return (
{/* Header */} @@ -101,7 +115,8 @@ export function ExplorePage() {
{error}
- ) : servers.length === 0 ? ( + ) : !hasAnyServers ? ( + /* True empty state — no discoverable servers at all */
@@ -109,21 +124,75 @@ export function ExplorePage() {

{searchQuery ? 'No servers match your search.' - : totalAll > 0 - ? `You've already joined all ${totalAll} discoverable server${totalAll === 1 ? '' : 's'}.` - : 'No servers have been made discoverable yet.'} + : 'No servers have been made discoverable yet.'}

) : ( -
- {servers.map((server) => ( - r.serverId === server.id && r.status === 'pending')} - onJoinSuccess={handleJoinSuccess} - /> - ))} +
+ {/* All-joined success banner (only when no unjoined servers remain) */} + {!hasUnjoined && hasJoined && !searchQuery && ( +
+ + + + + You're in all discoverable servers + +
+ )} + + {/* Unjoined servers grid */} + {hasUnjoined && ( +
+ {unjoinedServers.map((server) => ( + r.serverId === server.id && r.status === 'pending')} + onJoinSuccess={handleJoinSuccess} + /> + ))} +
+ )} + + {/* Joined servers section */} + {hasJoined && ( +
+ + + {!joinedCollapsed && ( +
+ {joinedServers.map((server) => ( + + ))} +
+ )} +
+ )}
)}
@@ -151,6 +220,7 @@ function ServerCard({ 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; } })() : null; @@ -181,8 +251,16 @@ function ServerCard({ } }; + const handleViewServer = () => { + onJoinSuccess(server.id); + }; + return ( -
+
{/* Banner / Icon area */}
{server.icon ? ( @@ -197,6 +275,18 @@ function ServerCard({ )} + {/* Joined badge (top-left) */} + {isJoined && ( +
+ + + + + Joined + +
+ )} + {/* Visibility badge */}
{joinError}
)} - {isPublic ? ( + {isJoined ? ( + + ) : isPublic ? (