fix: DM avatar color and reactions in federation + explore/server discovery

- Fix DM welcome header avatar using home identity for correct gradient color
- Register DM channel IDs in channelOriginMap so federated DM operations
  (reactions, messages, typing) route to the correct instance
- Pass origin when creating DM channels from friends list and WS events
- Add server discovery/explore page with public server listings
- Add server visibility and description fields
This commit is contained in:
Jannis Braun
2026-03-04 18:02:43 +01:00
parent 72d1fab930
commit 6e44a4ef2f
29 changed files with 1614 additions and 108 deletions
+144
View File
@@ -0,0 +1,144 @@
import { create } from 'zustand';
import type { ExploreServer, JoinRequest, ServerWithChannelsAndMembers } from '@backspace/shared';
import { api } from '../api/client';
import { useInstanceStore } from './instanceStore';
import { useServerStore } from './serverStore';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface TaggedExploreServer extends ExploreServer {
_instanceOrigin: string; // '' = home instance
}
interface ExploreState {
servers: TaggedExploreServer[];
myRequests: JoinRequest[];
searchQuery: string;
isLoading: boolean;
discoveryEnabled: boolean;
error: string | null;
fetchServers: (query?: string) => Promise<void>;
fetchMyRequests: () => Promise<void>;
publicJoin: (server: TaggedExploreServer) => Promise<ServerWithChannelsAndMembers>;
requestJoin: (server: TaggedExploreServer, 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) => ({
servers: [],
myRequests: [],
searchQuery: '',
isLoading: false,
discoveryEnabled: true,
error: null,
fetchServers: async (query?: string) => {
set({ isLoading: true, error: null });
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 allServers: TaggedExploreServer[] = [];
const seen = new Set<string>(); // dedup by serverId+origin
let homeDiscoveryEnabled = true;
for (const result of results) {
if (result.status !== 'fulfilled') continue;
const { servers, discoveryEnabled, origin } = result.value;
// Track home instance discovery state
if (!origin) {
homeDiscoveryEnabled = discoveryEnabled;
}
for (const server of servers) {
const key = `${server.id}:${origin}`;
if (seen.has(key)) continue;
seen.add(key);
allServers.push({ ...server, _instanceOrigin: origin });
}
}
set({
servers: allServers,
discoveryEnabled: homeDiscoveryEnabled,
isLoading: false,
});
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to fetch servers',
});
}
},
fetchMyRequests: async () => {
try {
const { requests } = await api.explore.myJoinRequests('pending');
set({ myRequests: requests });
} catch {
// Non-critical — silently fail
}
},
publicJoin: async (server: TaggedExploreServer) => {
const client = getApiForOrigin(server._instanceOrigin);
const fullServer = await client.explore.publicJoin(server.id);
// Add to server store
useServerStore.getState().addServerFromReady(server._instanceOrigin, fullServer);
// Remove from explore list
set((state) => ({
servers: state.servers.filter(s =>
!(s.id === server.id && s._instanceOrigin === server._instanceOrigin)
),
}));
return fullServer;
},
requestJoin: async (server: TaggedExploreServer, message?: string) => {
const client = getApiForOrigin(server._instanceOrigin);
const request = await client.explore.requestJoin(server.id, message);
set((state) => ({
myRequests: [...state.myRequests, request],
}));
return request;
},
setSearchQuery: (q: string) => set({ searchQuery: q }),
reset: () => set({
servers: [],
myRequests: [],
searchQuery: '',
isLoading: false,
discoveryEnabled: true,
error: null,
}),
}));
+17 -35
View File
@@ -140,13 +140,12 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
const tempClient = createApiClient(origin, () => null);
let response: AuthResponse | null = null;
let finalUsername = currentUser.username;
let needsLogin = false;
const finalUsername = `${currentUser.username}@${homeInstance}`;
// 2a: Attempt registration with plain username
// 2a: Attempt registration with namespaced username
try {
response = await tempClient.auth.register({
username: currentUser.username,
username: finalUsername,
password,
displayName: displayName || currentUser.displayName || undefined,
homeInstance,
@@ -154,52 +153,29 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
});
} catch (err) {
const message = (err as Error).message;
if (message.includes('already taken') || message.includes('409')) {
// Username collision — try domain-qualified username
try {
finalUsername = `${currentUser.username}@${homeInstance}`;
response = await tempClient.auth.register({
username: finalUsername,
password,
displayName: displayName || currentUser.displayName || undefined,
homeInstance,
homeUserId: currentUser.id,
});
} catch (err2) {
const msg2 = (err2 as Error).message;
if (msg2.includes('already taken') || msg2.includes('409')) {
// Both usernames exist on remote — fall through to login
needsLogin = true;
} else {
throw err2;
}
}
} else if (message.includes('Registration is currently closed') || message.includes('403')) {
// Registration closed on remote — fall through to login
needsLogin = true;
if (message.includes('already taken') || message.includes('409') ||
message.includes('Registration is currently closed') || message.includes('403')) {
// Already registered or registration closed — fall through to login
} else {
throw err;
}
}
// 2b: If registration didn't work, try login with the same password
if (needsLogin) {
// Try plain username first, then domain-qualified
// 2b: If registration didn't work, try login
if (!response) {
try {
response = await tempClient.auth.login({
username: currentUser.username,
username: finalUsername,
password,
});
finalUsername = currentUser.username;
} catch {
// Namespaced login failed — try legacy plain username as fallback
try {
finalUsername = `${currentUser.username}@${homeInstance}`;
response = await tempClient.auth.login({
username: finalUsername,
username: currentUser.username,
password,
});
} catch {
// Both login attempts failed — different password scenario
throw new DifferentPasswordError(currentUser.username);
}
}
@@ -390,6 +366,12 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
}
}
// Backfill cached username if stale after server-side migration
// (e.g. "test" was renamed to "test@nova.ddns.net")
if (user.username !== cachedEntry.username) {
cachedEntry.username = user.username;
}
const connectedInstance: ConnectedInstance = {
origin,
label,
+61 -7
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User } from '@backspace/shared';
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User, UpdateServerRequest } from '@backspace/shared';
import { api, BackspaceApiClient } from '../api/client';
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
@@ -39,7 +39,7 @@ interface ServerState {
setMembers: (members: MemberWithUser[]) => void;
setRoles: (roles: Role[]) => void;
setDmChannels: (channels: DmChannel[]) => void;
addDmChannel: (channel: DmChannel) => void;
addDmChannel: (channel: DmChannel, origin?: string) => void;
removeDmChannel: (id: string) => void;
addDmMember: (dmChannelId: string, user: User) => void;
removeDmMember: (dmChannelId: string, userId: string) => void;
@@ -48,7 +48,7 @@ interface ServerState {
loadServerDetail: (serverId: string) => Promise<void>;
loadDmChannels: () => Promise<void>;
createServer: (name: string, icon?: string) => Promise<Server>;
updateServer: (serverId: string, data: { name?: string; icon?: string }) => Promise<void>;
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>;
@@ -61,6 +61,7 @@ interface ServerState {
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;
}
@@ -85,9 +86,16 @@ export const useServerStore = create<ServerState>((set, get) => ({
setRoles: (roles) => set({ roles }),
setDmChannels: (dmChannels) => set({ dmChannels }),
addDmChannel: (channel) => set((state) => ({
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)]
})),
addDmChannel: (channel, origin?: string) => set((state) => {
const channelOriginMap = new Map(state.channelOriginMap);
if (origin !== undefined) {
channelOriginMap.set(channel.id, origin);
}
return {
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)],
channelOriginMap,
};
}),
removeDmChannel: (id) => set((state) => ({
dmChannels: state.dmChannels.filter(c => c.id !== id)
@@ -169,7 +177,7 @@ export const useServerStore = create<ServerState>((set, get) => ({
return server;
},
updateServer: async (serverId: string, data: { name?: string; icon?: string }) => {
updateServer: async (serverId: string, data: UpdateServerRequest) => {
const updated = await api.servers.update(serverId, data);
set((state) => ({
servers: state.servers.map(s => s.id === serverId ? { ...s, ...updated } : s),
@@ -284,6 +292,8 @@ export const useServerStore = create<ServerState>((set, get) => ({
icon: s.icon,
ownerId: s.ownerId,
inviteCode: s.inviteCode,
visibility: s.visibility ?? 'private' as const,
description: s.description ?? null,
createdAt: s.createdAt,
_instanceOrigin: origin,
}));
@@ -353,6 +363,7 @@ export const useServerStore = create<ServerState>((set, get) => ({
const dms = isHome ? (dmChannels || []) : get().dmChannels;
if (isHome) {
for (const dm of dms) {
channelOriginMap.set(dm.id, origin);
if (dm.lastMessage?.id) {
channelLastMessageIds.set(dm.id, dm.lastMessage.id);
}
@@ -377,6 +388,49 @@ 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,
_instanceOrigin: origin,
};
const channelToServerMap = new Map(get().channelToServerMap);
const channelLastMessageIds = new Map(get().channelLastMessageIds);
const serverPermissions = new Map(get().serverPermissions);
const channelPermissions = new Map(get().channelPermissions);
const channelOriginMap = new Map(get().channelOriginMap);
if (server.myPermissions) {
serverPermissions.set(server.id, server.myPermissions);
}
for (const ch of server.channels) {
channelToServerMap.set(ch.id, server.id);
channelOriginMap.set(ch.id, origin);
if (ch.lastMessageId) {
channelLastMessageIds.set(ch.id, ch.lastMessageId);
}
if (ch.myPermissions) {
channelPermissions.set(ch.id, ch.myPermissions);
}
}
set((state) => ({
servers: [...state.servers.filter(s => s.id !== server.id), tagged],
channelToServerMap,
channelLastMessageIds,
serverPermissions,
channelPermissions,
channelOriginMap,
}));
},
removeInstanceServers: (origin: string) => {
set((state) => {
const remainingServers = state.servers.filter(s => s._instanceOrigin !== origin);
+1
View File
@@ -18,6 +18,7 @@ const DEFAULT_LIMITS: InstanceStreamingLimits = {
allowedFramerates: [30, 45, 60],
maxResolution: 1080,
maxFramerate: 60,
discoveryEnabled: true,
};
export function getStreamingLimits(): InstanceStreamingLimits {
+5 -1
View File
@@ -28,6 +28,7 @@ interface UIState {
modalData: Record<string, unknown>;
isMobile: boolean;
showDms: boolean;
showExplore: boolean;
imagePreviewUrl: string | null;
userProfilePopout: {
user: User | null;
@@ -40,6 +41,7 @@ interface UIState {
closeModal: () => void;
setIsMobile: (isMobile: boolean) => void;
setShowDms: (show: boolean) => void;
setShowExplore: (show: boolean) => void;
openImagePreview: (url: string) => void;
closeImagePreview: () => void;
openUserProfile: (user: User, position: { top: number; left: number }) => void;
@@ -64,6 +66,7 @@ export const useUIStore = create<UIState>()(
modalData: {},
isMobile: false,
showDms: false,
showExplore: false,
imagePreviewUrl: null,
userProfilePopout: {
user: null,
@@ -89,7 +92,8 @@ export const useUIStore = create<UIState>()(
}
},
setShowDms: (show) => set({ showDms: show }),
setShowDms: (show) => set({ showDms: show, ...(show ? { showExplore: false } : {}) }),
setShowExplore: (show) => set({ showExplore: show, ...(show ? { showDms: false } : {}) }),
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),