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,
locale: DEFAULT_LOCALE,
showDebugNav: false,
demoMode: false,
};
const DEFAULTS: StoredConfig = {
gamePath: null,
@@ -30,6 +31,7 @@ function normalizePreferences(raw: Partial<AppPreferences> | undefined): AppPref
accent: accent && /^#[\da-f]{6}$/i.test(accent) ? accent : null,
locale: isAppLocale(locale) ? locale : DEFAULT_LOCALE,
showDebugNav: raw?.showDebugNav === true,
demoMode: raw?.demoMode === true,
};
}
@@ -5,6 +5,8 @@ import { Avatar, StatusDot } from "../../components/ui";
import { useSelf } from "../../store/social";
import { statusMeta } from "../../lib/vrchat";
import { api } from "../../lib/api";
import { useDemoMode } from "../../lib/debugSettings";
import { demoName } from "../../lib/demoMode";
import type { UserStatus } from "../../../../shared/types/user";
const STATUS_CHOICES: UserStatus[] = ["join me", "active", "ask me", "busy"];
@@ -15,12 +17,16 @@ const MENU_ROW =
export function AccountSwitcher() {
const { accounts, switchAccount, removeAccount, beginAddAccount, logout } = useAuth();
const self = useSelf();
const demoMode = useDemoMode();
const [open, setOpen] = useState(false);
const [showAccounts, setShowAccounts] = useState(false);
const active = accounts.accounts.find((a) => a.id === accounts.activeId);
const others = accounts.accounts.filter((a) => a.id !== accounts.activeId);
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() {
setOpen(false);
@@ -39,7 +45,7 @@ export function AccountSwitcher() {
aria-expanded={open}
>
<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
color={statusMeta[status].color}
ring="var(--surface)"
@@ -49,10 +55,10 @@ export function AccountSwitcher() {
</span>
<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]">
{active?.displayName ?? "Account"}
{activeName}
</span>
<span className="truncate text-left text-[11px] font-medium text-faint">
{self?.statusDescription || statusMeta[status].label}
{activeSub}
</span>
</span>
<span
@@ -73,7 +79,9 @@ export function AccountSwitcher() {
<>
<Separator />
<div className="flex flex-col gap-px">
{others.map((a) => (
{others.map((a) => {
const name = demoMode ? demoName(a.id) : a.displayName;
return (
<div
key={a.id}
className="group/row flex items-center rounded-sm transition-colors hover:bg-surface-2"
@@ -85,19 +93,20 @@ export function AccountSwitcher() {
close();
}}
>
<Avatar src={a.userIcon} name={a.displayName} size={28} />
<span className="truncate text-[13px] font-semibold">{a.displayName}</span>
<Avatar src={demoMode ? undefined : a.userIcon} name={name} size={28} />
<span className="truncate text-[13px] font-semibold">{name}</span>
</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"
title="Remove account"
aria-label={`Remove ${a.displayName}`}
aria-label={`Remove ${name}`}
onClick={() => void removeAccount(a.id)}
>
<X size={15} />
</button>
</div>
))}
);
})}
</div>
</>
) : null}
@@ -6,6 +6,8 @@ import { useFriends, useSelf } from "../../store/social";
import { useWorldName } from "../../store/worlds";
import { parseLocation, type UserProfile } from "../../../../shared/types/user";
import { useT } from "../../lib/i18n";
import { useDemoMode } from "../../lib/debugSettings";
import { anonymizeUserForDemo } from "../../lib/demoMode";
import { useUserMenu } from "./useUserMenu";
import { useFriendsGrouped } from "./useFriendsGrouped";
@@ -19,6 +21,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
const t = useT();
const friends = useFriends();
const self = useSelf();
const demoMode = useDemoMode();
const selfId = self?.id;
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) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, friend });
}, []);
setContextMenu({ x: e.clientX, y: e.clientY, friend: demoMode ? anonymizeUserForDemo(friend) : friend });
}, [demoMode]);
return (
<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>
<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 ? (
<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}
hideLocation
isSelf={f.id === selfId}
demoMode={demoMode}
onContextMenu={f.id !== selfId ? openContextMenu : undefined}
/>
))}
@@ -92,6 +96,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
key={f.id}
friend={f}
onOpen={onOpen}
demoMode={demoMode}
onContextMenu={openContextMenu}
/>
))}
@@ -111,6 +116,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
friend={f}
onOpen={onOpen}
dim
demoMode={demoMode}
onContextMenu={openContextMenu}
/>
))}
@@ -199,6 +205,7 @@ function FriendRow({
selfChrome,
hideLocation,
onContextMenu,
demoMode,
}: {
friend: UserProfile;
onOpen: (id: string) => void;
@@ -207,20 +214,22 @@ function FriendRow({
selfChrome?: boolean;
hideLocation?: boolean;
onContextMenu?: (e: React.MouseEvent, friend: UserProfile) => void;
demoMode?: boolean;
}) {
const t = useT();
const presence = presenceOf({ ...friend, isSelf });
const sub = friend.statusDescription || presence.effective.label;
const parsed = parseLocation(friend.location);
const displayFriend = demoMode ? anonymizeUserForDemo(friend) : friend;
const presence = presenceOf({ ...displayFriend, isSelf });
const sub = displayFriend.statusDescription || presence.effective.label;
const parsed = parseLocation(displayFriend.location);
const worldName = useWorldName(parsed?.worldId);
const location = parsed
? `${worldName ? `in ${worldName}` : t("nav:friends.inAWorld")} (#${parsed.instanceId})`
: locationLabel(friend.location);
: locationLabel(displayFriend.location);
return (
<button
onClick={() => onOpen(isSelf ? "me" : friend.id)}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, friend) : undefined}
title={friend.displayName}
title={displayFriend.displayName}
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",
dim ? "opacity-55 hover:opacity-100" : "",
@@ -229,13 +238,13 @@ function FriendRow({
: "",
].join(" ")}
>
<PresenceAvatar user={friend} size={32} />
<PresenceAvatar user={displayFriend} size={32} />
<span className="friend-row__text flex min-w-0 flex-col">
<span
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}
</span>
<span className="truncate text-[12px] text-muted">{sub}</span>
@@ -28,6 +28,8 @@ import {
trustMeta,
} from "../../lib/vrchat";
import { useProfile } from "./useProfile";
import { useDemoMode } from "../../lib/debugSettings";
import { anonymizeUserForDemo } from "../../lib/demoMode";
import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection";
import { GroupsSection } from "./GroupsSection";
import { LocationSection } from "./LocationSection";
@@ -48,22 +50,24 @@ export function ProfileView({ target }: { target: "me" | string }) {
function ProfileCard({ profile }: { profile: UserProfile }) {
const t = useT();
const demoMode = useDemoMode();
const displayProfile = demoMode ? anonymizeUserForDemo(profile) : profile;
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);
const { buildItems, modal } = useUserMenu();
const trust = trustMeta[profile.trustRank];
const presence = presenceOf(profile);
const trust = trustMeta[displayProfile.trustRank];
const presence = presenceOf(displayProfile);
const statusLabel =
!presence.online && profile.status !== "offline"
!presence.online && displayProfile.status !== "offline"
? t("profile:status.offlineWas", { status: presence.status.label })
: presence.effective.label;
const avatar = avatarOf(profile);
const banner = bannerOf(profile);
const devLabel = profile.developerType ? developerLabels[profile.developerType] : undefined;
const bioLinks = (profile.bioLinks ?? []).filter(Boolean);
const showcasedBadges = (profile.badges ?? []).filter((b) => b.showcased);
const avatar = avatarOf(displayProfile);
const banner = bannerOf(displayProfile);
const devLabel = displayProfile.developerType ? developerLabels[displayProfile.developerType] : undefined;
const bioLinks = (displayProfile.bioLinks ?? []).filter(Boolean);
const showcasedBadges = (displayProfile.badges ?? []).filter((b) => b.showcased);
return (
<HeroHeader
@@ -72,7 +76,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
overlap={-72}
media={
<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
className="profile__status-dot"
style={{ background: presence.color }}
@@ -83,21 +87,21 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
body={
<div className="min-w-0 flex-1 pb-2">
<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.isFriend && !profile.isSelf ? (
<Tag color="var(--status-join)">{t("profile:badge.friend")}</Tag>
) : null}
{devLabel ? <Tag color="var(--accent)">{devLabel}</Tag> : null}
{profile.ageVerified ? (
{displayProfile.ageVerified ? (
<Tag color="var(--trust-trusted)">{t("profile:badge.ageVerified")}</Tag>
) : null}
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-3">
<Tag color={trust.color}>{trust.label}</Tag>
<PresenceLabel label={statusLabel} color={presence.color} />
{profile.pronouns ? (
<span className="text-[13px] text-muted">{profile.pronouns}</span>
{displayProfile.pronouns ? (
<span className="text-[13px] text-muted">{displayProfile.pronouns}</span>
) : null}
</div>
{showcasedBadges.length ? (
@@ -135,7 +139,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null
}
>
<LocationSection location={profile.location} />
<LocationSection location={displayProfile.location} />
<Tabs
tabs={[
@@ -150,22 +154,22 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
{tab === "overview" ? (
<div className="flex flex-col gap-5 rise-in">
{profile.note ? (
{displayProfile.note ? (
<Section title={t("profile:sections.note")}>
<p className="text-[14px] leading-relaxed whitespace-pre-wrap text-text">
{profile.note}
{displayProfile.note}
</p>
</Section>
) : null}
{profile.statusDescription || profile.bio || bioLinks.length ? (
{displayProfile.statusDescription || displayProfile.bio || bioLinks.length ? (
<Section title={t("profile:sections.about")}>
{profile.statusDescription ? (
<p className="text-[15px] italic text-text">{profile.statusDescription}</p>
{displayProfile.statusDescription ? (
<p className="text-[15px] italic text-text">{displayProfile.statusDescription}</p>
) : null}
{profile.bio ? (
{displayProfile.bio ? (
<p className="text-[14px] leading-relaxed whitespace-pre-wrap text-muted">
{profile.bio}
{displayProfile.bio}
</p>
) : null}
{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">
<Section title={t("profile:sections.details")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
{profile.dateJoined ? (
{displayProfile.dateJoined ? (
<Fact
label={t("profile:facts.joined")}
value={formatDate(profile.dateJoined)}
value={formatDate(displayProfile.dateJoined)}
/>
) : null}
{profile.lastLogin ? (
{displayProfile.lastLogin ? (
<Fact
label={t("profile:facts.lastLogin")}
value={formatDateTime(profile.lastLogin)}
value={formatDateTime(displayProfile.lastLogin)}
/>
) : null}
{profile.lastActivity ? (
{displayProfile.lastActivity ? (
<Fact
label={t("profile:facts.lastActivity")}
value={formatDateTime(profile.lastActivity)}
value={formatDateTime(displayProfile.lastActivity)}
/>
) : null}
{profile.lastPlatform ? (
{displayProfile.lastPlatform ? (
<Fact
label={t("profile:facts.platform")}
value={platformLabel(profile.lastPlatform)}
value={platformLabel(displayProfile.lastPlatform)}
/>
) : null}
{profile.state ? (
<Fact label={t("profile:facts.state")} value={stateLabel(profile.state, t)} />
{displayProfile.state ? (
<Fact label={t("profile:facts.state")} value={stateLabel(displayProfile.state, t)} />
) : null}
<Fact label={t("profile:facts.userId")} value={profile.id} mono />
{demoMode ? null : <Fact label={t("profile:facts.userId")} value={profile.id} mono />}
</dl>
</Section>
{profile.languages?.length ? (
{displayProfile.languages?.length ? (
<Section title={t("profile:sections.languages")}>
<div className="flex flex-wrap gap-1.5">
{profile.languages.map((code) => (
{displayProfile.languages.map((code) => (
<Tag key={code}>{languageLabel(code)}</Tag>
))}
</div>
</Section>
) : null}
{profile.currentAvatarTags?.length ? (
{displayProfile.currentAvatarTags?.length ? (
<Section title={t("profile:sections.avatarTags")}>
<div className="flex flex-wrap gap-1.5">
{profile.currentAvatarTags.map((t) => (
{displayProfile.currentAvatarTags.map((t) => (
<Tag key={t}>{prettyTag(t, "content_")}</Tag>
))}
</div>
</Section>
) : null}
{profile.badges?.length ? (
{displayProfile.badges?.length ? (
<Section title={t("profile:sections.badges")}>
<div className="flex flex-wrap gap-2.5">
{[...profile.badges]
{[...displayProfile.badges]
.sort((a, b) => Number(b.showcased) - Number(a.showcased))
.map((b) => (
<div
@@ -260,10 +264,10 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null}
</div>
{profile.pastDisplayNames?.length ? (
{displayProfile.pastDisplayNames?.length ? (
<Section title={t("profile:sections.formerNames")} collapsible>
<div className="flex flex-wrap gap-2">
{profile.pastDisplayNames.map((p) => (
{displayProfile.pastDisplayNames.map((p) => (
<span
key={`${p.displayName}-${p.updatedAt}`}
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
x={menu.x}
y={menu.y}
items={buildItems(profile)}
items={buildItems(displayProfile)}
onClose={() => setMenu(null)}
/>
) : null}
@@ -27,7 +27,12 @@ import type { AppConfig, PreferredRegion, RegionPing } from "../../../../shared/
import type { UnityStatus } from "../../../../shared/types/unity";
import { api, errorMessage } from "../../lib/api";
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 { regionFlag, regionLabel } from "../../lib/vrchat";
import {
@@ -107,6 +112,8 @@ function DebugSection() {
const { t } = useI18n();
const showDebugNav = useDebugNavVisible();
const setDebugNavVisible = useSetDebugNavVisible();
const demoMode = useDemoMode();
const setDemoMode = useSetDemoMode();
return (
<Section
title={t("settings:debug.title")}
@@ -120,6 +127,13 @@ function DebugSection() {
</div>
<Toggle checked={showDebugNav} onChange={setDebugNavVisible} />
</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>
);
}
+14
View File
@@ -5,6 +5,10 @@ export function useDebugNavVisible(): boolean {
return useAppConfig().config?.preferences.showDebugNav ?? false;
}
export function useDemoMode(): boolean {
return useAppConfig().config?.preferences.demoMode ?? false;
}
export function useSetDebugNavVisible(): (visible: boolean) => void {
const { setPreferences } = useAppConfig();
return useCallback(
@@ -14,3 +18,13 @@ export function useSetDebugNavVisible(): (visible: boolean) => void {
[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",
"description": "Developer and troubleshooting options.",
"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": {
"title": "Instance Region",
@@ -88,7 +88,9 @@
"title": "デバッグ",
"description": "開発者向けとトラブルシューティング用の設定です。",
"sidebar": "サイドバーにデバッグを表示",
"sidebarHint": "左のナビゲーションにデバッグのショートカットを表示します。"
"sidebarHint": "左のナビゲーションにデバッグのショートカットを表示します。",
"demoMode": "デモモード",
"demoModeHint": "スクリーンショット用にユーザー情報を匿名化します。"
},
"region": {
"title": "インスタンスのリージョン",
@@ -88,7 +88,9 @@
"title": "ดีบัก",
"description": "ตัวเลือกสำหรับนักพัฒนาและการแก้ปัญหา",
"sidebar": "แสดงดีบักในแถบด้านข้าง",
"sidebarHint": "เพิ่มทางลัดดีบักกลับไปที่เมนูนำทางด้านซ้าย"
"sidebarHint": "เพิ่มทางลัดดีบักกลับไปที่เมนูนำทางด้านซ้าย",
"demoMode": "โหมดเดโม",
"demoModeHint": "ซ่อนข้อมูลผู้ใช้สำหรับภาพหน้าจอ"
},
"region": {
"title": "ภูมิภาคของอินสแตนซ์",
+1
View File
@@ -9,6 +9,7 @@ export interface AppPreferences {
accent: string | null;
locale: AppLocale;
showDebugNav: boolean;
demoMode: boolean;
}
export interface AppConfig {