Files
backspace/packages/web/src/stores/exploreStore.ts
T
Jannis Braun 22e7616c70 feat: explore page space banners, icon-derived gradients, and space descriptions
- Redesign explore cards with banner images, overlapping icons, and frosted fade
- Extract dominant colors from space icons for dynamic banner gradients
- Add space description/banner fields to schema with migration
- Move origin label from banner overlay to content metadata row
- Support space descriptions in settings overview panel
2026-03-09 13:49:50 +01:00

186 lines
6.2 KiB
TypeScript

import { create } from 'zustand';
import type { ExploreSpace, JoinRequest, SpaceWithChannelsAndMembers } from '@backspace/shared';
import { api } from '../api/client';
import { resolveAssetUrl } from '../utils/assetUrls';
import { useInstanceStore } from './instanceStore';
import { useSpaceStore } from './spaceStore';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface TaggedExploreSpace extends ExploreSpace {
_instanceOrigin: string; // '' = home instance
}
interface ExploreState {
spaces: TaggedExploreSpace[];
myRequests: JoinRequest[];
searchQuery: string;
isLoading: boolean;
discoveryEnabled: boolean;
totalAll: number;
error: string | null;
fetchSpaces: (query?: string) => Promise<void>;
fetchMyRequests: () => Promise<void>;
publicJoin: (space: TaggedExploreSpace) => Promise<SpaceWithChannelsAndMembers>;
requestJoin: (space: TaggedExploreSpace, message?: string) => Promise<JoinRequest>;
setSearchQuery: (q: string) => void;
reset: () => void;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function getApiForOrigin(origin: string) {
if (!origin) return api;
const instance = useInstanceStore.getState().instances.find(i => i.origin === origin);
return instance?.api ?? api;
}
// ─── Store ──────────────────────────────────────────────────────────────────
export const useExploreStore = create<ExploreState>((set, get) => ({
spaces: [],
myRequests: [],
searchQuery: '',
isLoading: false,
discoveryEnabled: true,
totalAll: 0,
error: null,
fetchSpaces: async (query?: string) => {
set({ isLoading: true, error: null });
// Wait for autoConnectAll to finish if it hasn't yet.
// This prevents fetching with an incomplete/empty instance list on page reload.
if (!useInstanceStore.getState()._autoConnectDone) {
await new Promise<void>((resolve) => {
const unsub = useInstanceStore.subscribe((state) => {
if (state._autoConnectDone) {
unsub();
resolve();
}
});
// Re-check after subscribing to avoid TOCTOU race
if (useInstanceStore.getState()._autoConnectDone) {
unsub();
resolve();
}
});
}
try {
const instances = useInstanceStore.getState().instances;
const connectedInstances = instances.filter(i => i.status === 'connected');
// Fetch from home + all connected remote instances in parallel
const results = await Promise.allSettled([
api.explore.list(query).then(res => ({ ...res, origin: '' })),
...connectedInstances.map(inst =>
inst.api.explore.list(query).then(res => ({ ...res, origin: inst.origin }))
),
]);
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 instance for discovery' });
return;
}
const allSpaces: TaggedExploreSpace[] = [];
const seen = new Set<string>(); // dedup by spaceId+origin
let homeDiscoveryEnabled = true;
let totalAllSum = 0;
for (const result of fulfilled) {
const { spaces, discoveryEnabled, totalAll, origin } = result.value;
// Track home instance discovery state
if (!origin) {
homeDiscoveryEnabled = discoveryEnabled;
}
totalAllSum += totalAll ?? 0;
for (const space of spaces) {
const key = `${space.id}:${origin}`;
if (seen.has(key)) continue;
seen.add(key);
if (origin && space.icon) {
space.icon = resolveAssetUrl(space.icon, origin) ?? space.icon;
}
if (origin && space.banner) {
space.banner = resolveAssetUrl(space.banner, origin) ?? space.banner;
}
allSpaces.push({ ...space, _instanceOrigin: origin, joined: space.joined ?? false });
}
}
set({
spaces: allSpaces,
discoveryEnabled: homeDiscoveryEnabled,
totalAll: totalAllSum,
isLoading: false,
});
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to fetch spaces',
});
}
},
fetchMyRequests: async () => {
try {
const { requests } = await api.explore.myJoinRequests('pending');
set({ myRequests: requests });
} catch {
// Non-critical — silently fail
}
},
publicJoin: async (space: TaggedExploreSpace) => {
const client = getApiForOrigin(space._instanceOrigin);
const fullSpace = await client.explore.publicJoin(space.id);
// Add to space store
useSpaceStore.getState().addSpaceFromReady(space._instanceOrigin, fullSpace);
// Mark as joined in explore list
set((state) => ({
spaces: state.spaces.map(s =>
s.id === space.id && s._instanceOrigin === space._instanceOrigin
? { ...s, joined: true }
: s
),
}));
return fullSpace;
},
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],
}));
return request;
},
setSearchQuery: (q: string) => set({ searchQuery: q }),
reset: () => set({
spaces: [],
myRequests: [],
searchQuery: '',
isLoading: false,
discoveryEnabled: true,
totalAll: 0,
error: null,
}),
}));