♻️ refactor: extract useFriendsGrouped, rename useAction, dedupe friend count

This commit is contained in:
2026-06-29 03:30:31 +07:00
parent ae5e4f1049
commit 1a91ffbe92
12 changed files with 101 additions and 77 deletions
@@ -1,7 +1,7 @@
import type { ContentFilterKey } from "../../../../../shared/types/settings"; import type { ContentFilterKey } from "../../../../../shared/types/settings";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n"; import { useI18n } from "../../../lib/i18n";
import { Notice, Section, ToggleRow, useAsync, type SectionProps } from "../ui"; import { Notice, Section, ToggleRow, useAction, type SectionProps } from "../ui";
const FILTER_ORDER: ContentFilterKey[] = [ const FILTER_ORDER: ContentFilterKey[] = [
"content_sex", "content_sex",
@@ -13,7 +13,7 @@ const FILTER_ORDER: ContentFilterKey[] = [
export function ContentGatingSection({ settings, onChange }: SectionProps) { export function ContentGatingSection({ settings, onChange }: SectionProps) {
const { t } = useI18n(); const { t } = useI18n();
const { busy, error, run } = useAsync(); const { busy, error, run } = useAction();
const active = new Set(settings.contentFilters); const active = new Set(settings.contentFilters);
function toggle(key: ContentFilterKey, on: boolean) { function toggle(key: ContentFilterKey, on: boolean) {
@@ -4,11 +4,11 @@ import { Trans } from "react-i18next";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n"; import { useI18n } from "../../../lib/i18n";
import { Button, Field } from "../../../components/ui"; import { Button, Field } from "../../../components/ui";
import { Notice, Section, useAsync, type SectionProps } from "../ui"; import { Notice, Section, useAction, type SectionProps } from "../ui";
export function DangerZoneSection({ settings, onChange }: SectionProps) { export function DangerZoneSection({ settings, onChange }: SectionProps) {
const { t } = useI18n(); const { t } = useI18n();
const { busy, error, run } = useAsync(); const { busy, error, run } = useAction();
const [confirmText, setConfirmText] = useState(""); const [confirmText, setConfirmText] = useState("");
const canDelete = confirmText.trim().toUpperCase() === "DELETE"; const canDelete = confirmText.trim().toUpperCase() === "DELETE";
@@ -11,7 +11,7 @@ import {
addDays, addDays,
daysSince, daysSince,
lastChangedLabel, lastChangedLabel,
useAsync, useAction,
type SectionProps, type SectionProps,
} from "../ui"; } from "../ui";
@@ -20,7 +20,7 @@ export function DisplayNameSection({ settings, onChange }: SectionProps) {
const [name, setName] = useState(settings.displayName); const [name, setName] = useState(settings.displayName);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmRevert, setConfirmRevert] = useState(false); const [confirmRevert, setConfirmRevert] = useState(false);
const { busy, error, ok, run } = useAsync(); const { busy, error, ok, run } = useAction();
const cooldownDays = settings.supporter ? 30 : 90; const cooldownDays = settings.supporter ? 30 : 90;
const changedDaysAgo = daysSince(settings.displayNameChangedAt); const changedDaysAgo = daysSince(settings.displayNameChangedAt);
@@ -2,13 +2,13 @@ import { useState } from "react";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n"; import { useI18n } from "../../../lib/i18n";
import { Button, Field } from "../../../components/ui"; import { Button, Field } from "../../../components/ui";
import { Notice, Section, useAsync, type SectionProps } from "../ui"; import { Notice, Section, useAction, type SectionProps } from "../ui";
export function EmailSection({ settings, onChange }: SectionProps) { export function EmailSection({ settings, onChange }: SectionProps) {
const { t } = useI18n(); const { t } = useI18n();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const { busy, error, ok, run } = useAsync(); const { busy, error, ok, run } = useAction();
async function submit() { async function submit() {
await run(api.settings.email(email.trim(), password), { await run(api.settings.email(email.trim(), password), {
@@ -7,7 +7,7 @@ import {
Notice, Notice,
Section, Section,
WEBSITE_ACCOUNT, WEBSITE_ACCOUNT,
useAsync, useAction,
type SectionProps, type SectionProps,
} from "../ui"; } from "../ui";
@@ -16,7 +16,7 @@ export function PasswordSection({ settings, onChange }: SectionProps) {
const [current, setCurrent] = useState(""); const [current, setCurrent] = useState("");
const [next, setNext] = useState(""); const [next, setNext] = useState("");
const [confirm, setConfirm] = useState(""); const [confirm, setConfirm] = useState("");
const { busy, error, ok, run } = useAsync(); const { busy, error, ok, run } = useAction();
const mismatch = confirm.length > 0 && next !== confirm; const mismatch = confirm.length > 0 && next !== confirm;
const valid = current.length > 0 && next.length >= 8 && next === confirm; const valid = current.length > 0 && next.length >= 8 && next === confirm;
@@ -1,10 +1,10 @@
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n"; import { useI18n } from "../../../lib/i18n";
import { Notice, Section, ToggleRow, useAsync, type SectionProps } from "../ui"; import { Notice, Section, ToggleRow, useAction, type SectionProps } from "../ui";
export function PrivacySection({ settings, onChange }: SectionProps) { export function PrivacySection({ settings, onChange }: SectionProps) {
const { t } = useI18n(); const { t } = useI18n();
const { busy, error, run } = useAsync(); const { busy, error, run } = useAction();
function setShared(show: boolean) { function setShared(show: boolean) {
void run(api.settings.privacy({ sharedConnectionsHidden: !show }), { onOk: onChange }); void run(api.settings.privacy({ sharedConnectionsHidden: !show }), { onOk: onChange });
@@ -6,7 +6,7 @@ import { useI18n } from "../../../lib/i18n";
import { Badge, Button, Field, Modal } from "../../../components/ui"; import { Badge, Button, Field, Modal } from "../../../components/ui";
import { useStepUp } from "../../auth/useStepUp"; import { useStepUp } from "../../auth/useStepUp";
import { TwoFactorPrompt } from "../../auth/TwoFactorPrompt"; import { TwoFactorPrompt } from "../../auth/TwoFactorPrompt";
import { Notice, Section, useAsync, type SectionProps } from "../ui"; import { Notice, Section, useAction, type SectionProps } from "../ui";
export function TwoFactorSection({ settings, onChange }: SectionProps) { export function TwoFactorSection({ settings, onChange }: SectionProps) {
const { t } = useI18n(); const { t } = useI18n();
@@ -16,8 +16,8 @@ export function TwoFactorSection({ settings, onChange }: SectionProps) {
} | null>(null); } | null>(null);
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [codes, setCodes] = useState<RecoveryCode[] | null>(null); const [codes, setCodes] = useState<RecoveryCode[] | null>(null);
const setup = useAsync(); const setup = useAction();
const verify = useAsync(); const verify = useAction();
const [confirmDisable, setConfirmDisable] = useState(false); const [confirmDisable, setConfirmDisable] = useState(false);
const [disabledOk, setDisabledOk] = useState(false); const [disabledOk, setDisabledOk] = useState(false);
const stepUp = useStepUp(); const stepUp = useStepUp();
@@ -2,11 +2,11 @@ import { useState } from "react";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n"; import { useI18n } from "../../../lib/i18n";
import { Button } from "../../../components/ui"; import { Button } from "../../../components/ui";
import { Notice, Section, useAsync } from "../ui"; import { Notice, Section, useAction } from "../ui";
export function UserDataSection() { export function UserDataSection() {
const { t } = useI18n(); const { t } = useI18n();
const { busy, error, ok, run } = useAsync(); const { busy, error, ok, run } = useAction();
const [armed, setArmed] = useState(false); const [armed, setArmed] = useState(false);
async function reset() { async function reset() {
+1 -1
View File
@@ -47,7 +47,7 @@ export function Section({
); );
} }
export function useAsync() { export function useAction() {
const { t } = useI18n(); const { t } = useI18n();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useSocial } from "../../store/social"; import { useFriends } from "../../store/social";
import { useWorlds } from "../../store/worlds"; import { useWorlds } from "../../store/worlds";
import { useGroups } from "../../store/groups"; import { useGroups } from "../../store/groups";
import { useCopied, useDebug } from "./useDebug"; import { useCopied, useDebug } from "./useDebug";
@@ -19,7 +19,7 @@ export function DebugPanel() {
const { cache, stats, logs, ws, repoStats, invalidate, clear, clearLogs, clearWs } = useDebug(); const { cache, stats, logs, ws, repoStats, invalidate, clear, clearLogs, clearWs } = useDebug();
const [tab, setTab] = useState<Tab>("cache"); const [tab, setTab] = useState<Tab>("cache");
const friendCount = useSocial((s) => Object.values(s.users).filter((u) => u.isFriend).length); const friendCount = useFriends().length;
const worldCount = useWorlds((s) => Object.keys(s.worlds).length); const worldCount = useWorlds((s) => Object.keys(s.worlds).length);
const groupCount = useGroups((s) => Object.keys(s.groups).length); const groupCount = useGroups((s) => Object.keys(s.groups).length);
const repoCount = repoStats.reduce((sum, r) => sum + r.count, 0); const repoCount = repoStats.reduce((sum, r) => sum + r.count, 0);
@@ -1,4 +1,4 @@
import { useMemo, useState, useCallback, type ReactNode } from "react"; import { useState, useCallback, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import { isOnline, locationLabel, presenceOf } from "../../lib/vrchat"; import { isOnline, locationLabel, presenceOf } from "../../lib/vrchat";
import { Badge, PresenceAvatar, ContextMenu } from "../../components/ui"; import { Badge, PresenceAvatar, ContextMenu } from "../../components/ui";
@@ -7,13 +7,7 @@ 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 { useUserMenu } from "./useUserMenu"; import { useUserMenu } from "./useUserMenu";
import { useFriendsGrouped } from "./useFriendsGrouped";
interface InstanceSection {
key: string;
worldId: string;
instanceId: string;
members: UserProfile[];
}
interface ContextMenuState { interface ContextMenuState {
x: number; x: number;
@@ -27,55 +21,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
const self = useSelf(); const self = useSelf();
const selfId = self?.id; const selfId = self?.id;
const sorted = useMemo( const { online, offline, instances, alone } = useFriendsGrouped(friends, self);
() =>
[...friends].sort(
(a, b) =>
Number(isOnline(b)) - Number(isOnline(a)) || a.displayName.localeCompare(b.displayName),
),
[friends],
);
const online = useMemo(() => sorted.filter(isOnline), [sorted]);
const offline = useMemo(() => sorted.filter((f) => !isOnline(f)), [sorted]);
const { instances, alone } = useMemo(() => {
const byInstance = new Map<string, UserProfile[]>();
const alone: UserProfile[] = [];
const people = self && isOnline(self) ? [self, ...online] : online;
for (const f of people) {
const parsed = parseLocation(f.location);
if (!parsed) {
if (f.id !== selfId) alone.push(f);
continue;
}
const key = `${parsed.worldId}:${parsed.instanceId}`;
const list = byInstance.get(key);
if (list) list.push(f);
else byInstance.set(key, [f]);
}
const instances: InstanceSection[] = [];
for (const [key, members] of byInstance) {
if (members.length < 2) {
if (members[0].id !== selfId) alone.push(members[0]);
continue;
}
members.sort(
(a, b) =>
Number(b.id === selfId) - Number(a.id === selfId) ||
a.displayName.localeCompare(b.displayName),
);
const [worldId, instanceId] = key.split(":");
instances.push({ key, worldId, instanceId, members });
}
instances.sort(
(a, b) =>
Number(b.members.some((m) => m.id === selfId)) -
Number(a.members.some((m) => m.id === selfId)) || b.members.length - a.members.length,
);
alone.sort((a, b) => a.displayName.localeCompare(b.displayName));
return { instances, alone };
}, [online, self, selfId]);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["offline"])); const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["offline"]));
const toggle = (id: string) => const toggle = (id: string) =>
@@ -0,0 +1,78 @@
import { useMemo } from "react";
import { parseLocation, type UserProfile } from "../../../../shared/types/user";
import { isOnline } from "../../lib/vrchat";
export interface InstanceSection {
key: string;
worldId: string;
instanceId: string;
members: UserProfile[];
}
export interface FriendsGrouped {
online: UserProfile[];
offline: UserProfile[];
instances: InstanceSection[];
alone: UserProfile[];
}
// groups online friends (plus self) by shared instance; everyone in an instance
// of one stays in `alone`. self is pinned first within each instance.
export function useFriendsGrouped(
friends: UserProfile[],
self: UserProfile | undefined,
): FriendsGrouped {
const selfId = self?.id;
const sorted = useMemo(
() =>
[...friends].sort(
(a, b) =>
Number(isOnline(b)) - Number(isOnline(a)) || a.displayName.localeCompare(b.displayName),
),
[friends],
);
const online = useMemo(() => sorted.filter(isOnline), [sorted]);
const offline = useMemo(() => sorted.filter((f) => !isOnline(f)), [sorted]);
const { instances, alone } = useMemo(() => {
const byInstance = new Map<string, UserProfile[]>();
const alone: UserProfile[] = [];
const people = self && isOnline(self) ? [self, ...online] : online;
for (const f of people) {
const parsed = parseLocation(f.location);
if (!parsed) {
if (f.id !== selfId) alone.push(f);
continue;
}
const key = `${parsed.worldId}:${parsed.instanceId}`;
const list = byInstance.get(key);
if (list) list.push(f);
else byInstance.set(key, [f]);
}
const instances: InstanceSection[] = [];
for (const [key, members] of byInstance) {
if (members.length < 2) {
if (members[0].id !== selfId) alone.push(members[0]);
continue;
}
members.sort(
(a, b) =>
Number(b.id === selfId) - Number(a.id === selfId) ||
a.displayName.localeCompare(b.displayName),
);
const [worldId, instanceId] = key.split(":");
instances.push({ key, worldId, instanceId, members });
}
instances.sort(
(a, b) =>
Number(b.members.some((m) => m.id === selfId)) -
Number(a.members.some((m) => m.id === selfId)) || b.members.length - a.members.length,
);
alone.sort((a, b) => a.displayName.localeCompare(b.displayName));
return { instances, alone };
}, [online, self, selfId]);
return { online, offline, instances, alone };
}