feat: federated space layout sync — home-authoritative with fallback
Federated users now have their sidebar layout synced from their true home instance instead of each browsing instance maintaining a separate disconnected layout. Layout saves route to the true home API with automatic fallback to the browsing instance if unreachable.
This commit is contained in:
@@ -698,7 +698,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'space_layout_updated': {
|
case 'space_layout_updated': {
|
||||||
if (!isHome) break;
|
// Accept layout updates from browsing instance OR true home
|
||||||
|
const layoutUser = useAuthStore.getState().user;
|
||||||
|
const isLayoutTrueHome = !!layoutUser?.homeInstance && origin !== '' && (() => {
|
||||||
|
try { return new URL(origin).host === layoutUser.homeInstance; } catch { return false; }
|
||||||
|
})();
|
||||||
|
if (!isHome && !isLayoutTrueHome) break;
|
||||||
const { setSpaceLayout } = useSpaceStore.getState();
|
const { setSpaceLayout } = useSpaceStore.getState();
|
||||||
setSpaceLayout(event.layout);
|
setSpaceLayout(event.layout);
|
||||||
useSpaceStore.setState({ folders: event.folders });
|
useSpaceStore.setState({ folders: event.folders });
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface SpaceState {
|
|||||||
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
|
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
|
||||||
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
|
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
|
||||||
categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home)
|
categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home)
|
||||||
|
_layoutFromTrueHome: boolean;
|
||||||
setSpaces: (spaces: TaggedSpace[]) => void;
|
setSpaces: (spaces: TaggedSpace[]) => void;
|
||||||
setCurrentSpace: (spaceId: string | null) => void;
|
setCurrentSpace: (spaceId: string | null) => void;
|
||||||
setChannels: (channels: Channel[]) => void;
|
setChannels: (channels: Channel[]) => void;
|
||||||
@@ -98,6 +99,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelPermissions: new Map(),
|
channelPermissions: new Map(),
|
||||||
channelOriginMap: new Map(),
|
channelOriginMap: new Map(),
|
||||||
categoryOriginMap: new Map(),
|
categoryOriginMap: new Map(),
|
||||||
|
_layoutFromTrueHome: false,
|
||||||
|
|
||||||
setSpaces: (spaces) => set({ spaces }),
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }),
|
setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }),
|
||||||
@@ -425,12 +427,26 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
// Optimistic: apply the layout immediately
|
// Optimistic: apply the layout immediately
|
||||||
set({ spaceLayout: items });
|
set({ spaceLayout: items });
|
||||||
|
|
||||||
|
const homeOrigin = getLayoutHomeOrigin();
|
||||||
|
const homeApi = getApiForOrigin(homeOrigin);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.spaceLayout.update({ items, folders });
|
const result = await homeApi.spaceLayout.update({ items, folders });
|
||||||
// Server may have resolved new:* IDs
|
// Server may have resolved new:* IDs
|
||||||
set({ spaceLayout: result.items, folders: result.folders });
|
set({ spaceLayout: result.items, folders: result.folders });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save space layout:', err);
|
// If true home is remote and unreachable, fall back to browsing instance
|
||||||
|
if (homeOrigin) {
|
||||||
|
console.warn(`Layout save to home (${homeOrigin}) failed, falling back to local:`, err);
|
||||||
|
try {
|
||||||
|
const result = await api.spaceLayout.update({ items, folders });
|
||||||
|
set({ spaceLayout: result.items, folders: result.folders });
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error('Failed to save space layout:', fallbackErr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Failed to save space layout:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -552,8 +568,22 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
categoryOriginMap,
|
categoryOriginMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only set folders and layout from home origin
|
// Determine if this origin is the user's true home (federation-aware)
|
||||||
if (isHome) {
|
const currentUser = useAuthStore.getState().user;
|
||||||
|
const isTrueHome = !!currentUser?.homeInstance && origin !== '' && (() => {
|
||||||
|
try { return new URL(origin).host === currentUser.homeInstance; } catch { return false; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Accept layout from true home (authoritative) or browsing instance (fallback)
|
||||||
|
if (isTrueHome) {
|
||||||
|
// Authoritative: true home always wins
|
||||||
|
update.folders = folders || [];
|
||||||
|
if (spaceLayout !== undefined) {
|
||||||
|
update.spaceLayout = spaceLayout ?? null;
|
||||||
|
}
|
||||||
|
update._layoutFromTrueHome = true;
|
||||||
|
} else if (isHome && !get()._layoutFromTrueHome) {
|
||||||
|
// Fallback: browsing instance's layout, only until true home connects
|
||||||
update.folders = folders || [];
|
update.folders = folders || [];
|
||||||
if (spaceLayout !== undefined) {
|
if (spaceLayout !== undefined) {
|
||||||
update.spaceLayout = spaceLayout ?? null;
|
update.spaceLayout = spaceLayout ?? null;
|
||||||
@@ -676,6 +706,16 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the removed origin was the true home, reset the layout authority flag
|
||||||
|
// so the browsing instance's layout can serve as fallback again
|
||||||
|
const currentUser = useAuthStore.getState().user;
|
||||||
|
let resetLayoutFlag = false;
|
||||||
|
if (currentUser?.homeInstance && origin !== '') {
|
||||||
|
try {
|
||||||
|
resetLayoutFlag = new URL(origin).host === currentUser.homeInstance;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
spaces: remainingSpaces,
|
spaces: remainingSpaces,
|
||||||
channelToSpaceMap,
|
channelToSpaceMap,
|
||||||
@@ -686,6 +726,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId)
|
currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId)
|
||||||
? state.currentSpaceId
|
? state.currentSpaceId
|
||||||
: null,
|
: null,
|
||||||
|
...(resetLayoutFlag ? { _layoutFromTrueHome: false } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -757,6 +798,17 @@ export function resolveUserOrigin(user: { homeInstance?: string | null }): strin
|
|||||||
return _resolveOriginFromHostname?.(host) ?? '';
|
return _resolveOriginFromHostname?.(host) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the origin that is authoritative for this user's space layout.
|
||||||
|
* '' = browsing instance (native users, or true home not yet connected).
|
||||||
|
* 'https://...' = connected remote that is the user's true home.
|
||||||
|
*/
|
||||||
|
export function getLayoutHomeOrigin(): string {
|
||||||
|
const user = useAuthStore.getState().user;
|
||||||
|
if (!user?.homeInstance) return '';
|
||||||
|
return _resolveOriginFromHostname?.(user.homeInstance) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
// ─── User ID resolution (federation) ──────────────────────────────────────────
|
// ─── User ID resolution (federation) ──────────────────────────────────────────
|
||||||
// Same resolver pattern as getApiForOrigin — registered by instanceStore on
|
// Same resolver pattern as getApiForOrigin — registered by instanceStore on
|
||||||
// import to break the circular dependency chain.
|
// import to break the circular dependency chain.
|
||||||
|
|||||||
Reference in New Issue
Block a user