feat: persist tab/search state across navigation

This commit is contained in:
2026-06-30 01:58:47 +07:00
parent d7af72a505
commit 83754497af
7 changed files with 36 additions and 12 deletions
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Trans } from "react-i18next";
import { useI18n } from "../../lib/i18n";
import { Banner, Loader, PAGE_TITLE, Tabs } from "../../components/ui";
import { useViewState } from "../navigation/NavContext";
import { useAccountSettings } from "./useAccountSettings";
import { DisplayNameSection } from "./sections/DisplayNameSection";
import { EmailSection } from "./sections/EmailSection";
@@ -22,7 +22,7 @@ type AccountTab = "account" | "security" | "privacy" | "data";
export function AccountSettingsView() {
const { t } = useI18n();
const { state, set } = useAccountSettings();
const [tab, setTab] = useState<AccountTab>("account");
const [tab, setTab] = useViewState<AccountTab>("account:tab", "account");
if (state.status === "loading") return <Loader className="absolute inset-0" />;
if (state.status === "error")
@@ -21,6 +21,7 @@ import {
type FavoriteFolder,
} from "../../store/avatars";
import { useSelf, useSocial } from "../../store/social";
import { useViewState } from "../navigation/NavContext";
import { AvatarCard } from "./AvatarCard";
import { FolderEditModal } from "./FolderEditModal";
@@ -41,8 +42,8 @@ function matchAvatar(query: string): AvatarFilter {
export function AvatarsView() {
const t = useT();
const [tab, setTab] = useState<Tab>("uploaded");
const [query, setQuery] = useState("");
const [tab, setTab] = useViewState<Tab>("avatars:tab", "uploaded");
const [query, setQuery] = useViewState("avatars:query", "");
const filter = useMemo(() => matchAvatar(query), [query]);
const selfId = useSocial((s) => s.selfId);
@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, useState } from "react";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
export type View =
| { kind: "user"; id: "me" | string }
@@ -30,12 +30,15 @@ interface Nav {
openGallery: () => void;
openSearch: () => void;
back: () => void;
getViewState: (key: string) => unknown;
setViewState: (key: string, value: unknown) => void;
}
const NavCtx = createContext<Nav | null>(null);
export function NavProvider({ children }: { children: React.ReactNode }) {
const [stack, setStack] = useState<View[]>([{ kind: "user", id: "me" }]);
const viewState = useRef(new Map<string, unknown>());
const push = (view: View) =>
setStack((s) => {
@@ -64,6 +67,8 @@ export function NavProvider({ children }: { children: React.ReactNode }) {
openGallery: () => root({ kind: "gallery" }),
openSearch: () => root({ kind: "search" }),
back: () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s)),
getViewState: (key) => viewState.current.get(key),
setViewState: (key, value) => viewState.current.set(key, value),
}),
[stack],
);
@@ -86,3 +91,19 @@ export function useNav(): Nav {
if (!ctx) throw new Error("useNav must be used within NavProvider");
return ctx;
}
export function useViewState<T>(key: string, initial: T): [T, (value: T) => void] {
const nav = useNav();
const [state, set] = useState<T>(() => {
const saved = nav.getViewState(key);
return saved === undefined ? initial : (saved as T);
});
const setBoth = useCallback(
(value: T) => {
nav.setViewState(key, value);
set(value);
},
[nav, key],
);
return [state, setBoth];
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Banner, Loader, PAGE_TITLE, Tabs } from "../../components/ui";
import { useT } from "../../lib/i18n";
import { useViewState } from "../navigation/NavContext";
import { useProfile } from "./useProfile";
import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection";
import { DiscoverSection } from "./DiscoverSection";
@@ -12,7 +12,7 @@ type Tab = "discover" | "worlds" | "favorites";
export function MyWorldsView() {
const t = useT();
const state = useProfile("me");
const [tab, setTab] = useState<Tab>("discover");
const [tab, setTab] = useViewState<Tab>("worlds:tab", "discover");
if (state.status === "loading") return <Loader className="absolute inset-0" />;
if (state.status === "error")
@@ -18,6 +18,7 @@ import {
Tag,
} from "../../components/ui";
import { useUserMenu } from "../friends/useUserMenu";
import { useViewState } from "../navigation/NavContext";
import {
avatarOf,
bannerOf,
@@ -47,7 +48,7 @@ export function ProfileView({ target }: { target: "me" | string }) {
function ProfileCard({ profile }: { profile: UserProfile }) {
const t = useT();
const [tab, setTab] = useState<TabId>("overview");
const [tab, setTab] = useViewState<TabId>(`profile:${profile.id}:tab`, "overview");
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const canAdd = !profile.isSelf && !profile.isFriend;
const addFriend = useAddFriend(profile.id);
@@ -14,7 +14,7 @@ import {
Tag,
} from "../../components/ui";
import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext";
import { useNav, useViewState } from "../navigation/NavContext";
import { useUserMenu } from "../friends/useUserMenu";
import { WorldCard } from "../world/WorldCard";
import { avatarOf, trustMeta } from "../../lib/vrchat";
@@ -31,8 +31,8 @@ export function SearchView() {
const t = useT();
const { openUser, openWorld } = useNav();
const { buildItems, modal } = useUserMenu();
const [tab, setTab] = useState<SearchTab>("users");
const [query, setQuery] = useState("");
const [tab, setTab] = useViewState<SearchTab>("search:tab", "users");
const [query, setQuery] = useViewState("search:query", "");
const [busy, setBusy] = useState(false);
const [users, setUsers] = useState<UserProfile[] | null>(null);
const [worlds, setWorlds] = useState<World[] | null>(null);
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import { useTheme } from "../../lib/ThemeContext";
import { useI18n } from "../../lib/i18n";
import { useViewState } from "../navigation/NavContext";
import { ACCENT_PRESETS, DEFAULT_ACCENT, type SchemeMode } from "../../lib/theme";
import type { AppConfig, PreferredRegion, RegionPing } from "../../../../shared/types/appConfig";
import type { UnityStatus } from "../../../../shared/types/unity";
@@ -42,7 +43,7 @@ type SettingsTab = "appearance" | "game" | "creator" | "about";
export function SettingsView() {
const { t } = useI18n();
const [tab, setTab] = useState<SettingsTab>("appearance");
const [tab, setTab] = useViewState<SettingsTab>("settings:tab", "appearance");
return (
<div className={SHELL}>
<header>