fix(mobile): Spaces tab remembers last-selected space across DM/Friends/Settings detours
Adds sticky `lastSelectedSpaceId` to useSpaceStore. Updated on every
setCurrentSpace(non-null) and on loadSpaceDetail success; NOT cleared by
setCurrentSpace(null), so the @me URL effect in AppLayout no longer wipes
the memory. Cleared only when the remembered space is actually removed
(deleteSpace / leaveSpace / removeSpace / removeInstanceSpaces / reset).
Ephemeral — URL drives initial state on reload.
MobileBottomNav.handleTab('spaces') and MobileSpacesScreen's initial
useState now resolve via `currentSpaceId ?? lastSelectedSpaceId`. The
prior Object.keys(lastChannelPerSpace)[0] fallback was wrong twice over:
first-inserted ordering locked the tab to whichever space the user
opened first in their session, and any approach reading currentSpaceId
alone breaks after a /channels/@me detour.
Also fixes a pre-existing render loop in MobileSpacesScreen's
currentSpaceId sync effect — `selectedSpaceId` removed from deps,
functional setState makes the update idempotent.
Spec: docs/systems/mobile-ui.md updates Tab Tap Behavior, MobileScreenHeader,
and LocalStorage Persistence sections.
This commit is contained in:
@@ -238,12 +238,23 @@ Badge caps at `99+` for numeric badges.
|
|||||||
|
|
||||||
| Tab | Navigation |
|
| Tab | Navigation |
|
||||||
|-----|------------|
|
|-----|------------|
|
||||||
| Spaces | Navigates to last known space route, or `/` |
|
| Spaces | Navigates to `/channels/<currentSpaceId ?? lastSelectedSpaceId>` if either is set, otherwise `/` (which redirects to `/channels/@me`) |
|
||||||
| DMs | Navigates to `/channels/@me` |
|
| DMs | Navigates to `/channels/@me` |
|
||||||
| You | No navigation (stays on current route) |
|
| You | No navigation (stays on current route) |
|
||||||
|
|
||||||
All tabs call `setMobileTab(tab)` which clears the mobile stack.
|
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
|
### Styling
|
||||||
|
|
||||||
- Container: `glass-bubble` surface tier
|
- Container: `glass-bubble` surface tier
|
||||||
@@ -412,7 +423,7 @@ Member group resolution (`getMemberGroup`):
|
|||||||
|
|
||||||
### MobileScreenHeader
|
### 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
|
```ts
|
||||||
interface MobileScreenHeaderProps {
|
interface MobileScreenHeaderProps {
|
||||||
@@ -426,6 +437,10 @@ interface MobileScreenHeaderProps {
|
|||||||
- Bottom border: `border-border-soft`
|
- Bottom border: `border-border-soft`
|
||||||
- Background: `bg-surface-base`
|
- Background: `bg-surface-base`
|
||||||
|
|
||||||
|
**Canonical pattern — TransferIndicator in `rightActions`:** every settings/instance screen mounts `<TransferIndicator />` 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export function MobileBottomNav() {
|
|||||||
const mobileScreen = useUIStore((s) => s.mobileScreen);
|
const mobileScreen = useUIStore((s) => s.mobileScreen);
|
||||||
const mobileStack = useUIStore((s) => s.mobileStack);
|
const mobileStack = useUIStore((s) => s.mobileStack);
|
||||||
const setMobileTab = useUIStore((s) => s.setMobileTab);
|
const setMobileTab = useUIStore((s) => s.setMobileTab);
|
||||||
const lastChannelPerSpace = useUIStore((s) => s.lastChannelPerSpace);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Badge data
|
// Badge data
|
||||||
@@ -41,8 +40,17 @@ export function MobileBottomNav() {
|
|||||||
setMobileTab(tab);
|
setMobileTab(tab);
|
||||||
if (tab === 'dms') navigate('/channels/@me');
|
if (tab === 'dms') navigate('/channels/@me');
|
||||||
if (tab === 'spaces') {
|
if (tab === 'spaces') {
|
||||||
const lastSpace = Object.keys(lastChannelPerSpace)[0];
|
// Prefer `currentSpaceId` (the canonical "currently selected space" —
|
||||||
if (lastSpace) navigate(`/channels/${lastSpace}`);
|
// 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('/');
|
else navigate('/');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function MobileSpacesScreen() {
|
|||||||
const channels = useSpaceStore((s) => s.channels);
|
const channels = useSpaceStore((s) => s.channels);
|
||||||
const categories = useSpaceStore((s) => s.categories);
|
const categories = useSpaceStore((s) => s.categories);
|
||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const lastSelectedSpaceId = useSpaceStore((s) => s.lastSelectedSpaceId);
|
||||||
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
||||||
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
||||||
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
|
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
|
||||||
@@ -65,7 +66,14 @@ export function MobileSpacesScreen() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(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<string | null>(
|
||||||
|
currentSpaceId ?? lastSelectedSpaceId,
|
||||||
|
);
|
||||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
||||||
const [showAddSheet, setShowAddSheet] = useState(false);
|
const [showAddSheet, setShowAddSheet] = useState(false);
|
||||||
const [leaveConfirmSpaceId, setLeaveConfirmSpaceId] = useState<string | null>(null);
|
const [leaveConfirmSpaceId, setLeaveConfirmSpaceId] = useState<string | null>(null);
|
||||||
@@ -87,11 +95,16 @@ export function MobileSpacesScreen() {
|
|||||||
// space (e.g. SpaceInviteCard's join handler seeding currentSpaceId before
|
// space (e.g. SpaceInviteCard's join handler seeding currentSpaceId before
|
||||||
// routing to the Spaces tab). Without this, useState(currentSpaceId) is only
|
// routing to the Spaces tab). Without this, useState(currentSpaceId) is only
|
||||||
// captured on mount and the strip stays on the previously-selected space.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (currentSpaceId && currentSpaceId !== selectedSpaceId) {
|
if (!currentSpaceId) return;
|
||||||
setSelectedSpaceId(currentSpaceId);
|
setSelectedSpaceId((prev) => (currentSpaceId !== prev ? currentSpaceId : prev));
|
||||||
}
|
}, [currentSpaceId]);
|
||||||
}, [currentSpaceId, selectedSpaceId]);
|
|
||||||
|
|
||||||
// Auto-select first space if none selected
|
// Auto-select first space if none selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ export interface UserViewEntry {
|
|||||||
interface SpaceState {
|
interface SpaceState {
|
||||||
spaces: TaggedSpace[];
|
spaces: TaggedSpace[];
|
||||||
currentSpaceId: string | null;
|
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[];
|
channels: Channel[];
|
||||||
categories: ChannelCategory[];
|
categories: ChannelCategory[];
|
||||||
members: MemberWithUser[];
|
members: MemberWithUser[];
|
||||||
@@ -172,6 +185,7 @@ async function pushLayoutToOrigin(
|
|||||||
export const useSpaceStore = create<SpaceState>((set, get) => ({
|
export const useSpaceStore = create<SpaceState>((set, get) => ({
|
||||||
spaces: [],
|
spaces: [],
|
||||||
currentSpaceId: null,
|
currentSpaceId: null,
|
||||||
|
lastSelectedSpaceId: null,
|
||||||
channels: [],
|
channels: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
members: [],
|
members: [],
|
||||||
@@ -196,6 +210,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
spaces: [],
|
spaces: [],
|
||||||
currentSpaceId: null,
|
currentSpaceId: null,
|
||||||
|
lastSelectedSpaceId: null,
|
||||||
channels: [],
|
channels: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
members: [],
|
members: [],
|
||||||
@@ -218,7 +233,14 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setSpaces: (spaces) => set({ spaces }),
|
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 }),
|
setChannels: (channels) => set({ channels }),
|
||||||
setCategories: (categories) => set({ categories }),
|
setCategories: (categories) => set({ categories }),
|
||||||
setMembers: (members) => set({ members }),
|
setMembers: (members) => set({ members }),
|
||||||
@@ -351,6 +373,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
loadingSpaceId: null,
|
loadingSpaceId: null,
|
||||||
currentSpaceId: spaceId,
|
currentSpaceId: spaceId,
|
||||||
|
lastSelectedSpaceId: spaceId,
|
||||||
channels: detail.channels.sort((a, b) => a.position - b.position),
|
channels: detail.channels.sort((a, b) => a.position - b.position),
|
||||||
categories: (detail.categories || []).sort((a, b) => a.position - b.position),
|
categories: (detail.categories || []).sort((a, b) => a.position - b.position),
|
||||||
members: detail.members,
|
members: detail.members,
|
||||||
@@ -392,6 +415,8 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
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:
|
||||||
|
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -405,6 +430,8 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
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:
|
||||||
|
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -553,6 +580,8 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
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,
|
||||||
|
lastSelectedSpaceId:
|
||||||
|
state.lastSelectedSpaceId === spaceId ? null : state.lastSelectedSpaceId,
|
||||||
channelToSpaceMap,
|
channelToSpaceMap,
|
||||||
channelPermissions,
|
channelPermissions,
|
||||||
channelOriginMap,
|
channelOriginMap,
|
||||||
@@ -1022,6 +1051,9 @@ 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,
|
||||||
|
lastSelectedSpaceId: remainingSpaces.find(s => s.id === state.lastSelectedSpaceId)
|
||||||
|
? state.lastSelectedSpaceId
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user