From 1a91ffbe92de7412c06238725d3c615f73b1bc50 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Mon, 29 Jun 2026 03:23:04 +0700 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20extract=20useF?= =?UTF-8?q?riendsGrouped,=20rename=20useAction,=20dedupe=20friend=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/sections/ContentGatingSection.tsx | 4 +- .../account/sections/DangerZoneSection.tsx | 4 +- .../account/sections/DisplayNameSection.tsx | 4 +- .../account/sections/EmailSection.tsx | 4 +- .../account/sections/PasswordSection.tsx | 4 +- .../account/sections/PrivacySection.tsx | 4 +- .../account/sections/TwoFactorSection.tsx | 6 +- .../account/sections/UserDataSection.tsx | 4 +- src/renderer/src/features/account/ui.tsx | 2 +- .../src/features/debug/DebugPanel.tsx | 4 +- .../src/features/friends/FriendsSidebar.tsx | 60 +------------- .../src/features/friends/useFriendsGrouped.ts | 78 +++++++++++++++++++ 12 files changed, 101 insertions(+), 77 deletions(-) create mode 100644 src/renderer/src/features/friends/useFriendsGrouped.ts diff --git a/src/renderer/src/features/account/sections/ContentGatingSection.tsx b/src/renderer/src/features/account/sections/ContentGatingSection.tsx index e276e0f..9e30304 100644 --- a/src/renderer/src/features/account/sections/ContentGatingSection.tsx +++ b/src/renderer/src/features/account/sections/ContentGatingSection.tsx @@ -1,7 +1,7 @@ import type { ContentFilterKey } from "../../../../../shared/types/settings"; import { api } from "../../../lib/api"; 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[] = [ "content_sex", @@ -13,7 +13,7 @@ const FILTER_ORDER: ContentFilterKey[] = [ export function ContentGatingSection({ settings, onChange }: SectionProps) { const { t } = useI18n(); - const { busy, error, run } = useAsync(); + const { busy, error, run } = useAction(); const active = new Set(settings.contentFilters); function toggle(key: ContentFilterKey, on: boolean) { diff --git a/src/renderer/src/features/account/sections/DangerZoneSection.tsx b/src/renderer/src/features/account/sections/DangerZoneSection.tsx index b0c7669..c9e66b3 100644 --- a/src/renderer/src/features/account/sections/DangerZoneSection.tsx +++ b/src/renderer/src/features/account/sections/DangerZoneSection.tsx @@ -4,11 +4,11 @@ import { Trans } from "react-i18next"; import { api } from "../../../lib/api"; import { useI18n } from "../../../lib/i18n"; 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) { const { t } = useI18n(); - const { busy, error, run } = useAsync(); + const { busy, error, run } = useAction(); const [confirmText, setConfirmText] = useState(""); const canDelete = confirmText.trim().toUpperCase() === "DELETE"; diff --git a/src/renderer/src/features/account/sections/DisplayNameSection.tsx b/src/renderer/src/features/account/sections/DisplayNameSection.tsx index ca88072..dc8892e 100644 --- a/src/renderer/src/features/account/sections/DisplayNameSection.tsx +++ b/src/renderer/src/features/account/sections/DisplayNameSection.tsx @@ -11,7 +11,7 @@ import { addDays, daysSince, lastChangedLabel, - useAsync, + useAction, type SectionProps, } from "../ui"; @@ -20,7 +20,7 @@ export function DisplayNameSection({ settings, onChange }: SectionProps) { const [name, setName] = useState(settings.displayName); const [password, setPassword] = useState(""); const [confirmRevert, setConfirmRevert] = useState(false); - const { busy, error, ok, run } = useAsync(); + const { busy, error, ok, run } = useAction(); const cooldownDays = settings.supporter ? 30 : 90; const changedDaysAgo = daysSince(settings.displayNameChangedAt); diff --git a/src/renderer/src/features/account/sections/EmailSection.tsx b/src/renderer/src/features/account/sections/EmailSection.tsx index 32d831e..2248e58 100644 --- a/src/renderer/src/features/account/sections/EmailSection.tsx +++ b/src/renderer/src/features/account/sections/EmailSection.tsx @@ -2,13 +2,13 @@ import { useState } from "react"; import { api } from "../../../lib/api"; import { useI18n } from "../../../lib/i18n"; 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) { const { t } = useI18n(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const { busy, error, ok, run } = useAsync(); + const { busy, error, ok, run } = useAction(); async function submit() { await run(api.settings.email(email.trim(), password), { diff --git a/src/renderer/src/features/account/sections/PasswordSection.tsx b/src/renderer/src/features/account/sections/PasswordSection.tsx index 5eaaa37..6e9c46b 100644 --- a/src/renderer/src/features/account/sections/PasswordSection.tsx +++ b/src/renderer/src/features/account/sections/PasswordSection.tsx @@ -7,7 +7,7 @@ import { Notice, Section, WEBSITE_ACCOUNT, - useAsync, + useAction, type SectionProps, } from "../ui"; @@ -16,7 +16,7 @@ export function PasswordSection({ settings, onChange }: SectionProps) { const [current, setCurrent] = useState(""); const [next, setNext] = 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 valid = current.length > 0 && next.length >= 8 && next === confirm; diff --git a/src/renderer/src/features/account/sections/PrivacySection.tsx b/src/renderer/src/features/account/sections/PrivacySection.tsx index 3e22bab..b5b53e2 100644 --- a/src/renderer/src/features/account/sections/PrivacySection.tsx +++ b/src/renderer/src/features/account/sections/PrivacySection.tsx @@ -1,10 +1,10 @@ import { api } from "../../../lib/api"; 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) { const { t } = useI18n(); - const { busy, error, run } = useAsync(); + const { busy, error, run } = useAction(); function setShared(show: boolean) { void run(api.settings.privacy({ sharedConnectionsHidden: !show }), { onOk: onChange }); diff --git a/src/renderer/src/features/account/sections/TwoFactorSection.tsx b/src/renderer/src/features/account/sections/TwoFactorSection.tsx index f0661e4..0d5408c 100644 --- a/src/renderer/src/features/account/sections/TwoFactorSection.tsx +++ b/src/renderer/src/features/account/sections/TwoFactorSection.tsx @@ -6,7 +6,7 @@ import { useI18n } from "../../../lib/i18n"; import { Badge, Button, Field, Modal } from "../../../components/ui"; import { useStepUp } from "../../auth/useStepUp"; 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) { const { t } = useI18n(); @@ -16,8 +16,8 @@ export function TwoFactorSection({ settings, onChange }: SectionProps) { } | null>(null); const [code, setCode] = useState(""); const [codes, setCodes] = useState(null); - const setup = useAsync(); - const verify = useAsync(); + const setup = useAction(); + const verify = useAction(); const [confirmDisable, setConfirmDisable] = useState(false); const [disabledOk, setDisabledOk] = useState(false); const stepUp = useStepUp(); diff --git a/src/renderer/src/features/account/sections/UserDataSection.tsx b/src/renderer/src/features/account/sections/UserDataSection.tsx index a456d70..ae8dd88 100644 --- a/src/renderer/src/features/account/sections/UserDataSection.tsx +++ b/src/renderer/src/features/account/sections/UserDataSection.tsx @@ -2,11 +2,11 @@ import { useState } from "react"; import { api } from "../../../lib/api"; import { useI18n } from "../../../lib/i18n"; import { Button } from "../../../components/ui"; -import { Notice, Section, useAsync } from "../ui"; +import { Notice, Section, useAction } from "../ui"; export function UserDataSection() { const { t } = useI18n(); - const { busy, error, ok, run } = useAsync(); + const { busy, error, ok, run } = useAction(); const [armed, setArmed] = useState(false); async function reset() { diff --git a/src/renderer/src/features/account/ui.tsx b/src/renderer/src/features/account/ui.tsx index a507054..cb90342 100644 --- a/src/renderer/src/features/account/ui.tsx +++ b/src/renderer/src/features/account/ui.tsx @@ -47,7 +47,7 @@ export function Section({ ); } -export function useAsync() { +export function useAction() { const { t } = useI18n(); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); diff --git a/src/renderer/src/features/debug/DebugPanel.tsx b/src/renderer/src/features/debug/DebugPanel.tsx index 15b881f..3d43392 100644 --- a/src/renderer/src/features/debug/DebugPanel.tsx +++ b/src/renderer/src/features/debug/DebugPanel.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useSocial } from "../../store/social"; +import { useFriends } from "../../store/social"; import { useWorlds } from "../../store/worlds"; import { useGroups } from "../../store/groups"; import { useCopied, useDebug } from "./useDebug"; @@ -19,7 +19,7 @@ export function DebugPanel() { const { cache, stats, logs, ws, repoStats, invalidate, clear, clearLogs, clearWs } = useDebug(); const [tab, setTab] = useState("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 groupCount = useGroups((s) => Object.keys(s.groups).length); const repoCount = repoStats.reduce((sum, r) => sum + r.count, 0); diff --git a/src/renderer/src/features/friends/FriendsSidebar.tsx b/src/renderer/src/features/friends/FriendsSidebar.tsx index d04ab85..2b6184d 100644 --- a/src/renderer/src/features/friends/FriendsSidebar.tsx +++ b/src/renderer/src/features/friends/FriendsSidebar.tsx @@ -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 { isOnline, locationLabel, presenceOf } from "../../lib/vrchat"; 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 { useT } from "../../lib/i18n"; import { useUserMenu } from "./useUserMenu"; - -interface InstanceSection { - key: string; - worldId: string; - instanceId: string; - members: UserProfile[]; -} +import { useFriendsGrouped } from "./useFriendsGrouped"; interface ContextMenuState { x: number; @@ -27,55 +21,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) { const self = useSelf(); 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(); - 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 { online, offline, instances, alone } = useFriendsGrouped(friends, self); const [collapsed, setCollapsed] = useState>(() => new Set(["offline"])); const toggle = (id: string) => diff --git a/src/renderer/src/features/friends/useFriendsGrouped.ts b/src/renderer/src/features/friends/useFriendsGrouped.ts new file mode 100644 index 0000000..136cb5c --- /dev/null +++ b/src/renderer/src/features/friends/useFriendsGrouped.ts @@ -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(); + 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 }; +}