diff --git a/src/main/cache/cache.ts b/src/main/cache/cache.ts index d1044cd..2e775e1 100644 --- a/src/main/cache/cache.ts +++ b/src/main/cache/cache.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { writeFileAtomic, writeFileAtomicSync } from "../lib/atomicFile"; import type { CacheEntryInfo, CacheStats } from "../../shared/types/debug"; +import { httpStatusOf } from "../vrchat/errors"; interface Entry { value: T; @@ -31,14 +32,11 @@ function byteSize(value: unknown): number { } function retryAfterMs(err: unknown): number | null { + if (httpStatusOf(err) !== 429) return null; const e = (err ?? {}) as { - status?: number; - statusCode?: number; - response?: { status?: number; headers?: Record }; + response?: { headers?: Record }; headers?: Record; }; - const status = e.status ?? e.statusCode ?? e.response?.status; - if (status !== 429) return null; const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"]; const secs = raw != null ? Number(raw) : NaN; return Number.isFinite(secs) ? secs * 1000 : 8000; diff --git a/src/main/vrchat/mappers.ts b/src/main/vrchat/mappers.ts index a327473..08a2489 100644 --- a/src/main/vrchat/mappers.ts +++ b/src/main/vrchat/mappers.ts @@ -92,10 +92,10 @@ function normalizeStatus(status?: string): UserStatus { } } -function toIso(v?: string | Date | null): string | undefined { +export function toIso(v?: string | Date | null): string | undefined { if (!v) return undefined; - const s = v instanceof Date ? v.toISOString() : v; - return s === "" ? undefined : s; + const date = v instanceof Date ? v : new Date(v); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); } export function toUserProfile(raw: RawUser, selfId: string): UserProfile { diff --git a/src/main/vrchat/settingsService.ts b/src/main/vrchat/settingsService.ts index ea531a1..e2ab705 100644 --- a/src/main/vrchat/settingsService.ts +++ b/src/main/vrchat/settingsService.ts @@ -8,6 +8,7 @@ import type { import type { TwoFactorMethod } from "../../shared/types/auth"; import type { UserStatus } from "../../shared/types/user"; import { requireActiveClient } from "./client"; +import { toIso } from "./mappers"; import { userCache } from "./userService"; import { cacheKeys } from "../cache/policies"; import { entityStore } from "../store/entityStore"; @@ -20,12 +21,6 @@ const CONTENT_FILTER_KEYS: ContentFilterKey[] = [ "content_horror", ]; -function toIso(d?: Date | string | null): string | undefined { - if (!d) return undefined; - const date = typeof d === "string" ? new Date(d) : d; - return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); -} - function toSettings(u: CurrentUser): AccountSettings { const filters = (u.contentFilters ?? []).filter((t): t is ContentFilterKey => (CONTENT_FILTER_KEYS as string[]).includes(t), diff --git a/src/main/vrchat/userService.ts b/src/main/vrchat/userService.ts index 4732b14..1ca9134 100644 --- a/src/main/vrchat/userService.ts +++ b/src/main/vrchat/userService.ts @@ -49,10 +49,13 @@ export async function getUser(userId: string): Promise { export async function getUserByName(username: string): Promise { const vrc = requireActiveClient(); const self = await selfId(); - return userCache.get(cacheKeys.userByName(username), policies.user, async () => { + const key = cacheKeys.userByName(username); + const profile = await userCache.get(key, policies.user, async () => { const { data } = await vrc.getUserByName({ path: { username }, throwOnError: true }); return toUserProfile(data, self); }); + refreshStore(profile, key); + return profile; } export async function searchUsers(query: string): Promise { diff --git a/src/renderer/src/components/ui/CardGrid.tsx b/src/renderer/src/components/ui/CardGrid.tsx new file mode 100644 index 0000000..a247c4f --- /dev/null +++ b/src/renderer/src/components/ui/CardGrid.tsx @@ -0,0 +1,9 @@ +export function CardGrid({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; +} diff --git a/src/renderer/src/components/ui/IconLabel.tsx b/src/renderer/src/components/ui/IconLabel.tsx new file mode 100644 index 0000000..44f74ef --- /dev/null +++ b/src/renderer/src/components/ui/IconLabel.tsx @@ -0,0 +1,22 @@ +export function IconLabel({ + icon, + children, + gap = "gap-1", + title, + className = "", + style, +}: { + icon: React.ReactNode; + children: React.ReactNode; + gap?: string; + title?: string; + className?: string; + style?: React.CSSProperties; +}) { + return ( + + {icon} + {children} + + ); +} diff --git a/src/renderer/src/components/ui/index.ts b/src/renderer/src/components/ui/index.ts index 5b132f3..f6ba166 100644 --- a/src/renderer/src/components/ui/index.ts +++ b/src/renderer/src/components/ui/index.ts @@ -17,6 +17,8 @@ export { Toggle } from "./Toggle"; export { Section, Fact } from "./Section"; export { StatTile } from "./StatTile"; export { SkeletonGrid } from "./SkeletonGrid"; +export { CardGrid } from "./CardGrid"; +export { IconLabel } from "./IconLabel"; export { Skeleton } from "./Skeleton"; export { LinkPill } from "./LinkPill"; export { CodeBlock } from "./CodeBlock"; diff --git a/src/renderer/src/features/debug/ui.tsx b/src/renderer/src/features/debug/ui.tsx index 37f580a..d884801 100644 --- a/src/renderer/src/features/debug/ui.tsx +++ b/src/renderer/src/features/debug/ui.tsx @@ -1,6 +1,8 @@ import { X } from "lucide-react"; import type { CacheEntryInfo, CacheStatus } from "../../../../shared/types/debug"; +export { formatBytes } from "../../lib/format"; + export const statusTone = { fresh: "success", stale: "warn", @@ -44,12 +46,6 @@ export function cacheStatus(e: CacheEntryInfo, now: number): CacheStatus { return now < e.expiresAt ? "fresh" : now < e.hardExpiresAt ? "stale" : "expired"; } -export function formatBytes(n: number): string { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - return `${(n / (1024 * 1024)).toFixed(1)} MB`; -} - export function formatDuration(s: number): string { if (s < 60) return `${s}s`; if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; diff --git a/src/renderer/src/features/gallery/format.ts b/src/renderer/src/features/gallery/format.ts index 91e4745..f8b05fb 100644 --- a/src/renderer/src/features/gallery/format.ts +++ b/src/renderer/src/features/gallery/format.ts @@ -1,14 +1,4 @@ -export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - const units = ["KB", "MB", "GB"]; - let n = bytes / 1024; - let i = 0; - while (n >= 1024 && i < units.length - 1) { - n /= 1024; - i++; - } - return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`; -} +export { formatBytes } from "../../lib/format"; export function formatBucket(bucket: string, locale?: string): string { const m = /^(\d{4})-(\d{2})$/.exec(bucket); diff --git a/src/renderer/src/features/profile/LocationSection.tsx b/src/renderer/src/features/profile/LocationSection.tsx index 8cdc347..683ddfa 100644 --- a/src/renderer/src/features/profile/LocationSection.tsx +++ b/src/renderer/src/features/profile/LocationSection.tsx @@ -1,6 +1,6 @@ import { ChevronRight, Globe, Hash, MapPin, Users } from "lucide-react"; import { parseLocation, type Location } from "../../../../shared/types/user"; -import { HoverImage } from "../../components/ui"; +import { HoverImage, IconLabel } from "../../components/ui"; import { useWorld } from "../../store/worlds"; import { useNav } from "../navigation/NavContext"; import { api } from "../../lib/api"; @@ -54,36 +54,34 @@ export function LocationSection({ location }: { location?: Location }) { {t("profile:location.by", { author: world.authorName })} - - + } title={t("profile:location.instance")}> {parsed.instanceId} - + {region ? ( - - {flag ? ( - {flag} - ) : ( - - )}{" "} + {flag} + ) : ( + + ) + } + > + {" "} {region} - + ) : null} {inInstance ? ( - - {" "} + } style={{ color: "var(--status-active)" }}> + {" "} {t("profile:location.inInstance", { n: inInstance.userCount })} - + ) : null} {world.occupants > 0 ? ( - - {t("profile:location.inWorld", { n: world.occupants })} - + }> + {" "} + {t("profile:location.inWorld", { n: world.occupants })} + ) : null} diff --git a/src/renderer/src/features/profile/WorldsSection.tsx b/src/renderer/src/features/profile/WorldsSection.tsx index ae9930c..deb05fc 100644 --- a/src/renderer/src/features/profile/WorldsSection.tsx +++ b/src/renderer/src/features/profile/WorldsSection.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import type { World } from "../../../../shared/types/world"; -import { CollapsibleCard, Field, SkeletonGrid } from "../../components/ui"; +import { CardGrid, CollapsibleCard, Field, SkeletonGrid } from "../../components/ui"; import { useT } from "../../lib/i18n"; import { WorldCard } from "../world/WorldCard"; import { useUserWorlds } from "./useUserWorlds"; @@ -73,11 +73,11 @@ export function FavoriteWorldsSection({
{shown.map((folder) => ( -
+ {folder.worlds.map((w) => ( ))} -
+
))} {loading ? : null} @@ -99,11 +99,11 @@ function WorldGrid({ if (worlds.length) { return ( -
+ {worlds.map((w) => ( ))} -
+
); } diff --git a/src/renderer/src/features/search/SearchView.tsx b/src/renderer/src/features/search/SearchView.tsx index 2cac459..e900343 100644 --- a/src/renderer/src/features/search/SearchView.tsx +++ b/src/renderer/src/features/search/SearchView.tsx @@ -3,7 +3,7 @@ import { Search as SearchIcon } from "lucide-react"; import type { UserProfile } from "../../../../shared/types/user"; import type { World } from "../../../../shared/types/world"; import { api } from "../../lib/api"; -import { Avatar, Button, ContextMenu, Field, Tabs, Tag } from "../../components/ui"; +import { Avatar, Button, CardGrid, ContextMenu, Field, Tabs, Tag } from "../../components/ui"; import { useT } from "../../lib/i18n"; import { useNav } from "../navigation/NavContext"; import { useUserMenu } from "../friends/useUserMenu"; @@ -106,11 +106,11 @@ export function SearchView() { ))}
) : ( -
+ {(results as World[]).map((w) => ( openWorld(w.id)} /> ))} -
+ )} diff --git a/src/renderer/src/features/world/WorldCard.tsx b/src/renderer/src/features/world/WorldCard.tsx index 54168ba..dcd0e1c 100644 --- a/src/renderer/src/features/world/WorldCard.tsx +++ b/src/renderer/src/features/world/WorldCard.tsx @@ -1,6 +1,6 @@ import { Circle, Star, Users } from "lucide-react"; import type { World } from "../../../../shared/types/world"; -import { Card, HoverImage, Tag } from "../../components/ui"; +import { Card, HoverImage, IconLabel, Tag } from "../../components/ui"; import { compactNumber } from "../../lib/format"; import { useT } from "../../lib/i18n"; import { useNav } from "../navigation/NavContext"; @@ -37,26 +37,29 @@ export function WorldCard({ ) : null}
- - {compactNumber(world.favorites)} - + } title={t("profile:worlds.tip.favorites")}> + {" "} + {compactNumber(world.favorites)} + {world.occupants > 0 ? ( - } title={t("profile:worlds.tip.online")} style={{ color: "var(--status-active)" }} > - {compactNumber(world.occupants)} - + {" "} + {compactNumber(world.occupants)} + ) : null} {world.visits > 0 ? ( {t("profile:worlds.visitsCount", { formattedCount: compactNumber(world.visits) })} ) : null} - - {world.capacity} - + } title={t("profile:worlds.tip.capacity")}> + {" "} + {world.capacity} +
diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index fe3dd2d..f300b24 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -1,3 +1,15 @@ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB"]; + let n = bytes / 1024; + let i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i++; + } + return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`; +} + export function compactNumber(n: number): string { if (n < 1000) return `${n}`; if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`; diff --git a/src/shared/types/auth.ts b/src/shared/types/auth.ts index 82bf525..efdee2f 100644 --- a/src/shared/types/auth.ts +++ b/src/shared/types/auth.ts @@ -5,19 +5,16 @@ export type AuthStatus = | { state: "awaiting2fa"; methods: TwoFactorMethod[] } | { state: "authenticated"; user: CurrentUserSummary }; -export interface CurrentUserSummary { - id: string; - displayName: string; - userIcon: string; - currentAvatarThumbnailImageUrl: string; -} - export interface Account { id: string; displayName: string; userIcon: string; } +export interface CurrentUserSummary extends Account { + currentAvatarThumbnailImageUrl: string; +} + export interface AccountsState { accounts: Account[]; activeId: string | null;