mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-14 03:10:01 +00:00
✨ feat: Manage world favorites
This commit is contained in:
@@ -164,15 +164,18 @@ export function toCurrentUserSummary(raw: RawUser): CurrentUserSummary {
|
||||
|
||||
type RawWorld = SdkWorld | LimitedWorld | FavoritedWorld;
|
||||
|
||||
// vrchat returns "???" for the name/author of a private world you can't read
|
||||
const unhide = (v: string | undefined): string => (v && v !== "???" ? v : "");
|
||||
|
||||
export function toWorld(raw: RawWorld): World {
|
||||
const platforms = raw.unityPackages ? platformsOf(raw.unityPackages) : undefined;
|
||||
const detailed = "visits" in raw;
|
||||
return {
|
||||
id: raw.id,
|
||||
detailed,
|
||||
name: raw.name,
|
||||
name: unhide(raw.name),
|
||||
authorId: raw.authorId ?? "",
|
||||
authorName: raw.authorName,
|
||||
authorName: unhide(raw.authorName),
|
||||
description: "description" in raw ? (raw.description ?? "") : "",
|
||||
imageUrl: raw.imageUrl ?? "",
|
||||
thumbnailImageUrl: raw.thumbnailImageUrl ?? "",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { VRChat } from "vrchat";
|
||||
import type { DiscoverCategory, FavoriteWorldFolder, World } from "../../shared/types/world";
|
||||
import type {
|
||||
DiscoverCategory,
|
||||
FavoriteGroupEdit,
|
||||
FavoriteLimits,
|
||||
FavoriteVisibility,
|
||||
FavoriteWorldFolder,
|
||||
MoveResult,
|
||||
World,
|
||||
} from "../../shared/types/world";
|
||||
import { httpStatusOf } from "./errors";
|
||||
import {
|
||||
getCategoryWorlds,
|
||||
@@ -9,17 +17,26 @@ import {
|
||||
} from "./rawEndpoints";
|
||||
import { toWorld } from "./mappers";
|
||||
import { cachedRead } from "./cachedRead";
|
||||
import { requireActiveClient } from "./client";
|
||||
import { userCache, currentUser } from "./userService";
|
||||
import { worldStore } from "../store/worldStore";
|
||||
import { worldFavoritesStore, type FavoriteGroupInput } from "../store/worldFavoritesStore";
|
||||
import { broadcast } from "../windows";
|
||||
import { cacheKeys, policies } from "../cache/policies";
|
||||
|
||||
export async function getWorld(worldId: string): Promise<World> {
|
||||
const world = await cachedRead(cacheKeys.world(worldId), policies.world, async (vrc) => {
|
||||
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
||||
return toWorld(data);
|
||||
});
|
||||
worldStore.addWorld(world);
|
||||
return world;
|
||||
try {
|
||||
const world = await cachedRead(cacheKeys.world(worldId), policies.world, async (vrc) => {
|
||||
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
||||
return toWorld(data);
|
||||
});
|
||||
worldStore.addWorld(world);
|
||||
return world;
|
||||
} catch (err) {
|
||||
const fallback = worldStore.get(worldId);
|
||||
if (fallback) return fallback;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
type CachedFavorites = { worlds: World[]; folders: FavoriteWorldFolder[] };
|
||||
@@ -156,6 +173,212 @@ async function loadCategory(
|
||||
return { id: cat.id, name: cat.name, worlds: raw.map(toWorld) };
|
||||
}
|
||||
|
||||
const DEFAULT_FOLDER = "worlds1";
|
||||
|
||||
export async function loadMyFavoriteWorlds(): Promise<void> {
|
||||
const me = await currentUser();
|
||||
const { groups, limits } = await cachedRead(
|
||||
cacheKeys.myFavoriteWorlds(),
|
||||
policies.favoriteWorlds,
|
||||
(vrc) => fetchMyFavorites(vrc, me.id),
|
||||
);
|
||||
worldFavoritesStore.setFavorites(groups, limits);
|
||||
void resolveHiddenFavorites(groups);
|
||||
}
|
||||
|
||||
// the favorites listing returns private worlds as "???"; the detail read fills them in, or 404s if deleted
|
||||
async function resolveHiddenFavorites(groups: FavoriteGroupInput[]): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
const seen = new Set<string>();
|
||||
for (const group of groups) {
|
||||
for (const world of group.worlds) {
|
||||
if (world.name || seen.has(world.id)) continue;
|
||||
seen.add(world.id);
|
||||
try {
|
||||
const { data } = await vrc.getWorld({ path: { worldId: world.id }, throwOnError: true });
|
||||
worldStore.addWorld(toWorld(data));
|
||||
} catch (err) {
|
||||
if (httpStatusOf(err) === 404) worldStore.addWorld({ ...world, deleted: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMyFavorites(
|
||||
vrc: VRChat,
|
||||
userId: string,
|
||||
): Promise<{ groups: FavoriteGroupInput[]; limits: FavoriteLimits }> {
|
||||
const [{ data: rawGroups }, limits] = await Promise.all([
|
||||
vrc.getFavoriteGroups({ query: { ownerId: userId, n: 100 }, throwOnError: true }),
|
||||
fetchFavoriteLimits(vrc),
|
||||
]);
|
||||
const worldGroups = rawGroups.filter((g) => isWorldGroupType(g.type));
|
||||
|
||||
const groups: FavoriteGroupInput[] = [];
|
||||
for (const group of worldGroups) {
|
||||
let worlds: World[] = [];
|
||||
try {
|
||||
const raw = await getFavoriteGroupWorlds(
|
||||
vrc,
|
||||
group.type as WorldFavoriteGroupType,
|
||||
group.name,
|
||||
userId,
|
||||
);
|
||||
worlds = raw.map(toWorld);
|
||||
} catch (err) {
|
||||
if (!isPrivateFavorites(err)) throw err;
|
||||
}
|
||||
groups.push({
|
||||
name: group.name,
|
||||
displayName: group.displayName || prettyFolderName(group.name),
|
||||
visibility: normalizeVisibility(group.visibility),
|
||||
worlds,
|
||||
});
|
||||
}
|
||||
return { groups, limits };
|
||||
}
|
||||
|
||||
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
|
||||
const { data } = await vrc.getFavoriteLimits({ throwOnError: true });
|
||||
return {
|
||||
maxGroups: data.maxFavoriteGroups?.world ?? data.defaultMaxFavoriteGroups,
|
||||
maxPerGroup: data.maxFavoritesPerGroup?.world ?? data.defaultMaxFavoritesPerGroup,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVisibility(v: string): FavoriteVisibility {
|
||||
return v === "friends" || v === "public" ? v : "private";
|
||||
}
|
||||
|
||||
export async function favoriteWorld(worldId: string, folder = DEFAULT_FOLDER): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
await vrc.addFavorite({
|
||||
body: { type: "world", favoriteId: worldId, tags: [folder] },
|
||||
throwOnError: true,
|
||||
});
|
||||
await reloadMyFavorites();
|
||||
}
|
||||
|
||||
export async function unfavoriteWorld(worldId: string): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
const fav = await findFavoriteRecord(vrc, worldId);
|
||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||
await reloadMyFavorites();
|
||||
}
|
||||
|
||||
export async function moveWorldToFolder(worldId: string, folder: string): Promise<MoveResult> {
|
||||
const vrc = requireActiveClient();
|
||||
const fav = await findFavoriteRecord(vrc, worldId);
|
||||
if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] };
|
||||
if (!(await canRefavorite(vrc, worldId))) return { moved: 0, skipped: [worldId] };
|
||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||
await vrc.addFavorite({
|
||||
body: { type: "world", favoriteId: worldId, tags: [folder] },
|
||||
throwOnError: true,
|
||||
});
|
||||
await reloadMyFavorites();
|
||||
return { moved: 1, skipped: [] };
|
||||
}
|
||||
|
||||
export async function unfavoriteWorlds(worldIds: string[]): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
const records = await favoriteRecords(vrc);
|
||||
for (const id of worldIds) {
|
||||
const recordId = records.get(id);
|
||||
if (recordId) await vrc.removeFavorite({ path: { favoriteId: recordId }, throwOnError: true });
|
||||
}
|
||||
await reloadMyFavorites();
|
||||
}
|
||||
|
||||
export async function moveWorldsToFolder(
|
||||
worldIds: string[],
|
||||
folder: string,
|
||||
): Promise<MoveResult> {
|
||||
const vrc = requireActiveClient();
|
||||
const records = await favoriteRecords(vrc);
|
||||
const skipped: string[] = [];
|
||||
let moved = 0;
|
||||
for (const id of worldIds) {
|
||||
if (!(await canRefavorite(vrc, id))) {
|
||||
skipped.push(id);
|
||||
continue;
|
||||
}
|
||||
const recordId = records.get(id);
|
||||
if (recordId) await vrc.removeFavorite({ path: { favoriteId: recordId }, throwOnError: true });
|
||||
await vrc.addFavorite({
|
||||
body: { type: "world", favoriteId: id, tags: [folder] },
|
||||
throwOnError: true,
|
||||
});
|
||||
moved++;
|
||||
}
|
||||
await reloadMyFavorites();
|
||||
return { moved, skipped };
|
||||
}
|
||||
|
||||
export async function clearFavoriteWorldFolder(folder: string): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
const me = await currentUser();
|
||||
await vrc.clearFavoriteGroup({
|
||||
path: { favoriteGroupType: "world", favoriteGroupName: folder, userId: me.id },
|
||||
throwOnError: true,
|
||||
});
|
||||
await reloadMyFavorites();
|
||||
}
|
||||
|
||||
export async function updateFavoriteWorldFolder(
|
||||
folder: string,
|
||||
edit: FavoriteGroupEdit,
|
||||
): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
const me = await currentUser();
|
||||
await vrc.updateFavoriteGroup({
|
||||
path: { favoriteGroupType: "world", favoriteGroupName: folder, userId: me.id },
|
||||
body: {
|
||||
displayName: edit.displayName,
|
||||
visibility: edit.visibility as never,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
await reloadMyFavorites();
|
||||
}
|
||||
|
||||
async function canRefavorite(vrc: VRChat, worldId: string): Promise<boolean> {
|
||||
try {
|
||||
await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findFavoriteRecord(vrc: VRChat, worldId: string) {
|
||||
return (await favoriteRecordEntries(vrc)).find((f) => f.favoriteId === worldId);
|
||||
}
|
||||
|
||||
async function favoriteRecords(vrc: VRChat): Promise<Map<string, string>> {
|
||||
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f.id]));
|
||||
}
|
||||
|
||||
async function favoriteRecordEntries(vrc: VRChat) {
|
||||
const pageSize = 100;
|
||||
const entries = [];
|
||||
for (let offset = 0; ; offset += pageSize) {
|
||||
const { data } = await vrc.getFavorites({
|
||||
query: { type: "world", n: pageSize, offset },
|
||||
throwOnError: true,
|
||||
});
|
||||
entries.push(...data);
|
||||
if (data.length < pageSize) return entries;
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadMyFavorites(): Promise<void> {
|
||||
userCache.invalidate(cacheKeys.myFavoriteWorlds());
|
||||
const me = await currentUser();
|
||||
userCache.invalidate(cacheKeys.favoriteWorlds(me.id));
|
||||
await loadMyFavoriteWorlds();
|
||||
}
|
||||
|
||||
export async function getUserWorlds(userId: string, isSelf: boolean): Promise<World[]> {
|
||||
const worlds = await cachedRead(
|
||||
cacheKeys.userWorlds(userId),
|
||||
|
||||
Reference in New Issue
Block a user