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