mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-14 03:10:01 +00:00
✨ feat: Avatars tab
This commit is contained in:
@@ -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<Avatar> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const folders = await cachedRead(
|
||||
cacheKeys.avatarFavorites(),
|
||||
policies.avatarFavorites,
|
||||
fetchFavoriteFolders,
|
||||
);
|
||||
avatarStore.setFavorites(folders);
|
||||
}
|
||||
|
||||
async function fetchFavoriteFolders(vrc: VRChat): Promise<FavoriteFolder[]> {
|
||||
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<void> {
|
||||
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<Avatar> {
|
||||
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<Avatar[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar));
|
||||
avatarStore.setFavorites(await fetchFavoriteFolders(vrc));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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<Avatar> {
|
||||
const { data } = await vrc.client.get({ url: `/avatars/${avatarId}`, throwOnError: true });
|
||||
return data as Avatar;
|
||||
}
|
||||
|
||||
export async function getMyAvatarsRaw(vrc: VRChat): Promise<Avatar[]> {
|
||||
const { data } = await vrc.client.get<Avatar[], unknown, true>({
|
||||
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<Avatar[]> {
|
||||
const { data } = await vrc.client.get<Avatar[], unknown, true>({
|
||||
url: "/avatars/favorites",
|
||||
query: { n: 100, tag: group },
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
interface FavoriteGroupItem {
|
||||
favoriteId: string;
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user