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:
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,7 @@ const DEFAULT_LIMITS: InstanceStreamingLimits = {
|
||||
allowedFramerates: [30, 45, 60],
|
||||
maxResolution: 1080,
|
||||
maxFramerate: 60,
|
||||
discoveryEnabled: true,
|
||||
};
|
||||
|
||||
export function getStreamingLimits(): InstanceStreamingLimits {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user