feat(mobile): loading skeleton parity + mascot-flash settle gate

Brings mobile to skeleton parity with desktop. Two mobile-specific renders, plus a fix for the empty-state mascot flashing on every space switch.

- MobileSpacesScreen: shimmer skeleton for the channel list while `useSpaceStore.loadingSpaceId === selectedSpaceId` (uncategorized rows + category header + categorized rows). Mirrors desktop ChannelSidebar's `showChannelSkeleton`. Gated through `useDelayedLoading` so cached/fast loads don't flash the placeholder.
- MobileMembersScreen: shimmer skeleton for the member list (two role-section headers + circular avatars + name bars). Mirrors desktop MemberSidebar's `showMemberSkeleton`.
- Empty-state "No channels yet." mascot was firing on EVERY space switch for ~50–200 ms — `state.channels` only ever holds the most-recently loaded space's channels, so `spaceChannels` filters to `[]` immediately on selection change while `loadSpaceDetail` is still in flight. New `loadedSpaceIds: Set<string>` on useSpaceStore, populated only on `loadSpaceDetail` success and pruned on space removal events. The mascot is now gated on `loadedSpaceIds.has(selectedSpaceId)` so it only fires when the space is *settled* with zero channels.

Reuses the existing `.skeleton` / `.skeleton-bar` / `.skeleton-circle` CSS primitives + `useDelayedLoading` hook (no new shared `<Skeleton />` primitive — sites need row-specific geometry, parameterizing would either over-abstract or duplicate the inline approach).

Specs: docs/systems/mobile-ui.md (Loading Skeletons inventory + per-screen skeleton bullets + settle-gate rationale); docs/systems/spaces.md (Client Load State subsection covering loadingSpaceId vs loadedSpaceIds).
This commit is contained in:
Jannis Braun
2026-05-08 10:22:59 +02:00
parent 71b383ce7f
commit cdb4b5f41f
5 changed files with 283 additions and 57 deletions
+70 -22
View File
@@ -99,6 +99,23 @@ interface SpaceState {
*/
userViews: Map<string, UserViewEntry>;
loadingSpaceId: string | null; // non-null while loadSpaceDetail is fetching
/**
* Set of spaceIds whose `loadSpaceDetail` has completed at least once this
* session. Distinct from `currentSpaceId` (which moves with selection) and
* from `loadingSpaceId` (which only marks in-flight). Render sites use this
* to differentiate "load not yet attempted" from "loaded with empty result"
* — see `MobileSpacesScreen`'s mascot empty state, which must not appear
* during the pre-skeleton load window.
*
* Lifecycle:
* - Added on successful `loadSpaceDetail` completion.
* - Cleared per-space when the space is removed (`deleteSpace`,
* `leaveSpace`, `removeSpace`, `removeInstanceSpaces`).
* - Wiped entirely on `reset` (logout).
*
* Not persisted (ephemeral).
*/
loadedSpaceIds: Set<string>;
_layoutUpdatedAt: number;
setSpaces: (spaces: TaggedSpace[]) => void;
setCurrentSpace: (spaceId: string | null) => void;
@@ -203,6 +220,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
dmAlternatives: new Map(),
userViews: new Map(),
loadingSpaceId: null,
loadedSpaceIds: new Set(),
_layoutUpdatedAt: 0,
reset: () => {
@@ -228,6 +246,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
dmAlternatives: new Map(),
userViews: new Map(),
loadingSpaceId: null,
loadedSpaceIds: new Set(),
_layoutUpdatedAt: 0,
});
},
@@ -370,16 +389,21 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
}
}
set({
loadingSpaceId: null,
currentSpaceId: spaceId,
lastSelectedSpaceId: spaceId,
channels: detail.channels.sort((a, b) => a.position - b.position),
categories: (detail.categories || []).sort((a, b) => a.position - b.position),
members: detail.members,
roles: detail.roles.sort((a, b) => b.position - a.position),
spacePermissions,
channelPermissions,
set((state) => {
const loadedSpaceIds = new Set(state.loadedSpaceIds);
loadedSpaceIds.add(spaceId);
return {
loadingSpaceId: null,
currentSpaceId: spaceId,
lastSelectedSpaceId: spaceId,
channels: detail.channels.sort((a, b) => a.position - b.position),
categories: (detail.categories || []).sort((a, b) => a.position - b.position),
members: detail.members,
roles: detail.roles.sort((a, b) => b.position - a.position),
spacePermissions,
channelPermissions,
loadedSpaceIds,
};
});
} catch {
set({ loadingSpaceId: null });
@@ -412,12 +436,17 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
deleteSpace: async (spaceId: string) => {
await api.spaces.delete(spaceId);
set((state) => ({
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
lastSelectedSpaceId:
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
}));
set((state) => {
const loadedSpaceIds = new Set(state.loadedSpaceIds);
loadedSpaceIds.delete(spaceId);
return {
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
lastSelectedSpaceId:
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
loadedSpaceIds,
};
});
},
leaveSpace: async (spaceId: string) => {
@@ -427,12 +456,17 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
const userId = getMyUserIdForOrigin(origin);
if (!userId) return;
await targetApi.spaces.removeMember(spaceId, userId);
set((state) => ({
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
lastSelectedSpaceId:
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
}));
set((state) => {
const loadedSpaceIds = new Set(state.loadedSpaceIds);
loadedSpaceIds.delete(spaceId);
return {
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
lastSelectedSpaceId:
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
loadedSpaceIds,
};
});
},
joinSpace: async (spaceId: string, inviteCode: string) => {
@@ -577,6 +611,9 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
}
spacePermissions.delete(spaceId);
const loadedSpaceIds = new Set(state.loadedSpaceIds);
loadedSpaceIds.delete(spaceId);
return {
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
@@ -587,6 +624,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
channelOriginMap,
channelLastMessageIds,
spacePermissions,
loadedSpaceIds,
};
});
@@ -1039,6 +1077,15 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
if (entry.deliveredBy !== origin) userViews.set(key, entry);
}
// Drop loadedSpaceIds entries for spaces removed by this instance teardown
const removedSpaceIds = new Set(
state.spaces.filter(s => s._instanceOrigin === origin).map(s => s.id)
);
const loadedSpaceIds = new Set<string>();
for (const id of state.loadedSpaceIds) {
if (!removedSpaceIds.has(id)) loadedSpaceIds.add(id);
}
return {
spaces: remainingSpaces,
channelToSpaceMap,
@@ -1054,6 +1101,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
lastSelectedSpaceId: remainingSpaces.find(s => s.id === state.lastSelectedSpaceId)
? state.lastSelectedSpaceId
: null,
loadedSpaceIds,
};
});