diff --git a/docs/systems/mobile-ui.md b/docs/systems/mobile-ui.md index 4cf50cd7..0c496d8e 100644 --- a/docs/systems/mobile-ui.md +++ b/docs/systems/mobile-ui.md @@ -352,6 +352,8 @@ Split-pane layout: 60px `glass-strip` space strip on the left + channel list on - Voice channel tap opens `MobileVoiceJoinSheet` (not direct join) - Text channel tap: navigates via router + pushes `channel-chat` screen - Channel/category context menus for management (guarded by `MANAGE_CHANNELS` permission) +- **Loading skeleton:** while `useSpaceStore.loadingSpaceId === selectedSpaceId`, the channel-list area renders a shimmer skeleton (uncategorized rows + category header + categorized rows). Gated through `useDelayedLoading` so cached/fast loads don't flash the placeholder. Mirrors desktop `ChannelSidebar`'s `showChannelSkeleton`. Skeleton row geometry matches the real channel rows (`px-3 py-2` with `w-4 h-4` icon → ~36px row). +- **Empty-state mascot — settle gate:** the "No channels yet." mascot must NOT render during the pre-skeleton load window (the < 200 ms threshold of `useDelayedLoading`). Without a settle gate, every space switch flashes the mascot for ~50–200 ms because `state.channels` only ever holds the most-recently loaded space's channels — `spaceChannels` filters to `[]` immediately on selection change while `loadSpaceDetail` is still in flight. The mascot is gated on `isSpaceSettledEmpty = !isLoadingSelectedSpace && loadedSpaceIds.has(selectedSpaceId) && spaceChannels.length === 0`. `loadedSpaceIds` is a `Set` on `useSpaceStore`, populated only on successful `loadSpaceDetail` completion (not on failed loads), and pruned on `deleteSpace` / `leaveSpace` / `removeSpace` / `removeInstanceSpaces` / `reset`. Render order is therefore: skeleton (loading, > threshold) → blank (loading, < threshold) → mascot (settled empty) → real channel list. Desktop `ChannelSidebar` has no empty-state branch, so this asymmetry is mobile-only. **Layout resolution:** - `spaceLayout` array items can be `{ t: 's', id }` (space) or `{ t: 'f', id }` (folder) @@ -429,6 +431,8 @@ Member group resolution (`getMemberGroup`): 2. Has roles: top role by position `{ key: roleId, label: ROLE_NAME, position }` 3. No roles: `{ key: '__online__', label: 'ONLINE', position: -1 }` +**Loading skeleton:** while `useSpaceStore.loadingSpaceId === spaceId` (the same flag that gates `MobileSpacesScreen`'s channel-list skeleton — `loadSpaceDetail` populates members alongside channels), the screen renders a shimmer skeleton (two role-section headers + circular avatar placeholders + name bars). Gated through `useDelayedLoading` so cached/fast loads don't flash the placeholder. Mirrors desktop `MemberSidebar`'s `showMemberSkeleton`. Skeleton row geometry matches the real member rows (`gap-2.5 px-2 py-2.5` with `w-9 h-9` avatar → ~52px row). + ### MobileScreenHeader Reusable header component used by `MobileInstancePanel`, `MobileMembersScreen`, `MobileSettingsScreen`, and inline in screenMap wrappers. @@ -615,6 +619,26 @@ The container subscribes to `useUIStore.isMobile`, `useUIStore.mobileStack`, and --- +## Loading Skeletons (Mobile Inventory) + +Mobile uses the same `.skeleton` / `.skeleton-bar` / `.skeleton-circle` / `.skeleton-block` CSS primitives as desktop (see `docs/systems/design-system.md`) plus the shared `useDelayedLoading` hook (200 ms threshold + 300 ms minimum display time). All skeleton placements are content-plane elements rendered on the matte surface — never glass. + +| Site | Loading source | Status | +|---|---|---| +| App boot (root layout pre-`useAuth.user`) | `useAuth.isLoading` (gated by `useDelayedLoading` in `AppLayout`) | Shared with desktop — `AppLayout` returns the boot skeleton before the mobile/desktop branch split, so both paths show it during cold start | +| `MessageList` initial + pagination | `chatStore` per-channel `isLoading` / `isLoadingMore` | Shared with desktop — `MessageList` is rendered inside both `MainContent` (desktop) and `MobileChatScreen` (mobile) | +| `MobileSpacesScreen` channel list | `useSpaceStore.loadingSpaceId === selectedSpaceId` | Mobile-specific render, mirrors desktop `ChannelSidebar`. Empty-state mascot is settle-gated on `loadedSpaceIds` to avoid flashing during the pre-skeleton load window (see "Empty-state mascot — settle gate" above) | +| `MobileMembersScreen` member list | `useSpaceStore.loadingSpaceId === spaceId` | Mobile-specific render, mirrors desktop `MemberSidebar` | +| `FriendsPage` (mobile) | `socialStore.isLoading && friends.length === 0 && requests.length === 0` | Shared component; renders `LoadingSpinner` (no skeleton) — used unchanged on mobile | +| `ExplorePage` (mobile) | `exploreStore.isLoading && spaces.length === 0` | Shared component; renders `LoadingSpinner` — used unchanged on mobile | +| `GifPicker` (mobile sheet) | per-fetch local state | Shared component; uses `animate-pulse` placeholder tiles — used unchanged on mobile | + +**Sizing rule:** mobile skeleton rows must match the row geometry of the real content for that screen so the populate is smooth and no layout shift occurs. The `MobileSpacesScreen` channel-row skeleton is `px-3 py-2` with a `w-4 h-4` icon (~36 px row); the `MobileMembersScreen` row skeleton is `gap-2.5 px-2 py-2.5` with a `w-9 h-9` avatar (~52 px row). Both use staggered `animationDelay` so the shimmer cascades across rows rather than pulsing in lockstep. + +**Why no shared `` primitive:** the project's established pattern composes the existing CSS classes inline at each site (see `AppLayout.tsx`, `ChannelSidebar.tsx`, `MemberSidebar.tsx`, `MessageList.tsx`). Each site needs row geometry that matches its specific layout, so a parameterized component would either over-abstract (``) or duplicate the inline approach. Adding a JSX wrapper over `
` would also obscure the visual diff between the placeholder and the real row it replaces. + +--- + ## LocalStorage Persistence The uiStore uses `zustand/persist` with `partialize`: diff --git a/docs/systems/spaces.md b/docs/systems/spaces.md index cc869801..6dc22029 100644 --- a/docs/systems/spaces.md +++ b/docs/systems/spaces.md @@ -711,3 +711,16 @@ Remote space icons, banners, and member avatars are resolved via `resolveAssetUr - `loadSpaceDetail` loads a remote space - `joinByCode` returns a remote space - `exploreStore.fetchSpaces` processes remote explore results + +### Client Load State (`useSpaceStore`) + +Two distinct flags track per-space load progress: + +- `loadingSpaceId: string | null` — non-null while a `loadSpaceDetail` call is in flight. Drives the channel-list and member-list skeletons (gated through `useDelayedLoading`). +- `loadedSpaceIds: Set` — populated only on successful `loadSpaceDetail` completion. Used to differentiate "load not yet attempted" from "loaded with empty result." Required by mobile UI to gate the empty-state mascot — without it, the mascot flashes during the pre-skeleton load window because `state.channels` is overwritten on each `loadSpaceDetail` and a fresh space switch leaves `spaceChannels` momentarily filtered to `[]`. + +`loadedSpaceIds` lifecycle: +- Added on `loadSpaceDetail` success (the same `set()` that replaces `channels`/`categories`/`members`). +- Pruned per-space on `deleteSpace`, `leaveSpace`, `removeSpace`, `removeInstanceSpaces`. +- Wiped entirely on `reset` (logout). +- Ephemeral — not persisted. diff --git a/packages/web/src/components/layout/MobileMembersScreen.tsx b/packages/web/src/components/layout/MobileMembersScreen.tsx index e102713c..96d1608f 100644 --- a/packages/web/src/components/layout/MobileMembersScreen.tsx +++ b/packages/web/src/components/layout/MobileMembersScreen.tsx @@ -10,6 +10,7 @@ import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import { MobileScreenHeader } from './MobileScreenHeader'; +import { useDelayedLoading } from '../../hooks/useDelayedLoading'; /** * Derives the display group for a member based on their highest-positioned role @@ -109,6 +110,7 @@ export function MobileMembersScreen({ params }: MobileMembersScreenProps) { const members = useSpaceStore((s) => s.members); const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId); const userActivities = useActivityStore((s) => s.userActivities); const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); @@ -116,6 +118,11 @@ export function MobileMembersScreen({ params }: MobileMembersScreenProps) { const space = spaces.find(s => s.id === spaceId); const ownerId = space?.ownerId; + // Mirror desktop MemberSidebar's `showMemberSkeleton`: gate the skeleton + // behind useDelayedLoading so cached / fast loads don't flash the placeholder. + const isLoadingSpace = !!loadingSpaceId && loadingSpaceId === spaceId; + const showMemberSkeleton = useDelayedLoading(isLoadingSpace); + const { roleGroups, offlineMembers } = useMemo(() => { const online = members.filter(m => m.user.status !== 'offline'); const offline = members.filter(m => m.user.status === 'offline'); @@ -179,7 +186,57 @@ export function MobileMembersScreen({ params }: MobileMembersScreenProps) {
0 ? `Members — ${totalCount}` : 'Members'} />
- {onlineCount === 0 && offlineMembers.length === 0 ? ( + {showMemberSkeleton ? ( +
+ {/* Role group 1 — match real row geometry: w-9 h-9 avatar + + gap-2.5 + py-2.5 → ~52px row height. */} +
+ {Array.from({ length: 2 }, (_, i) => ( +
+
+
+
+
+
+ ))} + {/* Role group 2 */} +
+ {Array.from({ length: 5 }, (_, i) => ( +
+
+
+
+
+
+ ))} +
+ ) : onlineCount === 0 && offlineMembers.length === 0 ? (
No members found
diff --git a/packages/web/src/components/layout/MobileSpacesScreen.tsx b/packages/web/src/components/layout/MobileSpacesScreen.tsx index f243f8bb..3be63882 100644 --- a/packages/web/src/components/layout/MobileSpacesScreen.tsx +++ b/packages/web/src/components/layout/MobileSpacesScreen.tsx @@ -19,6 +19,7 @@ import { VoiceUserRow } from '../voice/VoiceUserRow'; import { MobileVoiceJoinSheet } from '../voice/MobileVoiceJoinSheet'; import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems'; import { joinVoiceChannel } from '../../utils/voice'; +import { useDelayedLoading } from '../../hooks/useDelayedLoading'; type ResolvedItem = | { type: 'space'; space: TaggedSpace } @@ -39,6 +40,8 @@ export function MobileSpacesScreen() { const folders = useSpaceStore((s) => s.folders); const spaceLayout = useSpaceStore((s) => s.spaceLayout); const updateSpaceLayout = useSpaceStore((s) => s.updateSpaceLayout); + const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId); + const loadedSpaceIds = useSpaceStore((s) => s.loadedSpaceIds); const unreadChannels = useChatStore((s) => s.unreadChannels); const currentChannelId = useChatStore((s) => s.currentChannelId); @@ -508,6 +511,37 @@ export function MobileSpacesScreen() { const canInvite = hasPermissionBit(myPerms, PermissionBits.CREATE_INVITE); const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE); + // Show skeleton while the selected space's channels/members are being fetched. + // Mirrors desktop ChannelSidebar's `showChannelSkeleton` (gated by + // useDelayedLoading to avoid flicker on cached/fast loads). + const isLoadingSelectedSpace = !!loadingSpaceId && loadingSpaceId === selectedSpaceId; + const showChannelSkeleton = useDelayedLoading(isLoadingSelectedSpace); + // Render-branch gating for the channel-list area: + // + // Render order is: skeleton (loading) → mascot (loaded but empty) → real list. + // + // The mascot empty state must be gated on a SETTLED state: a load has + // completed at least once for this space AND no load is currently in flight. + // Without the settled gate, the mascot flashed for the pre-skeleton window + // (< 200 ms threshold of `useDelayedLoading`) on every space switch — for + // any space whose channels weren't already in `state.channels`, which is + // every fresh space switch since `state.channels` only ever holds the most + // recently loaded space's channels (`loadSpaceDetail` replaces them). + // + // Desktop `ChannelSidebar` has no empty-state branch, so this asymmetry + // never affected it; mobile's "No channels yet." mascot is the differential + // surface that needed the settle gate. + // + // `loadedSpaceIds` is added by `loadSpaceDetail` on success only, so a + // failed load won't falsely trigger the empty state. When the gate is + // false (loading or never loaded), we render nothing in the gap between + // pre-skeleton and skeleton — preferable to flashing a misleading mascot. + const isSpaceSettledEmpty = + !isLoadingSelectedSpace && + !!selectedSpaceId && + loadedSpaceIds.has(selectedSpaceId) && + spaceChannels.length === 0; + const handleVoiceUserContextMenu = useCallback( (e: React.MouseEvent, userId: string, channelId: string) => { if (userId === user?.id) return; @@ -783,44 +817,94 @@ export function MobileSpacesScreen() { {/* Channel list */}
- {/* Uncategorized channels */} - {uncategorizedChannels.map(renderChannelItem)} - - {/* Categorized channels */} - {sortedCategories.map(category => { - const catChannels = channelsByCategory.get(category.id) || []; - if (catChannels.length === 0) return null; - const isCollapsed = collapsedCategories.has(category.id); - - return ( -
- - {!isCollapsed && catChannels.map(renderChannelItem)} +
+
+
+ ))}
- ); - })} - - {/* Empty state */} - {spaceChannels.length === 0 && selectedSpaceId && ( -
- -

No channels yet.

+ ) : ( + <> + {/* Uncategorized channels */} + {uncategorizedChannels.map(renderChannelItem)} + + {/* Categorized channels */} + {sortedCategories.map(category => { + const catChannels = channelsByCategory.get(category.id) || []; + if (catChannels.length === 0) return null; + const isCollapsed = collapsedCategories.has(category.id); + + return ( +
+ + {!isCollapsed && catChannels.map(renderChannelItem)} +
+ ); + })} + + {/* Empty state — gated on settled (not loading) so the mascot + never flashes during the pre-skeleton load window. */} + {isSpaceSettledEmpty && ( +
+ +

No channels yet.

+
+ )} + )}
diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 131d4c96..55150c79 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -99,6 +99,23 @@ interface SpaceState { */ userViews: Map; 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; _layoutUpdatedAt: number; setSpaces: (spaces: TaggedSpace[]) => void; setCurrentSpace: (spaceId: string | null) => void; @@ -203,6 +220,7 @@ export const useSpaceStore = create((set, get) => ({ dmAlternatives: new Map(), userViews: new Map(), loadingSpaceId: null, + loadedSpaceIds: new Set(), _layoutUpdatedAt: 0, reset: () => { @@ -228,6 +246,7 @@ export const useSpaceStore = create((set, get) => ({ dmAlternatives: new Map(), userViews: new Map(), loadingSpaceId: null, + loadedSpaceIds: new Set(), _layoutUpdatedAt: 0, }); }, @@ -370,16 +389,21 @@ export const useSpaceStore = create((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((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((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((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((set, get) => ({ channelOriginMap, channelLastMessageIds, spacePermissions, + loadedSpaceIds, }; }); @@ -1039,6 +1077,15 @@ export const useSpaceStore = create((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(); + 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((set, get) => ({ lastSelectedSpaceId: remainingSpaces.find(s => s.id === state.lastSelectedSpaceId) ? state.lastSelectedSpaceId : null, + loadedSpaceIds, }; });