diff --git a/docs/systems/mobile-ui.md b/docs/systems/mobile-ui.md index 5c7c7801..3a99e69f 100644 --- a/docs/systems/mobile-ui.md +++ b/docs/systems/mobile-ui.md @@ -238,12 +238,23 @@ Badge caps at `99+` for numeric badges. | Tab | Navigation | |-----|------------| -| Spaces | Navigates to last known space route, or `/` | +| Spaces | Navigates to `/channels/` if either is set, otherwise `/` (which redirects to `/channels/@me`) | | DMs | Navigates to `/channels/@me` | | You | No navigation (stays on current route) | All tabs call `setMobileTab(tab)` which clears the mobile stack. +The Spaces tab prefers `useSpaceStore.getState().currentSpaceId` — the canonical "currently selected space" — and falls back to `lastSelectedSpaceId`, a sticky memory in `useSpaceStore` that survives `@me` navigation. `currentSpaceId` is the canonical answer when available (URL routing, the space strip, SpaceInviteCard joins all set it), but `AppLayout`'s URL effect clears `currentSpaceId` to null whenever the URL is `/channels/@me`. Without the sticky fallback, returning to Spaces after a DMs/Friends/Settings detour would have nothing to anchor to and `MobileSpacesScreen`'s auto-select would fall back to `spaces[0]`. The previous `Object.keys(lastChannelPerSpace)[0]` approach was wrong for a different reason (it returned the first-inserted key, locking the tab to whichever space the user opened first in their session); `lastChannelPerSpace` is still used by `MobileSpacesScreen.setLastChannel` for the per-space last-channel-jump-on-channel-tap feature — that remains a separate concern. + +`MobileSpacesScreen` mirrors the same fallback at mount: `useState(currentSpaceId ?? lastSelectedSpaceId)`. After a DM detour the screen remounts with `currentSpaceId === null` (cleared by AppLayout) but the sticky memory still resolves to the previously-selected space. The local `setCurrentSpace(selectedSpaceId)` effect then restores `currentSpaceId` to that value, so the rest of the app sees a consistent selection. + +`lastSelectedSpaceId` lifecycle (defined in `useSpaceStore`): + +- Updated on every `setCurrentSpace(non-null)` call and on `loadSpaceDetail` success. +- NOT cleared by `setCurrentSpace(null)` — that's the whole point. +- Cleared to null only when the remembered space is actually removed: `deleteSpace`, `leaveSpace`, `removeSpace` (kicked / WS event), `removeInstanceSpaces` (instance disconnect/removal), `reset` (logout). +- Ephemeral — not persisted to localStorage. On page reload the URL drives initial state; the sticky memory only matters within a session, between tab cycles. + ### Styling - Container: `glass-bubble` surface tier @@ -412,7 +423,7 @@ Member group resolution (`getMemberGroup`): ### MobileScreenHeader -Reusable header component used by `MobileInstancePanel`, `MobileMembersScreen`, and inline in screenMap wrappers. +Reusable header component used by `MobileInstancePanel`, `MobileMembersScreen`, `MobileSettingsScreen`, and inline in screenMap wrappers. ```ts interface MobileScreenHeaderProps { @@ -426,6 +437,10 @@ interface MobileScreenHeaderProps { - Bottom border: `border-border-soft` - Background: `bg-surface-base` +**Canonical pattern — TransferIndicator in `rightActions`:** every settings/instance screen mounts `` via the `rightActions` slot so an in-flight profile/banner upload (or any transfer initiated before navigating into settings) remains visible and controllable from the screen the user is currently on. Wired in: `MobileSettingsScreen` (hub + each direct panel mode), `MobileInstancePanel`, all six `settings-instance-*` wrappers in `MobileShell.tsx` (general / registration / federation / streaming / storage / users). The indicator is idle-cheap — a single Map subscription + small icon button when no transfers are active — so mounting it on every settings screen has no measurable performance cost. See `docs/systems/uploads.md` for the transfer-chrome surface inventory. + +`MobileChatScreen` uses its own inline header (not `MobileScreenHeader`) and mounts `TransferIndicator` directly. The dropdown panel renders below the trigger via `absolute right-0 top-full mt-2` and is width-capped at `min(300px, calc(100vw - 16px))` to avoid clipping on narrow viewports. Click-outside dismissal listens to both `mousedown` and `touchstart` so iOS Safari closes the tray on a single tap. + --- ## Voice Overlay @@ -571,4 +586,4 @@ partialize: (state) => ({ Only `memberListOpen` and `lastChannelPerSpace` persist. Mobile navigation state (`mobileScreen`, `mobileStack`) is ephemeral and resets on page reload. -`lastChannelPerSpace` is used by `MobileBottomNav` to navigate to the last-viewed channel when the Spaces tab is tapped, and by `MobileSpacesScreen` when a text channel is opened (via `setLastChannel`). +`lastChannelPerSpace` is used by `MobileSpacesScreen` to remember the most-recent text channel for each space (via `setLastChannel`), and by `AppLayout`'s desktop auto-select effect to land on that channel when the user opens a space without a channelId. It is NOT what the mobile Spaces bottom-nav tap reads — that reads `useSpaceStore`'s `currentSpaceId ?? lastSelectedSpaceId` (see "Tab Tap Behavior" above). `lastSelectedSpaceId` itself lives in `useSpaceStore` and is intentionally NOT persisted — only the in-session sticky-memory semantic matters. diff --git a/packages/web/src/components/layout/MobileBottomNav.tsx b/packages/web/src/components/layout/MobileBottomNav.tsx index 29e1c1e6..9cc5d74f 100644 --- a/packages/web/src/components/layout/MobileBottomNav.tsx +++ b/packages/web/src/components/layout/MobileBottomNav.tsx @@ -10,7 +10,6 @@ export function MobileBottomNav() { const mobileScreen = useUIStore((s) => s.mobileScreen); const mobileStack = useUIStore((s) => s.mobileStack); const setMobileTab = useUIStore((s) => s.setMobileTab); - const lastChannelPerSpace = useUIStore((s) => s.lastChannelPerSpace); const navigate = useNavigate(); // Badge data @@ -41,8 +40,17 @@ export function MobileBottomNav() { setMobileTab(tab); if (tab === 'dms') navigate('/channels/@me'); if (tab === 'spaces') { - const lastSpace = Object.keys(lastChannelPerSpace)[0]; - if (lastSpace) navigate(`/channels/${lastSpace}`); + // Prefer `currentSpaceId` (the canonical "currently selected space" — + // updated by URL routing, the space strip, SpaceInviteCard joins, etc.). + // Fall back to `lastSelectedSpaceId`, the sticky memory that survives + // navigating to `/channels/@me`. AppLayout's URL effect clears + // `currentSpaceId` to null whenever the URL is `@me`; without the + // sticky memory, returning to Spaces from a DM/Friends/Settings detour + // would have nothing to anchor to and `MobileSpacesScreen`'s auto-select + // would fall back to `spaces[0]`. + const { currentSpaceId, lastSelectedSpaceId } = useSpaceStore.getState(); + const target = currentSpaceId ?? lastSelectedSpaceId; + if (target) navigate(`/channels/${target}`); else navigate('/'); } }; diff --git a/packages/web/src/components/layout/MobileSpacesScreen.tsx b/packages/web/src/components/layout/MobileSpacesScreen.tsx index a61b55e2..556a5c59 100644 --- a/packages/web/src/components/layout/MobileSpacesScreen.tsx +++ b/packages/web/src/components/layout/MobileSpacesScreen.tsx @@ -29,6 +29,7 @@ export function MobileSpacesScreen() { const channels = useSpaceStore((s) => s.channels); const categories = useSpaceStore((s) => s.categories); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + const lastSelectedSpaceId = useSpaceStore((s) => s.lastSelectedSpaceId); const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail); const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace); const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap); @@ -65,7 +66,14 @@ export function MobileSpacesScreen() { const user = useAuthStore((s) => s.user); // Local state - const [selectedSpaceId, setSelectedSpaceId] = useState(currentSpaceId); + // Initial value: prefer `currentSpaceId` if set (the user is currently in a + // space's URL), otherwise fall back to the sticky `lastSelectedSpaceId`. + // This handles the remount-after-DM-detour case: AppLayout's URL effect has + // cleared `currentSpaceId` to null on `/channels/@me`, but the user's + // intent — the most-recently-selected space — is still recorded. + const [selectedSpaceId, setSelectedSpaceId] = useState( + currentSpaceId ?? lastSelectedSpaceId, + ); const [collapsedCategories, setCollapsedCategories] = useState>(new Set()); const [showAddSheet, setShowAddSheet] = useState(false); const [leaveConfirmSpaceId, setLeaveConfirmSpaceId] = useState(null); @@ -87,11 +95,16 @@ export function MobileSpacesScreen() { // space (e.g. SpaceInviteCard's join handler seeding currentSpaceId before // routing to the Spaces tab). Without this, useState(currentSpaceId) is only // captured on mount and the strip stays on the previously-selected space. + // + // selectedSpaceId is intentionally NOT in the dep array: including it caused + // a render loop because handleSpaceSelect briefly leaves selectedSpaceId !== + // currentSpaceId until the existing effect below propagates the local pick + // to the store. Functional setState makes the update idempotent — if the + // store already matches, React bails and skips the re-render. useEffect(() => { - if (currentSpaceId && currentSpaceId !== selectedSpaceId) { - setSelectedSpaceId(currentSpaceId); - } - }, [currentSpaceId, selectedSpaceId]); + if (!currentSpaceId) return; + setSelectedSpaceId((prev) => (currentSpaceId !== prev ? currentSpaceId : prev)); + }, [currentSpaceId]); // Auto-select first space if none selected useEffect(() => { diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 2b6df0e2..131d4c96 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -59,6 +59,19 @@ export interface UserViewEntry { interface SpaceState { spaces: TaggedSpace[]; currentSpaceId: string | null; + /** + * Sticky memory of the most-recently-selected space. Updated on every + * `setCurrentSpace(non-null)` and when `loadSpaceDetail` lands. Crucially, it + * is NOT cleared by `setCurrentSpace(null)` (the @me / DMs navigation case) + * so mobile callers can answer "which space should the Spaces tab return to + * after a side trip through DMs?" — `currentSpaceId` is wiped on @me by + * AppLayout's URL effect, which would otherwise force a fallback to + * `spaces[0]`. Cleared only when the remembered space is actually removed + * (deleteSpace / leaveSpace / removeSpaceFromState / removeInstanceSpaces / + * reset). Ephemeral — not persisted, since URL drives initial state on + * reload. + */ + lastSelectedSpaceId: string | null; channels: Channel[]; categories: ChannelCategory[]; members: MemberWithUser[]; @@ -172,6 +185,7 @@ async function pushLayoutToOrigin( export const useSpaceStore = create((set, get) => ({ spaces: [], currentSpaceId: null, + lastSelectedSpaceId: null, channels: [], categories: [], members: [], @@ -196,6 +210,7 @@ export const useSpaceStore = create((set, get) => ({ set({ spaces: [], currentSpaceId: null, + lastSelectedSpaceId: null, channels: [], categories: [], members: [], @@ -218,7 +233,14 @@ export const useSpaceStore = create((set, get) => ({ }, setSpaces: (spaces) => set({ spaces }), - setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }), + setCurrentSpace: (spaceId) => + set((state) => ({ + currentSpaceId: spaceId, + // Only update sticky memory when actually selecting a space. Clearing + // currentSpaceId (e.g. on @me navigation) must NOT wipe the memory — + // that's the whole point of this slot. + lastSelectedSpaceId: spaceId !== null ? spaceId : state.lastSelectedSpaceId, + })), setChannels: (channels) => set({ channels }), setCategories: (categories) => set({ categories }), setMembers: (members) => set({ members }), @@ -351,6 +373,7 @@ 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, @@ -392,6 +415,8 @@ export const useSpaceStore = create((set, get) => ({ set((state) => ({ spaces: state.spaces.filter(s => s.id !== spaceId), currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId, + lastSelectedSpaceId: + state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId, })); }, @@ -405,6 +430,8 @@ export const useSpaceStore = create((set, get) => ({ set((state) => ({ spaces: state.spaces.filter(s => s.id !== spaceId), currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId, + lastSelectedSpaceId: + state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId, })); }, @@ -553,6 +580,8 @@ export const useSpaceStore = create((set, get) => ({ return { spaces: state.spaces.filter(s => s.id !== spaceId), currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId, + lastSelectedSpaceId: + state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId, channelToSpaceMap, channelPermissions, channelOriginMap, @@ -1022,6 +1051,9 @@ export const useSpaceStore = create((set, get) => ({ currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId) ? state.currentSpaceId : null, + lastSelectedSpaceId: remainingSpaces.find(s => s.id === state.lastSelectedSpaceId) + ? state.lastSelectedSpaceId + : null, }; });