feat: add demo mode anonymization

This commit is contained in:
2026-06-30 21:29:57 +07:00
parent 9b90140cfd
commit 06ee048afc
11 changed files with 205 additions and 82 deletions
+2
View File
@@ -14,6 +14,7 @@ const DEFAULT_PREFERENCES: AppPreferences = {
accent: null, accent: null,
locale: DEFAULT_LOCALE, locale: DEFAULT_LOCALE,
showDebugNav: false, showDebugNav: false,
demoMode: false,
}; };
const DEFAULTS: StoredConfig = { const DEFAULTS: StoredConfig = {
gamePath: null, gamePath: null,
@@ -30,6 +31,7 @@ function normalizePreferences(raw: Partial<AppPreferences> | undefined): AppPref
accent: accent && /^#[\da-f]{6}$/i.test(accent) ? accent : null, accent: accent && /^#[\da-f]{6}$/i.test(accent) ? accent : null,
locale: isAppLocale(locale) ? locale : DEFAULT_LOCALE, locale: isAppLocale(locale) ? locale : DEFAULT_LOCALE,
showDebugNav: raw?.showDebugNav === true, showDebugNav: raw?.showDebugNav === true,
demoMode: raw?.demoMode === true,
}; };
} }
@@ -5,6 +5,8 @@ import { Avatar, StatusDot } from "../../components/ui";
import { useSelf } from "../../store/social"; import { useSelf } from "../../store/social";
import { statusMeta } from "../../lib/vrchat"; import { statusMeta } from "../../lib/vrchat";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { useDemoMode } from "../../lib/debugSettings";
import { demoName } from "../../lib/demoMode";
import type { UserStatus } from "../../../../shared/types/user"; import type { UserStatus } from "../../../../shared/types/user";
const STATUS_CHOICES: UserStatus[] = ["join me", "active", "ask me", "busy"]; const STATUS_CHOICES: UserStatus[] = ["join me", "active", "ask me", "busy"];
@@ -15,12 +17,16 @@ const MENU_ROW =
export function AccountSwitcher() { export function AccountSwitcher() {
const { accounts, switchAccount, removeAccount, beginAddAccount, logout } = useAuth(); const { accounts, switchAccount, removeAccount, beginAddAccount, logout } = useAuth();
const self = useSelf(); const self = useSelf();
const demoMode = useDemoMode();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [showAccounts, setShowAccounts] = useState(false); const [showAccounts, setShowAccounts] = useState(false);
const active = accounts.accounts.find((a) => a.id === accounts.activeId); const active = accounts.accounts.find((a) => a.id === accounts.activeId);
const others = accounts.accounts.filter((a) => a.id !== accounts.activeId); const others = accounts.accounts.filter((a) => a.id !== accounts.activeId);
const status = self?.status ?? "offline"; const status = self?.status ?? "offline";
const activeName = active ? (demoMode ? demoName(active.id) : active.displayName) : "Account";
const activeIcon = demoMode ? undefined : active?.userIcon;
const activeSub = demoMode ? statusMeta[status].label : self?.statusDescription || statusMeta[status].label;
function close() { function close() {
setOpen(false); setOpen(false);
@@ -39,7 +45,7 @@ export function AccountSwitcher() {
aria-expanded={open} aria-expanded={open}
> >
<span className="acct__current relative shrink-0 leading-[0]"> <span className="acct__current relative shrink-0 leading-[0]">
<Avatar src={active?.userIcon} name={active?.displayName} size={32} /> <Avatar src={activeIcon} name={activeName} size={32} />
<StatusDot <StatusDot
color={statusMeta[status].color} color={statusMeta[status].color}
ring="var(--surface)" ring="var(--surface)"
@@ -49,10 +55,10 @@ export function AccountSwitcher() {
</span> </span>
<span className="acct__current-text flex min-w-0 flex-1 flex-col"> <span className="acct__current-text flex min-w-0 flex-1 flex-col">
<span className="truncate text-left text-[13.5px] font-semibold leading-[1.25]"> <span className="truncate text-left text-[13.5px] font-semibold leading-[1.25]">
{active?.displayName ?? "Account"} {activeName}
</span> </span>
<span className="truncate text-left text-[11px] font-medium text-faint"> <span className="truncate text-left text-[11px] font-medium text-faint">
{self?.statusDescription || statusMeta[status].label} {activeSub}
</span> </span>
</span> </span>
<span <span
@@ -73,7 +79,9 @@ export function AccountSwitcher() {
<> <>
<Separator /> <Separator />
<div className="flex flex-col gap-px"> <div className="flex flex-col gap-px">
{others.map((a) => ( {others.map((a) => {
const name = demoMode ? demoName(a.id) : a.displayName;
return (
<div <div
key={a.id} key={a.id}
className="group/row flex items-center rounded-sm transition-colors hover:bg-surface-2" className="group/row flex items-center rounded-sm transition-colors hover:bg-surface-2"
@@ -85,19 +93,20 @@ export function AccountSwitcher() {
close(); close();
}} }}
> >
<Avatar src={a.userIcon} name={a.displayName} size={28} /> <Avatar src={demoMode ? undefined : a.userIcon} name={name} size={28} />
<span className="truncate text-[13px] font-semibold">{a.displayName}</span> <span className="truncate text-[13px] font-semibold">{name}</span>
</button> </button>
<button <button
className="grid w-[30px] shrink-0 self-stretch place-items-center rounded-sm text-faint opacity-0 transition-[opacity,color,background] duration-[var(--dur)] ease-[var(--ease)] hover:bg-[color-mix(in_srgb,var(--danger)_12%,transparent)] hover:text-danger group-hover/row:opacity-100" className="grid w-[30px] shrink-0 self-stretch place-items-center rounded-sm text-faint opacity-0 transition-[opacity,color,background] duration-[var(--dur)] ease-[var(--ease)] hover:bg-[color-mix(in_srgb,var(--danger)_12%,transparent)] hover:text-danger group-hover/row:opacity-100"
title="Remove account" title="Remove account"
aria-label={`Remove ${a.displayName}`} aria-label={`Remove ${name}`}
onClick={() => void removeAccount(a.id)} onClick={() => void removeAccount(a.id)}
> >
<X size={15} /> <X size={15} />
</button> </button>
</div> </div>
))} );
})}
</div> </div>
</> </>
) : null} ) : null}
@@ -6,6 +6,8 @@ import { useFriends, useSelf } from "../../store/social";
import { useWorldName } from "../../store/worlds"; import { useWorldName } from "../../store/worlds";
import { parseLocation, type UserProfile } from "../../../../shared/types/user"; import { parseLocation, type UserProfile } from "../../../../shared/types/user";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useDemoMode } from "../../lib/debugSettings";
import { anonymizeUserForDemo } from "../../lib/demoMode";
import { useUserMenu } from "./useUserMenu"; import { useUserMenu } from "./useUserMenu";
import { useFriendsGrouped } from "./useFriendsGrouped"; import { useFriendsGrouped } from "./useFriendsGrouped";
@@ -19,6 +21,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
const t = useT(); const t = useT();
const friends = useFriends(); const friends = useFriends();
const self = useSelf(); const self = useSelf();
const demoMode = useDemoMode();
const selfId = self?.id; const selfId = self?.id;
const { online, offline, instances, alone } = useFriendsGrouped(friends, self); const { online, offline, instances, alone } = useFriendsGrouped(friends, self);
@@ -40,8 +43,8 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
const openContextMenu = useCallback((e: React.MouseEvent, friend: UserProfile) => { const openContextMenu = useCallback((e: React.MouseEvent, friend: UserProfile) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, friend }); setContextMenu({ x: e.clientX, y: e.clientY, friend: demoMode ? anonymizeUserForDemo(friend) : friend });
}, []); }, [demoMode]);
return ( return (
<aside className="friendsbar flex h-full flex-col overflow-hidden border-l border-border bg-surface"> <aside className="friendsbar flex h-full flex-col overflow-hidden border-l border-border bg-surface">
@@ -53,7 +56,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
</header> </header>
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
{selfAlone ? <FriendRow friend={self} onOpen={onOpen} isSelf selfChrome /> : null} {selfAlone ? <FriendRow friend={self} onOpen={onOpen} isSelf selfChrome demoMode={demoMode} /> : null}
{friends.length === 0 ? ( {friends.length === 0 ? (
<p className="p-3 text-[13px] text-faint">{t("nav:friends.noFriendsOnline")}</p> <p className="p-3 text-[13px] text-faint">{t("nav:friends.noFriendsOnline")}</p>
@@ -74,6 +77,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
onOpen={onOpen} onOpen={onOpen}
hideLocation hideLocation
isSelf={f.id === selfId} isSelf={f.id === selfId}
demoMode={demoMode}
onContextMenu={f.id !== selfId ? openContextMenu : undefined} onContextMenu={f.id !== selfId ? openContextMenu : undefined}
/> />
))} ))}
@@ -92,6 +96,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
key={f.id} key={f.id}
friend={f} friend={f}
onOpen={onOpen} onOpen={onOpen}
demoMode={demoMode}
onContextMenu={openContextMenu} onContextMenu={openContextMenu}
/> />
))} ))}
@@ -111,6 +116,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
friend={f} friend={f}
onOpen={onOpen} onOpen={onOpen}
dim dim
demoMode={demoMode}
onContextMenu={openContextMenu} onContextMenu={openContextMenu}
/> />
))} ))}
@@ -199,6 +205,7 @@ function FriendRow({
selfChrome, selfChrome,
hideLocation, hideLocation,
onContextMenu, onContextMenu,
demoMode,
}: { }: {
friend: UserProfile; friend: UserProfile;
onOpen: (id: string) => void; onOpen: (id: string) => void;
@@ -207,20 +214,22 @@ function FriendRow({
selfChrome?: boolean; selfChrome?: boolean;
hideLocation?: boolean; hideLocation?: boolean;
onContextMenu?: (e: React.MouseEvent, friend: UserProfile) => void; onContextMenu?: (e: React.MouseEvent, friend: UserProfile) => void;
demoMode?: boolean;
}) { }) {
const t = useT(); const t = useT();
const presence = presenceOf({ ...friend, isSelf }); const displayFriend = demoMode ? anonymizeUserForDemo(friend) : friend;
const sub = friend.statusDescription || presence.effective.label; const presence = presenceOf({ ...displayFriend, isSelf });
const parsed = parseLocation(friend.location); const sub = displayFriend.statusDescription || presence.effective.label;
const parsed = parseLocation(displayFriend.location);
const worldName = useWorldName(parsed?.worldId); const worldName = useWorldName(parsed?.worldId);
const location = parsed const location = parsed
? `${worldName ? `in ${worldName}` : t("nav:friends.inAWorld")} (#${parsed.instanceId})` ? `${worldName ? `in ${worldName}` : t("nav:friends.inAWorld")} (#${parsed.instanceId})`
: locationLabel(friend.location); : locationLabel(displayFriend.location);
return ( return (
<button <button
onClick={() => onOpen(isSelf ? "me" : friend.id)} onClick={() => onOpen(isSelf ? "me" : friend.id)}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, friend) : undefined} onContextMenu={onContextMenu ? (e) => onContextMenu(e, friend) : undefined}
title={friend.displayName} title={displayFriend.displayName}
className={[ className={[
"friend-row flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-2", "friend-row flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-2",
dim ? "opacity-55 hover:opacity-100" : "", dim ? "opacity-55 hover:opacity-100" : "",
@@ -229,13 +238,13 @@ function FriendRow({
: "", : "",
].join(" ")} ].join(" ")}
> >
<PresenceAvatar user={friend} size={32} /> <PresenceAvatar user={displayFriend} size={32} />
<span className="friend-row__text flex min-w-0 flex-col"> <span className="friend-row__text flex min-w-0 flex-col">
<span <span
className={`flex min-w-0 items-center gap-1.5 text-[13.5px] ${isSelf ? "font-semibold" : "font-medium"}`} className={`flex min-w-0 items-center gap-1.5 text-[13.5px] ${isSelf ? "font-semibold" : "font-medium"}`}
> >
<span className="truncate">{friend.displayName}</span> <span className="truncate">{displayFriend.displayName}</span>
{isSelf ? <Badge tone="accent">You</Badge> : null} {isSelf ? <Badge tone="accent">You</Badge> : null}
</span> </span>
<span className="truncate text-[12px] text-muted">{sub}</span> <span className="truncate text-[12px] text-muted">{sub}</span>
@@ -28,6 +28,8 @@ import {
trustMeta, trustMeta,
} from "../../lib/vrchat"; } from "../../lib/vrchat";
import { useProfile } from "./useProfile"; import { useProfile } from "./useProfile";
import { useDemoMode } from "../../lib/debugSettings";
import { anonymizeUserForDemo } from "../../lib/demoMode";
import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection"; import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection";
import { GroupsSection } from "./GroupsSection"; import { GroupsSection } from "./GroupsSection";
import { LocationSection } from "./LocationSection"; import { LocationSection } from "./LocationSection";
@@ -48,22 +50,24 @@ export function ProfileView({ target }: { target: "me" | string }) {
function ProfileCard({ profile }: { profile: UserProfile }) { function ProfileCard({ profile }: { profile: UserProfile }) {
const t = useT(); const t = useT();
const demoMode = useDemoMode();
const displayProfile = demoMode ? anonymizeUserForDemo(profile) : profile;
const [tab, setTab] = useViewState<TabId>(`profile:${profile.id}:tab`, "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);
const { buildItems, modal } = useUserMenu(); const { buildItems, modal } = useUserMenu();
const trust = trustMeta[profile.trustRank]; const trust = trustMeta[displayProfile.trustRank];
const presence = presenceOf(profile); const presence = presenceOf(displayProfile);
const statusLabel = const statusLabel =
!presence.online && profile.status !== "offline" !presence.online && displayProfile.status !== "offline"
? t("profile:status.offlineWas", { status: presence.status.label }) ? t("profile:status.offlineWas", { status: presence.status.label })
: presence.effective.label; : presence.effective.label;
const avatar = avatarOf(profile); const avatar = avatarOf(displayProfile);
const banner = bannerOf(profile); const banner = bannerOf(displayProfile);
const devLabel = profile.developerType ? developerLabels[profile.developerType] : undefined; const devLabel = displayProfile.developerType ? developerLabels[displayProfile.developerType] : undefined;
const bioLinks = (profile.bioLinks ?? []).filter(Boolean); const bioLinks = (displayProfile.bioLinks ?? []).filter(Boolean);
const showcasedBadges = (profile.badges ?? []).filter((b) => b.showcased); const showcasedBadges = (displayProfile.badges ?? []).filter((b) => b.showcased);
return ( return (
<HeroHeader <HeroHeader
@@ -72,7 +76,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
overlap={-72} overlap={-72}
media={ media={
<div className="profile__avatar" style={{ "--ring": trust.color } as React.CSSProperties}> <div className="profile__avatar" style={{ "--ring": trust.color } as React.CSSProperties}>
<Avatar src={avatar} name={profile.displayName} size={116} /> <Avatar src={avatar} name={displayProfile.displayName} size={116} />
<span <span
className="profile__status-dot" className="profile__status-dot"
style={{ background: presence.color }} style={{ background: presence.color }}
@@ -83,21 +87,21 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
body={ body={
<div className="min-w-0 flex-1 pb-2"> <div className="min-w-0 flex-1 pb-2">
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
<h2 className="text-[32px] font-bold tracking-[-0.6px]">{profile.displayName}</h2> <h2 className="text-[32px] font-bold tracking-[-0.6px]">{displayProfile.displayName}</h2>
{profile.isSelf ? <Tag color="var(--accent)">{t("profile:badge.you")}</Tag> : null} {profile.isSelf ? <Tag color="var(--accent)">{t("profile:badge.you")}</Tag> : null}
{profile.isFriend && !profile.isSelf ? ( {profile.isFriend && !profile.isSelf ? (
<Tag color="var(--status-join)">{t("profile:badge.friend")}</Tag> <Tag color="var(--status-join)">{t("profile:badge.friend")}</Tag>
) : null} ) : null}
{devLabel ? <Tag color="var(--accent)">{devLabel}</Tag> : null} {devLabel ? <Tag color="var(--accent)">{devLabel}</Tag> : null}
{profile.ageVerified ? ( {displayProfile.ageVerified ? (
<Tag color="var(--trust-trusted)">{t("profile:badge.ageVerified")}</Tag> <Tag color="var(--trust-trusted)">{t("profile:badge.ageVerified")}</Tag>
) : null} ) : null}
</div> </div>
<div className="mt-2.5 flex flex-wrap items-center gap-3"> <div className="mt-2.5 flex flex-wrap items-center gap-3">
<Tag color={trust.color}>{trust.label}</Tag> <Tag color={trust.color}>{trust.label}</Tag>
<PresenceLabel label={statusLabel} color={presence.color} /> <PresenceLabel label={statusLabel} color={presence.color} />
{profile.pronouns ? ( {displayProfile.pronouns ? (
<span className="text-[13px] text-muted">{profile.pronouns}</span> <span className="text-[13px] text-muted">{displayProfile.pronouns}</span>
) : null} ) : null}
</div> </div>
{showcasedBadges.length ? ( {showcasedBadges.length ? (
@@ -135,7 +139,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null ) : null
} }
> >
<LocationSection location={profile.location} /> <LocationSection location={displayProfile.location} />
<Tabs <Tabs
tabs={[ tabs={[
@@ -150,22 +154,22 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
{tab === "overview" ? ( {tab === "overview" ? (
<div className="flex flex-col gap-5 rise-in"> <div className="flex flex-col gap-5 rise-in">
{profile.note ? ( {displayProfile.note ? (
<Section title={t("profile:sections.note")}> <Section title={t("profile:sections.note")}>
<p className="text-[14px] leading-relaxed whitespace-pre-wrap text-text"> <p className="text-[14px] leading-relaxed whitespace-pre-wrap text-text">
{profile.note} {displayProfile.note}
</p> </p>
</Section> </Section>
) : null} ) : null}
{profile.statusDescription || profile.bio || bioLinks.length ? ( {displayProfile.statusDescription || displayProfile.bio || bioLinks.length ? (
<Section title={t("profile:sections.about")}> <Section title={t("profile:sections.about")}>
{profile.statusDescription ? ( {displayProfile.statusDescription ? (
<p className="text-[15px] italic text-text">{profile.statusDescription}</p> <p className="text-[15px] italic text-text">{displayProfile.statusDescription}</p>
) : null} ) : null}
{profile.bio ? ( {displayProfile.bio ? (
<p className="text-[14px] leading-relaxed whitespace-pre-wrap text-muted"> <p className="text-[14px] leading-relaxed whitespace-pre-wrap text-muted">
{profile.bio} {displayProfile.bio}
</p> </p>
) : null} ) : null}
{bioLinks.length ? ( {bioLinks.length ? (
@@ -183,61 +187,61 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2"> <div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2">
<Section title={t("profile:sections.details")}> <Section title={t("profile:sections.details")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3"> <dl className="grid grid-cols-2 gap-x-6 gap-y-3">
{profile.dateJoined ? ( {displayProfile.dateJoined ? (
<Fact <Fact
label={t("profile:facts.joined")} label={t("profile:facts.joined")}
value={formatDate(profile.dateJoined)} value={formatDate(displayProfile.dateJoined)}
/> />
) : null} ) : null}
{profile.lastLogin ? ( {displayProfile.lastLogin ? (
<Fact <Fact
label={t("profile:facts.lastLogin")} label={t("profile:facts.lastLogin")}
value={formatDateTime(profile.lastLogin)} value={formatDateTime(displayProfile.lastLogin)}
/> />
) : null} ) : null}
{profile.lastActivity ? ( {displayProfile.lastActivity ? (
<Fact <Fact
label={t("profile:facts.lastActivity")} label={t("profile:facts.lastActivity")}
value={formatDateTime(profile.lastActivity)} value={formatDateTime(displayProfile.lastActivity)}
/> />
) : null} ) : null}
{profile.lastPlatform ? ( {displayProfile.lastPlatform ? (
<Fact <Fact
label={t("profile:facts.platform")} label={t("profile:facts.platform")}
value={platformLabel(profile.lastPlatform)} value={platformLabel(displayProfile.lastPlatform)}
/> />
) : null} ) : null}
{profile.state ? ( {displayProfile.state ? (
<Fact label={t("profile:facts.state")} value={stateLabel(profile.state, t)} /> <Fact label={t("profile:facts.state")} value={stateLabel(displayProfile.state, t)} />
) : null} ) : null}
<Fact label={t("profile:facts.userId")} value={profile.id} mono /> {demoMode ? null : <Fact label={t("profile:facts.userId")} value={profile.id} mono />}
</dl> </dl>
</Section> </Section>
{profile.languages?.length ? ( {displayProfile.languages?.length ? (
<Section title={t("profile:sections.languages")}> <Section title={t("profile:sections.languages")}>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{profile.languages.map((code) => ( {displayProfile.languages.map((code) => (
<Tag key={code}>{languageLabel(code)}</Tag> <Tag key={code}>{languageLabel(code)}</Tag>
))} ))}
</div> </div>
</Section> </Section>
) : null} ) : null}
{profile.currentAvatarTags?.length ? ( {displayProfile.currentAvatarTags?.length ? (
<Section title={t("profile:sections.avatarTags")}> <Section title={t("profile:sections.avatarTags")}>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{profile.currentAvatarTags.map((t) => ( {displayProfile.currentAvatarTags.map((t) => (
<Tag key={t}>{prettyTag(t, "content_")}</Tag> <Tag key={t}>{prettyTag(t, "content_")}</Tag>
))} ))}
</div> </div>
</Section> </Section>
) : null} ) : null}
{profile.badges?.length ? ( {displayProfile.badges?.length ? (
<Section title={t("profile:sections.badges")}> <Section title={t("profile:sections.badges")}>
<div className="flex flex-wrap gap-2.5"> <div className="flex flex-wrap gap-2.5">
{[...profile.badges] {[...displayProfile.badges]
.sort((a, b) => Number(b.showcased) - Number(a.showcased)) .sort((a, b) => Number(b.showcased) - Number(a.showcased))
.map((b) => ( .map((b) => (
<div <div
@@ -260,10 +264,10 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null} ) : null}
</div> </div>
{profile.pastDisplayNames?.length ? ( {displayProfile.pastDisplayNames?.length ? (
<Section title={t("profile:sections.formerNames")} collapsible> <Section title={t("profile:sections.formerNames")} collapsible>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{profile.pastDisplayNames.map((p) => ( {displayProfile.pastDisplayNames.map((p) => (
<span <span
key={`${p.displayName}-${p.updatedAt}`} key={`${p.displayName}-${p.updatedAt}`}
className="rounded-lg border border-border bg-surface px-2.5 py-1 text-[13px] text-text" className="rounded-lg border border-border bg-surface px-2.5 py-1 text-[13px] text-text"
@@ -309,7 +313,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
<ContextMenu <ContextMenu
x={menu.x} x={menu.x}
y={menu.y} y={menu.y}
items={buildItems(profile)} items={buildItems(displayProfile)}
onClose={() => setMenu(null)} onClose={() => setMenu(null)}
/> />
) : null} ) : null}
@@ -27,7 +27,12 @@ import type { AppConfig, PreferredRegion, RegionPing } from "../../../../shared/
import type { UnityStatus } from "../../../../shared/types/unity"; import type { UnityStatus } from "../../../../shared/types/unity";
import { api, errorMessage } from "../../lib/api"; import { api, errorMessage } from "../../lib/api";
import { useAsync } from "../../lib/useAsync"; import { useAsync } from "../../lib/useAsync";
import { useSetDebugNavVisible, useDebugNavVisible } from "../../lib/debugSettings"; import {
useDemoMode,
useSetDemoMode,
useSetDebugNavVisible,
useDebugNavVisible,
} from "../../lib/debugSettings";
import { useAppConfig } from "../../lib/AppConfigContext"; import { useAppConfig } from "../../lib/AppConfigContext";
import { regionFlag, regionLabel } from "../../lib/vrchat"; import { regionFlag, regionLabel } from "../../lib/vrchat";
import { import {
@@ -107,6 +112,8 @@ function DebugSection() {
const { t } = useI18n(); const { t } = useI18n();
const showDebugNav = useDebugNavVisible(); const showDebugNav = useDebugNavVisible();
const setDebugNavVisible = useSetDebugNavVisible(); const setDebugNavVisible = useSetDebugNavVisible();
const demoMode = useDemoMode();
const setDemoMode = useSetDemoMode();
return ( return (
<Section <Section
title={t("settings:debug.title")} title={t("settings:debug.title")}
@@ -120,6 +127,13 @@ function DebugSection() {
</div> </div>
<Toggle checked={showDebugNav} onChange={setDebugNavVisible} /> <Toggle checked={showDebugNav} onChange={setDebugNavVisible} />
</div> </div>
<div className="mt-3 flex items-center justify-between gap-4 rounded-lg border border-border bg-surface-2 p-3">
<div>
<div className="text-[13px] font-semibold text-text">{t("settings:debug.demoMode")}</div>
<div className="mt-0.5 text-[12px] text-muted">{t("settings:debug.demoModeHint")}</div>
</div>
<Toggle checked={demoMode} onChange={setDemoMode} />
</div>
</Section> </Section>
); );
} }
+14
View File
@@ -5,6 +5,10 @@ export function useDebugNavVisible(): boolean {
return useAppConfig().config?.preferences.showDebugNav ?? false; return useAppConfig().config?.preferences.showDebugNav ?? false;
} }
export function useDemoMode(): boolean {
return useAppConfig().config?.preferences.demoMode ?? false;
}
export function useSetDebugNavVisible(): (visible: boolean) => void { export function useSetDebugNavVisible(): (visible: boolean) => void {
const { setPreferences } = useAppConfig(); const { setPreferences } = useAppConfig();
return useCallback( return useCallback(
@@ -14,3 +18,13 @@ export function useSetDebugNavVisible(): (visible: boolean) => void {
[setPreferences], [setPreferences],
); );
} }
export function useSetDemoMode(): (enabled: boolean) => void {
const { setPreferences } = useAppConfig();
return useCallback(
(enabled: boolean) => {
void setPreferences({ demoMode: enabled });
},
[setPreferences],
);
}
+64
View File
@@ -0,0 +1,64 @@
import type { UserProfile } from "../../../shared/types/user";
const GIVEN = [
"Aoi",
"Hana",
"Haruka",
"Hikari",
"Kaede",
"Koharu",
"Mika",
"Natsuki",
"Ren",
"Rin",
"Sora",
"Yui",
];
const FAMILY = [
"Amamiya",
"Fujimoto",
"Hoshino",
"Kisaragi",
"Kobayashi",
"Minazuki",
"Mizuno",
"Sakuraba",
"Shirakawa",
"Tachibana",
"Tsukino",
"Yamabuki",
];
function hashId(id: string): number {
let hash = 2166136261;
for (let i = 0; i < id.length; i++) {
hash ^= id.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
export function demoName(id: string): string {
const hash = hashId(id);
return `${FAMILY[hash % FAMILY.length]} ${GIVEN[(hash >>> 4) % GIVEN.length]}`;
}
export function anonymizeUserForDemo(user: UserProfile): UserProfile {
return {
...user,
displayName: demoName(user.id),
bio: "",
bioLinks: [],
statusDescription: "",
userIcon: "",
profilePicOverride: "",
profilePicOverrideThumbnail: "",
currentAvatarImageUrl: "",
currentAvatarThumbnailImageUrl: "",
location: user.location === "offline" ? "offline" : "private",
note: undefined,
pronouns: undefined,
pastDisplayNames: [],
badges: [],
};
}
@@ -88,7 +88,9 @@
"title": "Debug", "title": "Debug",
"description": "Developer and troubleshooting options.", "description": "Developer and troubleshooting options.",
"sidebar": "Show Debug in the sidebar", "sidebar": "Show Debug in the sidebar",
"sidebarHint": "Adds the Debug shortcut back to the left navigation." "sidebarHint": "Adds the Debug shortcut back to the left navigation.",
"demoMode": "Demo mode",
"demoModeHint": "Anonymize user-facing details for screenshots."
}, },
"region": { "region": {
"title": "Instance Region", "title": "Instance Region",
@@ -88,7 +88,9 @@
"title": "デバッグ", "title": "デバッグ",
"description": "開発者向けとトラブルシューティング用の設定です。", "description": "開発者向けとトラブルシューティング用の設定です。",
"sidebar": "サイドバーにデバッグを表示", "sidebar": "サイドバーにデバッグを表示",
"sidebarHint": "左のナビゲーションにデバッグのショートカットを表示します。" "sidebarHint": "左のナビゲーションにデバッグのショートカットを表示します。",
"demoMode": "デモモード",
"demoModeHint": "スクリーンショット用にユーザー情報を匿名化します。"
}, },
"region": { "region": {
"title": "インスタンスのリージョン", "title": "インスタンスのリージョン",
@@ -88,7 +88,9 @@
"title": "ดีบัก", "title": "ดีบัก",
"description": "ตัวเลือกสำหรับนักพัฒนาและการแก้ปัญหา", "description": "ตัวเลือกสำหรับนักพัฒนาและการแก้ปัญหา",
"sidebar": "แสดงดีบักในแถบด้านข้าง", "sidebar": "แสดงดีบักในแถบด้านข้าง",
"sidebarHint": "เพิ่มทางลัดดีบักกลับไปที่เมนูนำทางด้านซ้าย" "sidebarHint": "เพิ่มทางลัดดีบักกลับไปที่เมนูนำทางด้านซ้าย",
"demoMode": "โหมดเดโม",
"demoModeHint": "ซ่อนข้อมูลผู้ใช้สำหรับภาพหน้าจอ"
}, },
"region": { "region": {
"title": "ภูมิภาคของอินสแตนซ์", "title": "ภูมิภาคของอินสแตนซ์",
+1
View File
@@ -9,6 +9,7 @@ export interface AppPreferences {
accent: string | null; accent: string | null;
locale: AppLocale; locale: AppLocale;
showDebugNav: boolean; showDebugNav: boolean;
demoMode: boolean;
} }
export interface AppConfig { export interface AppConfig {