diff --git a/src/main/cache/policies.ts b/src/main/cache/policies.ts index 3f9af5e..d450df7 100644 --- a/src/main/cache/policies.ts +++ b/src/main/cache/policies.ts @@ -12,6 +12,7 @@ export const policies = { world: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 }, avatar: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 }, avatarFavorites: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, + avatarMine: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, userGroups: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, representedGroup: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, group: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 }, @@ -33,6 +34,7 @@ export const cacheKeys = { world: (id: string) => `world:${id}`, avatar: (id: string) => `avatar:${id}`, avatarFavorites: () => "avatar:favorites", + avatarMine: () => "avatar:mine", userGroups: (id: string) => `user:groups:${id}`, representedGroup: (id: string) => `user:group:represented:${id}`, group: (id: string) => `group:${id}`, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 4233b16..778bec0 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -61,7 +61,14 @@ const handlers = { guard(() => instances.inviteSelf(worldId, instanceId)), "avatar:get": (avatarId) => guard(() => avatars.getAvatar(avatarId)), - "avatar:favorites": () => guard(() => avatars.getFavoritedAvatars()), + "avatar:snapshot": () => guard(async () => avatars.avatarSnapshot()), + "avatar:loadMine": () => guard(() => avatars.loadMyAvatars()), + "avatar:loadFavorites": () => guard(() => avatars.loadFavoritedAvatars()), + "avatar:select": (avatarId) => guard(() => avatars.selectAvatar(avatarId)), + "avatar:update": ({ avatarId, edit }) => guard(() => avatars.updateAvatar(avatarId, edit)), + "avatar:delete": (avatarId) => guard(() => avatars.deleteAvatar(avatarId)), + "avatar:setFavorited": ({ avatarId, favorited }) => + guard(() => avatars.setAvatarFavorited(avatarId, favorited)), "group:byUser": (userId) => guard(() => groups.getUserGroups(userId)), "group:represented": (userId) => guard(() => groups.getRepresentedGroup(userId)), diff --git a/src/main/store/avatarStore.ts b/src/main/store/avatarStore.ts new file mode 100644 index 0000000..5c97b60 --- /dev/null +++ b/src/main/store/avatarStore.ts @@ -0,0 +1,84 @@ +import type { Avatar, AvatarSnapshot, FavoriteAvatarFolder } from "../../shared/types/avatar"; +import type { FieldSource } from "../../shared/types/repository"; +import { repos } from "./repository/manager"; + +export type { AvatarSnapshot }; + +type Change = { type: "seed"; snapshot: AvatarSnapshot } | { type: "upsert"; avatar: Avatar }; +type Listener = (change: Change) => void; + +class AvatarStore { + private readonly listeners = new Set(); + private mineIds = new Set(); + private favorites: FavoriteAvatarFolder[] = []; + private wired = false; + + onChange(fn: Listener): () => void { + this.wire(); + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + private wire(): void { + if (this.wired || !repos.hasActive) return; + this.wired = true; + repos.active.avatars.onChange((c) => { + this.emit({ type: "upsert", avatar: c.entity }); + }); + } + + setMine(avatars: Avatar[]): void { + this.mineIds = new Set(avatars.map((a) => a.id)); + repos.active.avatars.upsertMany(avatars, "rest:list"); + this.emit({ type: "seed", snapshot: this.snapshot() }); + } + + setFavorites(folders: { name: string; displayName: string; avatars: Avatar[] }[]): void { + this.favorites = folders.map((f) => ({ + name: f.name, + displayName: f.displayName, + avatarIds: f.avatars.map((a) => a.id), + })); + for (const f of folders) repos.active.avatars.upsertMany(f.avatars, "rest:list"); + this.emit({ type: "seed", snapshot: this.snapshot() }); + } + + addAvatar(avatar: Avatar, src: FieldSource = "rest:detail"): void { + repos.active.avatars.upsert(avatar, src); + } + + removeAvatar(avatarId: string): void { + this.mineIds.delete(avatarId); + this.favorites = this.favorites + .map((f) => ({ ...f, avatarIds: f.avatarIds.filter((id) => id !== avatarId) })) + .filter((f) => f.avatarIds.length); + repos.active.avatars.remove(avatarId); + this.emit({ type: "seed", snapshot: this.snapshot() }); + } + + get(avatarId: string): Avatar | undefined { + return repos.active.avatars.get(avatarId); + } + + snapshot(): AvatarSnapshot { + return { + avatars: repos.hasActive ? repos.active.avatars.all() : [], + mineIds: [...this.mineIds], + favorites: this.favorites, + }; + } + + reset(): void { + this.mineIds.clear(); + this.favorites = []; + this.wired = false; + this.wire(); + this.emit({ type: "seed", snapshot: this.snapshot() }); + } + + private emit(change: Change): void { + for (const fn of this.listeners) fn(change); + } +} + +export const avatarStore = new AvatarStore(); diff --git a/src/main/store/social.ts b/src/main/store/social.ts index fa90498..ea86fb4 100644 --- a/src/main/store/social.ts +++ b/src/main/store/social.ts @@ -9,6 +9,7 @@ import { activeId } from "../accounts/store"; import { entityStore, type SocialSnapshot } from "./entityStore"; import { worldStore } from "./worldStore"; import { groupStore } from "./groupStore"; +import { avatarStore } from "./avatarStore"; import { repos } from "./repository/manager"; import { broadcast } from "../windows"; import { logger } from "../debug/logger"; @@ -172,6 +173,7 @@ export async function seedActiveAccount(force = false): Promise { if (!id) { worldStore.reset(); groupStore.reset(); + avatarStore.reset(); entityStore.reset(); return; } @@ -212,4 +214,9 @@ export function startSocialBridge(): void { if (c.type === "seed") broadcast("group:seed", c.snapshot); else broadcast("group:upsert", c.group); }); + + avatarStore.onChange((c) => { + if (c.type === "seed") broadcast("avatar:seed", c.snapshot); + else broadcast("avatar:upsert", c.avatar); + }); } diff --git a/src/main/vrchat/avatarService.ts b/src/main/vrchat/avatarService.ts index cdf635d..21d6e9b 100644 --- a/src/main/vrchat/avatarService.ts +++ b/src/main/vrchat/avatarService.ts @@ -1,27 +1,149 @@ -import type { Avatar } from "../../shared/types/avatar"; +import type { VRChat } from "vrchat"; +import type { Avatar, AvatarEdit, AvatarSnapshot } from "../../shared/types/avatar"; import { toAvatar } from "./mappers"; +import { httpStatusOf } from "./errors"; import { cachedRead } from "./cachedRead"; -import { repos } from "../store/repository/manager"; +import { requireActiveClient } from "./client"; +import { userCache } from "./userService"; +import { entityStore } from "../store/entityStore"; +import { avatarStore } from "../store/avatarStore"; import { cacheKeys, policies } from "../cache/policies"; +import { getAvatarRaw, getMyAvatarsRaw, getFavoritedAvatarsRaw } from "./rawEndpoints"; + +type FavoriteFolder = { name: string; displayName: string; avatars: Avatar[] }; export async function getAvatar(avatarId: string): Promise { - const avatar = await cachedRead(cacheKeys.avatar(avatarId), policies.avatar, async (vrc) => { - const { data } = await vrc.getAvatar({ path: { avatarId }, throwOnError: true }); - return toAvatar(data); + try { + const avatar = await cachedRead(cacheKeys.avatar(avatarId), policies.avatar, async (vrc) => { + return toAvatar(await getAvatarRaw(vrc, avatarId)); + }); + avatarStore.addAvatar(avatar); + return avatar; + } catch (err) { + const fallback = avatarStore.get(avatarId); + if (fallback) return fallback; + throw err; + } +} + +export function avatarSnapshot(): AvatarSnapshot { + return avatarStore.snapshot(); +} + +export async function loadMyAvatars(): Promise { + const avatars = await cachedRead(cacheKeys.avatarMine(), policies.avatarMine, async (vrc) => { + const data = await getMyAvatarsRaw(vrc); + return data.map(toAvatar); }); - repos.active.avatars.upsert(avatar, "rest:detail"); + avatarStore.setMine(avatars); +} + +export async function loadFavoritedAvatars(): Promise { + const folders = await cachedRead( + cacheKeys.avatarFavorites(), + policies.avatarFavorites, + fetchFavoriteFolders, + ); + avatarStore.setFavorites(folders); +} + +async function fetchFavoriteFolders(vrc: VRChat): Promise { + const { data: groups } = await vrc.getFavoriteGroups({ + query: { n: 100 }, + throwOnError: true, + }); + const avatarGroups = groups.filter((g) => g.type === "avatar"); + + const folders: FavoriteFolder[] = []; + for (const group of avatarGroups) { + let raw; + try { + raw = await getFavoritedAvatarsRaw(vrc, group.name); + } catch (err) { + if (httpStatusOf(err) === 401 || httpStatusOf(err) === 403) continue; + throw err; + } + if (!raw.length) continue; + folders.push({ + name: group.name, + displayName: group.displayName || prettyFolderName(group.name), + avatars: raw.map(toAvatar), + }); + } + return folders; +} + +function prettyFolderName(key: string): string { + const m = /^avatars(\d+)$/.exec(key); + if (m) return `Group ${m[1]}`; + return key.charAt(0).toUpperCase() + key.slice(1); +} + +export async function selectAvatar(avatarId: string): Promise { + const vrc = requireActiveClient(); + const { data } = await vrc.selectAvatar({ path: { avatarId }, throwOnError: true }); + userCache.invalidate(cacheKeys.currentUser()); + entityStore.upsertFrom( + { + id: data.id, + currentAvatarId: data.currentAvatar, + currentAvatarImageUrl: data.currentAvatarImageUrl, + currentAvatarThumbnailImageUrl: data.currentAvatarThumbnailImageUrl, + }, + "rest:detail", + Date.now(), + ); +} + +export async function updateAvatar(avatarId: string, edit: AvatarEdit): Promise { + const vrc = requireActiveClient(); + const { data } = await vrc.updateAvatar({ + path: { avatarId }, + body: { + name: edit.name, + description: edit.description, + releaseStatus: edit.releaseStatus as never, + }, + throwOnError: true, + }); + const avatar = toAvatar(data); + invalidateAvatar(avatarId); + avatarStore.addAvatar(avatar); + await refreshLists(vrc); return avatar; } -export async function getFavoritedAvatars(): Promise { - const avatars = await cachedRead( - cacheKeys.avatarFavorites(), - policies.avatarFavorites, - async (vrc) => { - const { data } = await vrc.getFavoritedAvatars({ query: { n: 100 }, throwOnError: true }); - return data.map(toAvatar); - }, - ); - repos.active.avatars.upsertMany(avatars, "rest:list"); - return avatars; +export async function deleteAvatar(avatarId: string): Promise { + const vrc = requireActiveClient(); + await vrc.deleteAvatar({ path: { avatarId }, throwOnError: true }); + invalidateAvatar(avatarId); + avatarStore.removeAvatar(avatarId); + await refreshLists(vrc); +} + +export async function setAvatarFavorited(avatarId: string, favorited: boolean): Promise { + const vrc = requireActiveClient(); + if (favorited) { + await vrc.addFavorite({ + body: { type: "avatar", favoriteId: avatarId, tags: ["avatars1"] }, + throwOnError: true, + }); + } else { + const { data } = await vrc.getFavorites({ query: { type: "avatar", n: 100 }, throwOnError: true }); + const fav = data.find((f) => f.favoriteId === avatarId); + if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); + } + userCache.invalidate(cacheKeys.avatarFavorites()); + await loadFavoritedAvatars(); +} + +function invalidateAvatar(avatarId: string): void { + userCache.invalidate(cacheKeys.avatar(avatarId)); + userCache.invalidate(cacheKeys.avatarMine()); + userCache.invalidate(cacheKeys.avatarFavorites()); +} + +async function refreshLists(vrc: VRChat): Promise { + avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar)); + avatarStore.setFavorites(await fetchFavoriteFolders(vrc)); } diff --git a/src/main/vrchat/mappers.ts b/src/main/vrchat/mappers.ts index 08a2489..68ff537 100644 --- a/src/main/vrchat/mappers.ts +++ b/src/main/vrchat/mappers.ts @@ -28,6 +28,7 @@ interface RawUser { userIcon?: string; profilePicOverride?: string; profilePicOverrideThumbnail?: string; + currentAvatar?: string; currentAvatarImageUrl?: string; currentAvatarThumbnailImageUrl?: string; currentAvatarTags?: string[]; @@ -113,6 +114,7 @@ export function toUserProfile(raw: RawUser, selfId: string): UserProfile { userIcon: raw.userIcon ?? "", profilePicOverride: raw.profilePicOverride ?? "", profilePicOverrideThumbnail: raw.profilePicOverrideThumbnail ?? "", + currentAvatarId: raw.currentAvatar, currentAvatarImageUrl: raw.currentAvatarImageUrl ?? "", currentAvatarThumbnailImageUrl: raw.currentAvatarThumbnailImageUrl ?? "", currentAvatarTags: raw.currentAvatarTags ?? [], @@ -280,10 +282,20 @@ interface RawAvatar { releaseStatus?: string; tags?: string[]; favorites?: number; + featured?: boolean; + performance?: { standalonewindows?: string; android?: string }; created_at?: string | Date; updated_at?: string | Date; } +function hasBuild(rating?: string): boolean { + return Boolean(rating) && rating !== "None"; +} + +function ratingOf(rating?: string): string | undefined { + return hasBuild(rating) ? rating : undefined; +} + export function toAvatar(raw: RawAvatar): Avatar { return { id: raw.id, @@ -296,6 +308,15 @@ export function toAvatar(raw: RawAvatar): Avatar { releaseStatus: raw.releaseStatus ?? "private", tags: raw.tags ?? [], favorites: raw.favorites ?? 0, + featured: raw.featured, + platforms: { + pc: hasBuild(raw.performance?.standalonewindows), + android: hasBuild(raw.performance?.android), + }, + performance: { + pc: ratingOf(raw.performance?.standalonewindows), + android: ratingOf(raw.performance?.android), + }, createdAt: toIso(raw.created_at), updatedAt: toIso(raw.updated_at), }; diff --git a/src/main/vrchat/rawEndpoints.ts b/src/main/vrchat/rawEndpoints.ts index 09b0734..931df36 100644 --- a/src/main/vrchat/rawEndpoints.ts +++ b/src/main/vrchat/rawEndpoints.ts @@ -1,7 +1,30 @@ -import type { VRChat, FavoritedWorld, LimitedWorld } from "vrchat"; +import type { VRChat, FavoritedWorld, LimitedWorld, Avatar } from "vrchat"; // VRChat web routes that are missing from the SDK. +export async function getAvatarRaw(vrc: VRChat, avatarId: string): Promise { + const { data } = await vrc.client.get({ url: `/avatars/${avatarId}`, throwOnError: true }); + return data as Avatar; +} + +export async function getMyAvatarsRaw(vrc: VRChat): Promise { + const { data } = await vrc.client.get({ + url: "/avatars", + query: { user: "me", releaseStatus: "all", sort: "updated", order: "descending", n: 100 }, + throwOnError: true, + }); + return data; +} + +export async function getFavoritedAvatarsRaw(vrc: VRChat, group?: string): Promise { + const { data } = await vrc.client.get({ + url: "/avatars/favorites", + query: { n: 100, tag: group }, + throwOnError: true, + }); + return data; +} + interface FavoriteGroupItem { favoriteId: string; id: string; diff --git a/src/renderer/src/components/AppShell.tsx b/src/renderer/src/components/AppShell.tsx index add1edc..b8ad345 100644 --- a/src/renderer/src/components/AppShell.tsx +++ b/src/renderer/src/components/AppShell.tsx @@ -7,6 +7,7 @@ import { ExternalLink, Images, Globe2, + Shirt, Search, Settings, SlidersHorizontal, @@ -16,6 +17,8 @@ import { import type { LucideIcon } from "lucide-react"; import { ProfileView } from "../features/profile/ProfileView"; import { MyWorldsView } from "../features/profile/MyWorldsView"; +import { AvatarsView } from "../features/avatar/AvatarsView"; +import { AvatarView } from "../features/avatar/AvatarView"; import { WorldView } from "../features/world/WorldView"; import { InstanceView } from "../features/world/InstanceView"; import { GroupView } from "../features/group/GroupView"; @@ -44,7 +47,7 @@ type NavItem = { id: string; label: string; icon: LucideIcon; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; kind?: View["kind"]; external?: boolean; divider?: boolean; @@ -94,6 +97,13 @@ function Shell() { divider: true, onClick: () => nav.openWorlds(), }, + { + id: "avatars", + label: t("nav:avatars"), + icon: Shirt, + kind: "avatars", + onClick: () => nav.openAvatars(), + }, { id: "account", label: t("nav:account"), @@ -114,13 +124,19 @@ function Shell() { label: t("nav:debug"), icon: Settings, external: true, - onClick: () => void api.debug.openWindow(), + onClick: (e) => { + if (e.ctrlKey || e.metaKey) void api.debug.cacheClear(); + else void api.debug.openWindow(); + }, }, ]; const openProfile = (id: "me" | string) => nav.openUser(id); const stageKey = - nav.current.kind === "user" || nav.current.kind === "world" || nav.current.kind === "group" + nav.current.kind === "user" || + nav.current.kind === "world" || + nav.current.kind === "avatar" || + nav.current.kind === "group" ? `${nav.current.kind}:${nav.current.id}` : nav.current.kind; @@ -154,8 +170,8 @@ function Shell() { {item.divider ?
: null} + + {isOwner ? ( + <> + + + + ) : null} +
+ {error ?

{error}

: null} + + {editOpen ? ( + setEditOpen(false)} /> + ) : null} + + setDeleteOpen(false)} + onDeleted={back} + /> + + ); +} + +function EditModal({ avatar, onClose }: { avatar: Avatar; onClose: () => void }) { + const t = useT(); + const [name, setName] = useState(avatar.name); + const [description, setDescription] = useState(avatar.description); + const [releaseStatus, setReleaseStatus] = useState(avatar.releaseStatus); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const save = async () => { + setBusy(true); + setError(null); + try { + await api.avatar.update(avatar.id, { name, description, releaseStatus }); + onClose(); + } catch (err) { + setError(errorMessage(err, t("avatar:actions.failed"))); + setBusy(false); + } + }; + + return ( + } + confirmLabel={t("avatar:actions.save")} + onConfirm={save} + confirmLoading={busy} + confirmDisabled={!name.trim()} + > +
+ setName(e.target.value)} + /> +