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:
@@ -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)
|
- Voice channel tap opens `MobileVoiceJoinSheet` (not direct join)
|
||||||
- Text channel tap: navigates via router + pushes `channel-chat` screen
|
- Text channel tap: navigates via router + pushes `channel-chat` screen
|
||||||
- Channel/category context menus for management (guarded by `MANAGE_CHANNELS` permission)
|
- 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<string>` 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:**
|
**Layout resolution:**
|
||||||
- `spaceLayout` array items can be `{ t: 's', id }` (space) or `{ t: 'f', id }` (folder)
|
- `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 }`
|
2. Has roles: top role by position `{ key: roleId, label: ROLE_NAME, position }`
|
||||||
3. No roles: `{ key: '__online__', label: 'ONLINE', position: -1 }`
|
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
|
### MobileScreenHeader
|
||||||
|
|
||||||
Reusable header component used by `MobileInstancePanel`, `MobileMembersScreen`, `MobileSettingsScreen`, and inline in screenMap wrappers.
|
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 `<Skeleton />` 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 (`<Row count={N} avatarSize={S} ...>`) or duplicate the inline approach. Adding a JSX wrapper over `<div className="skeleton">` would also obscure the visual diff between the placeholder and the real row it replaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## LocalStorage Persistence
|
## LocalStorage Persistence
|
||||||
|
|
||||||
The uiStore uses `zustand/persist` with `partialize`:
|
The uiStore uses `zustand/persist` with `partialize`:
|
||||||
|
|||||||
@@ -711,3 +711,16 @@ Remote space icons, banners, and member avatars are resolved via `resolveAssetUr
|
|||||||
- `loadSpaceDetail` loads a remote space
|
- `loadSpaceDetail` loads a remote space
|
||||||
- `joinByCode` returns a remote space
|
- `joinByCode` returns a remote space
|
||||||
- `exploreStore.fetchSpaces` processes remote explore results
|
- `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<string>` — 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.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
|||||||
import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
|
import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
|
||||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||||
import { MobileScreenHeader } from './MobileScreenHeader';
|
import { MobileScreenHeader } from './MobileScreenHeader';
|
||||||
|
import { useDelayedLoading } from '../../hooks/useDelayedLoading';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives the display group for a member based on their highest-positioned role
|
* 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 members = useSpaceStore((s) => s.members);
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
||||||
const userActivities = useActivityStore((s) => s.userActivities);
|
const userActivities = useActivityStore((s) => s.userActivities);
|
||||||
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||||
|
|
||||||
@@ -116,6 +118,11 @@ export function MobileMembersScreen({ params }: MobileMembersScreenProps) {
|
|||||||
const space = spaces.find(s => s.id === spaceId);
|
const space = spaces.find(s => s.id === spaceId);
|
||||||
const ownerId = space?.ownerId;
|
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 { roleGroups, offlineMembers } = useMemo(() => {
|
||||||
const online = members.filter(m => m.user.status !== 'offline');
|
const online = members.filter(m => m.user.status !== 'offline');
|
||||||
const offline = 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) {
|
|||||||
<div className="flex flex-col h-full bg-surface-base">
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
<MobileScreenHeader title={totalCount > 0 ? `Members — ${totalCount}` : 'Members'} />
|
<MobileScreenHeader title={totalCount > 0 ? `Members — ${totalCount}` : 'Members'} />
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
{onlineCount === 0 && offlineMembers.length === 0 ? (
|
{showMemberSkeleton ? (
|
||||||
|
<div className="px-2 pt-2" role="status" aria-label="Loading members">
|
||||||
|
{/* Role group 1 — match real row geometry: w-9 h-9 avatar +
|
||||||
|
gap-2.5 + py-2.5 → ~52px row height. */}
|
||||||
|
<div
|
||||||
|
className="skeleton skeleton-bar h-2 w-[35%] mb-2"
|
||||||
|
style={{ animationDelay: '0s' }}
|
||||||
|
/>
|
||||||
|
{Array.from({ length: 2 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={`g1-${i}`}
|
||||||
|
className="flex items-center gap-2.5 px-2 py-2.5 mb-1"
|
||||||
|
style={{ animationDelay: `${i * 0.12}s` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="skeleton skeleton-circle w-9 h-9 flex-shrink-0"
|
||||||
|
style={{ animationDelay: `${i * 0.12}s` }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div
|
||||||
|
className="skeleton skeleton-bar"
|
||||||
|
style={{ width: `${50 + (i * 19) % 30}%`, animationDelay: `${i * 0.12}s` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Role group 2 */}
|
||||||
|
<div
|
||||||
|
className="skeleton skeleton-bar h-2 w-[45%] mb-2 mt-4"
|
||||||
|
style={{ animationDelay: '0.25s' }}
|
||||||
|
/>
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={`g2-${i}`}
|
||||||
|
className="flex items-center gap-2.5 px-2 py-2.5 mb-1"
|
||||||
|
style={{ animationDelay: `${(i + 2) * 0.12}s` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="skeleton skeleton-circle w-9 h-9 flex-shrink-0"
|
||||||
|
style={{ animationDelay: `${(i + 2) * 0.12}s` }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div
|
||||||
|
className="skeleton skeleton-bar"
|
||||||
|
style={{ width: `${42 + (i * 15) % 35}%`, animationDelay: `${(i + 2) * 0.12}s` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : onlineCount === 0 && offlineMembers.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-40 text-txt-tertiary text-sm">
|
<div className="flex items-center justify-center h-40 text-txt-tertiary text-sm">
|
||||||
No members found
|
No members found
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { VoiceUserRow } from '../voice/VoiceUserRow';
|
|||||||
import { MobileVoiceJoinSheet } from '../voice/MobileVoiceJoinSheet';
|
import { MobileVoiceJoinSheet } from '../voice/MobileVoiceJoinSheet';
|
||||||
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
|
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
|
||||||
import { joinVoiceChannel } from '../../utils/voice';
|
import { joinVoiceChannel } from '../../utils/voice';
|
||||||
|
import { useDelayedLoading } from '../../hooks/useDelayedLoading';
|
||||||
|
|
||||||
type ResolvedItem =
|
type ResolvedItem =
|
||||||
| { type: 'space'; space: TaggedSpace }
|
| { type: 'space'; space: TaggedSpace }
|
||||||
@@ -39,6 +40,8 @@ export function MobileSpacesScreen() {
|
|||||||
const folders = useSpaceStore((s) => s.folders);
|
const folders = useSpaceStore((s) => s.folders);
|
||||||
const spaceLayout = useSpaceStore((s) => s.spaceLayout);
|
const spaceLayout = useSpaceStore((s) => s.spaceLayout);
|
||||||
const updateSpaceLayout = useSpaceStore((s) => s.updateSpaceLayout);
|
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 unreadChannels = useChatStore((s) => s.unreadChannels);
|
||||||
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
||||||
@@ -508,6 +511,37 @@ export function MobileSpacesScreen() {
|
|||||||
const canInvite = hasPermissionBit(myPerms, PermissionBits.CREATE_INVITE);
|
const canInvite = hasPermissionBit(myPerms, PermissionBits.CREATE_INVITE);
|
||||||
const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE);
|
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(
|
const handleVoiceUserContextMenu = useCallback(
|
||||||
(e: React.MouseEvent, userId: string, channelId: string) => {
|
(e: React.MouseEvent, userId: string, channelId: string) => {
|
||||||
if (userId === user?.id) return;
|
if (userId === user?.id) return;
|
||||||
@@ -783,6 +817,53 @@ export function MobileSpacesScreen() {
|
|||||||
|
|
||||||
{/* Channel list */}
|
{/* Channel list */}
|
||||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
|
{showChannelSkeleton ? (
|
||||||
|
<div className="px-1 pt-1" role="status" aria-label="Loading channels">
|
||||||
|
{/* Uncategorized group — 3 channel rows. Sized to match the real
|
||||||
|
channel rows (`px-3 py-2 rounded-lg` with `text-sm`/`w-4 h-4`
|
||||||
|
icon → ~36px height per row). */}
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={`u-${i}`}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 mb-1"
|
||||||
|
style={{ animationDelay: `${i * 0.1}s` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="skeleton w-4 h-4 rounded-sm flex-shrink-0"
|
||||||
|
style={{ animationDelay: `${i * 0.1}s` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="skeleton skeleton-bar flex-1"
|
||||||
|
style={{ width: `${50 + (i * 17) % 30}%`, animationDelay: `${i * 0.1}s` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Category group */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div
|
||||||
|
className="skeleton skeleton-bar h-2 w-[42%] ml-1 mb-2"
|
||||||
|
style={{ animationDelay: '0.3s' }}
|
||||||
|
/>
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={`c-${i}`}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 mb-1"
|
||||||
|
style={{ animationDelay: `${(i + 3) * 0.1}s` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="skeleton w-4 h-4 rounded-sm flex-shrink-0"
|
||||||
|
style={{ animationDelay: `${(i + 3) * 0.1}s` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="skeleton skeleton-bar flex-1"
|
||||||
|
style={{ width: `${45 + (i * 13) % 35}%`, animationDelay: `${(i + 3) * 0.1}s` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Uncategorized channels */}
|
{/* Uncategorized channels */}
|
||||||
{uncategorizedChannels.map(renderChannelItem)}
|
{uncategorizedChannels.map(renderChannelItem)}
|
||||||
|
|
||||||
@@ -815,13 +896,16 @@ export function MobileSpacesScreen() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state — gated on settled (not loading) so the mascot
|
||||||
{spaceChannels.length === 0 && selectedSpaceId && (
|
never flashes during the pre-skeleton load window. */}
|
||||||
|
{isSpaceSettledEmpty && (
|
||||||
<div className="flex flex-col items-center justify-center h-32 opacity-80">
|
<div className="flex flex-col items-center justify-center h-32 opacity-80">
|
||||||
<Mascot state="idle" className="w-20 h-20 mb-2" />
|
<Mascot state="idle" className="w-20 h-20 mb-2" />
|
||||||
<p className="text-txt-tertiary text-sm">No channels yet.</p>
|
<p className="text-txt-tertiary text-sm">No channels yet.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,23 @@ interface SpaceState {
|
|||||||
*/
|
*/
|
||||||
userViews: Map<string, UserViewEntry>;
|
userViews: Map<string, UserViewEntry>;
|
||||||
loadingSpaceId: string | null; // non-null while loadSpaceDetail is fetching
|
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;
|
_layoutUpdatedAt: number;
|
||||||
setSpaces: (spaces: TaggedSpace[]) => void;
|
setSpaces: (spaces: TaggedSpace[]) => void;
|
||||||
setCurrentSpace: (spaceId: string | null) => void;
|
setCurrentSpace: (spaceId: string | null) => void;
|
||||||
@@ -203,6 +220,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
dmAlternatives: new Map(),
|
dmAlternatives: new Map(),
|
||||||
userViews: new Map(),
|
userViews: new Map(),
|
||||||
loadingSpaceId: null,
|
loadingSpaceId: null,
|
||||||
|
loadedSpaceIds: new Set(),
|
||||||
_layoutUpdatedAt: 0,
|
_layoutUpdatedAt: 0,
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
@@ -228,6 +246,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
dmAlternatives: new Map(),
|
dmAlternatives: new Map(),
|
||||||
userViews: new Map(),
|
userViews: new Map(),
|
||||||
loadingSpaceId: null,
|
loadingSpaceId: null,
|
||||||
|
loadedSpaceIds: new Set(),
|
||||||
_layoutUpdatedAt: 0,
|
_layoutUpdatedAt: 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -370,7 +389,10 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set({
|
set((state) => {
|
||||||
|
const loadedSpaceIds = new Set(state.loadedSpaceIds);
|
||||||
|
loadedSpaceIds.add(spaceId);
|
||||||
|
return {
|
||||||
loadingSpaceId: null,
|
loadingSpaceId: null,
|
||||||
currentSpaceId: spaceId,
|
currentSpaceId: spaceId,
|
||||||
lastSelectedSpaceId: spaceId,
|
lastSelectedSpaceId: spaceId,
|
||||||
@@ -380,6 +402,8 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
roles: detail.roles.sort((a, b) => b.position - a.position),
|
roles: detail.roles.sort((a, b) => b.position - a.position),
|
||||||
spacePermissions,
|
spacePermissions,
|
||||||
channelPermissions,
|
channelPermissions,
|
||||||
|
loadedSpaceIds,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
set({ loadingSpaceId: null });
|
set({ loadingSpaceId: null });
|
||||||
@@ -412,12 +436,17 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
|
|
||||||
deleteSpace: async (spaceId: string) => {
|
deleteSpace: async (spaceId: string) => {
|
||||||
await api.spaces.delete(spaceId);
|
await api.spaces.delete(spaceId);
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const loadedSpaceIds = new Set(state.loadedSpaceIds);
|
||||||
|
loadedSpaceIds.delete(spaceId);
|
||||||
|
return {
|
||||||
spaces: state.spaces.filter(s => s.id !== spaceId),
|
spaces: state.spaces.filter(s => s.id !== spaceId),
|
||||||
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
|
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
|
||||||
lastSelectedSpaceId:
|
lastSelectedSpaceId:
|
||||||
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
|
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
|
||||||
}));
|
loadedSpaceIds,
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
leaveSpace: async (spaceId: string) => {
|
leaveSpace: async (spaceId: string) => {
|
||||||
@@ -427,12 +456,17 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
const userId = getMyUserIdForOrigin(origin);
|
const userId = getMyUserIdForOrigin(origin);
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
await targetApi.spaces.removeMember(spaceId, userId);
|
await targetApi.spaces.removeMember(spaceId, userId);
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const loadedSpaceIds = new Set(state.loadedSpaceIds);
|
||||||
|
loadedSpaceIds.delete(spaceId);
|
||||||
|
return {
|
||||||
spaces: state.spaces.filter(s => s.id !== spaceId),
|
spaces: state.spaces.filter(s => s.id !== spaceId),
|
||||||
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
|
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
|
||||||
lastSelectedSpaceId:
|
lastSelectedSpaceId:
|
||||||
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
|
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
|
||||||
}));
|
loadedSpaceIds,
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
joinSpace: async (spaceId: string, inviteCode: string) => {
|
joinSpace: async (spaceId: string, inviteCode: string) => {
|
||||||
@@ -577,6 +611,9 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
spacePermissions.delete(spaceId);
|
spacePermissions.delete(spaceId);
|
||||||
|
|
||||||
|
const loadedSpaceIds = new Set(state.loadedSpaceIds);
|
||||||
|
loadedSpaceIds.delete(spaceId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
spaces: state.spaces.filter(s => s.id !== spaceId),
|
spaces: state.spaces.filter(s => s.id !== spaceId),
|
||||||
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
|
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
|
||||||
@@ -587,6 +624,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelOriginMap,
|
channelOriginMap,
|
||||||
channelLastMessageIds,
|
channelLastMessageIds,
|
||||||
spacePermissions,
|
spacePermissions,
|
||||||
|
loadedSpaceIds,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1039,6 +1077,15 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
if (entry.deliveredBy !== origin) userViews.set(key, entry);
|
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 {
|
return {
|
||||||
spaces: remainingSpaces,
|
spaces: remainingSpaces,
|
||||||
channelToSpaceMap,
|
channelToSpaceMap,
|
||||||
@@ -1054,6 +1101,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
lastSelectedSpaceId: remainingSpaces.find(s => s.id === state.lastSelectedSpaceId)
|
lastSelectedSpaceId: remainingSpaces.find(s => s.id === state.lastSelectedSpaceId)
|
||||||
? state.lastSelectedSpaceId
|
? state.lastSelectedSpaceId
|
||||||
: null,
|
: null,
|
||||||
|
loadedSpaceIds,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user