mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-14 03:10:01 +00:00
✨ feat: improve favorite bulk moves
This commit is contained in:
@@ -9,7 +9,7 @@ import type {
|
||||
MoveResult,
|
||||
} from "../../shared/types/avatar";
|
||||
import { toAvatar } from "./mappers";
|
||||
import { httpStatusOf } from "./errors";
|
||||
import { httpStatusOf, isTransientError } from "./errors";
|
||||
import { cachedRead } from "./cachedRead";
|
||||
import { requireActiveClient } from "./client";
|
||||
import { userCache, currentUser } from "./userService";
|
||||
@@ -70,7 +70,7 @@ async function fetchFavorites(vrc: VRChat): Promise<{
|
||||
]);
|
||||
const avatarGroups = groups.filter((g) => g.type === "avatar");
|
||||
|
||||
const folders: FavoriteFolder[] = [];
|
||||
const existing: FavoriteFolder[] = [];
|
||||
for (const group of avatarGroups) {
|
||||
let raw: Awaited<ReturnType<typeof getFavoritedAvatarsRaw>> = [];
|
||||
try {
|
||||
@@ -78,14 +78,37 @@ async function fetchFavorites(vrc: VRChat): Promise<{
|
||||
} catch (err) {
|
||||
if (httpStatusOf(err) !== 401 && httpStatusOf(err) !== 403) throw err;
|
||||
}
|
||||
folders.push({
|
||||
existing.push({
|
||||
name: group.name,
|
||||
displayName: group.displayName || prettyFolderName(group.name),
|
||||
visibility: normalizeVisibility(group.visibility),
|
||||
avatars: raw.map(toAvatar),
|
||||
});
|
||||
}
|
||||
return { folders, limits };
|
||||
return { folders: fillAvatarSlots(existing, limits.maxGroups), limits };
|
||||
}
|
||||
|
||||
function fillAvatarSlots(existing: FavoriteFolder[], max: number): FavoriteFolder[] {
|
||||
const out = orderSlots(existing, "avatars");
|
||||
const taken = new Set(out.map((f) => f.name));
|
||||
for (let i = 1; out.length < max && i <= max; i++) {
|
||||
const name = `avatars${i}`;
|
||||
if (taken.has(name)) continue;
|
||||
out.push({ name, displayName: prettyFolderName(name), visibility: "private", avatars: [] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function orderSlots<T extends { name: string }>(groups: T[], prefix: string): T[] {
|
||||
const slotNum = (name: string) => {
|
||||
const m = new RegExp(`^${prefix}(\\d+)$`).exec(name);
|
||||
return m ? Number(m[1]) : null;
|
||||
};
|
||||
const custom = groups.filter((g) => slotNum(g.name) === null);
|
||||
const numbered = groups
|
||||
.filter((g) => slotNum(g.name) !== null)
|
||||
.sort((a, b) => slotNum(a.name)! - slotNum(b.name)!);
|
||||
return [...custom, ...numbered];
|
||||
}
|
||||
|
||||
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
|
||||
@@ -164,17 +187,28 @@ export async function unfavoriteAvatar(avatarId: string): Promise<void> {
|
||||
await reloadFavorites();
|
||||
}
|
||||
|
||||
export async function moveAvatarToFolder(avatarId: string, folder: string): Promise<MoveResult> {
|
||||
export async function moveAvatarToFolder(
|
||||
avatarId: string,
|
||||
folder: string,
|
||||
reload = true,
|
||||
): Promise<MoveResult> {
|
||||
const vrc = requireActiveClient();
|
||||
const fav = await findFavoriteRecord(vrc, avatarId);
|
||||
if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] };
|
||||
if (!(await canRefavorite(vrc, avatarId))) return { moved: 0, skipped: [avatarId] };
|
||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||
await vrc.addFavorite({
|
||||
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
|
||||
throwOnError: true,
|
||||
});
|
||||
await reloadFavorites();
|
||||
try {
|
||||
await vrc.addFavorite({
|
||||
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
|
||||
throwOnError: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (fav) await restoreAvatarFavorite(vrc, fav);
|
||||
if (reload) await reloadFavorites();
|
||||
if (isTransientError(err)) throw err;
|
||||
return { moved: 0, skipped: [avatarId] };
|
||||
}
|
||||
if (reload) await reloadFavorites();
|
||||
return { moved: 1, skipped: [] };
|
||||
}
|
||||
|
||||
@@ -183,7 +217,7 @@ export async function unfavoriteAvatars(avatarIds: string[]): Promise<void> {
|
||||
const records = await favoriteRecords(vrc);
|
||||
for (const id of avatarIds) {
|
||||
const fav = records.get(id);
|
||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav }, throwOnError: true });
|
||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||
}
|
||||
await reloadFavorites();
|
||||
}
|
||||
@@ -202,11 +236,22 @@ export async function moveAvatarsToFolder(
|
||||
continue;
|
||||
}
|
||||
const fav = records.get(id);
|
||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav }, throwOnError: true });
|
||||
await vrc.addFavorite({
|
||||
body: { type: "avatar", favoriteId: id, tags: [folder] },
|
||||
throwOnError: true,
|
||||
});
|
||||
if (fav?.tags?.includes(folder)) continue;
|
||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||
try {
|
||||
await vrc.addFavorite({
|
||||
body: { type: "avatar", favoriteId: id, tags: [folder] },
|
||||
throwOnError: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (fav) await restoreAvatarFavorite(vrc, fav);
|
||||
if (isTransientError(err)) {
|
||||
await reloadFavorites();
|
||||
throw err;
|
||||
}
|
||||
skipped.push(id);
|
||||
continue;
|
||||
}
|
||||
moved++;
|
||||
}
|
||||
await reloadFavorites();
|
||||
@@ -217,7 +262,8 @@ async function canRefavorite(vrc: VRChat, avatarId: string): Promise<boolean> {
|
||||
try {
|
||||
await getAvatarRaw(vrc, avatarId);
|
||||
return true;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (isTransientError(err)) throw err;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -250,8 +296,18 @@ async function findFavoriteRecord(vrc: VRChat, avatarId: string) {
|
||||
return (await favoriteRecordEntries(vrc)).find((f) => f.favoriteId === avatarId);
|
||||
}
|
||||
|
||||
async function favoriteRecords(vrc: VRChat): Promise<Map<string, string>> {
|
||||
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f.id]));
|
||||
async function favoriteRecords(vrc: VRChat) {
|
||||
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f]));
|
||||
}
|
||||
|
||||
async function restoreAvatarFavorite(
|
||||
vrc: VRChat,
|
||||
fav: { favoriteId: string; tags: string[] },
|
||||
): Promise<void> {
|
||||
await vrc.addFavorite({
|
||||
body: { type: "avatar", favoriteId: fav.favoriteId, tags: fav.tags.length ? fav.tags : ["avatars1"] },
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function favoriteRecordEntries(vrc: VRChat) {
|
||||
@@ -267,7 +323,7 @@ async function favoriteRecordEntries(vrc: VRChat) {
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadFavorites(): Promise<void> {
|
||||
export async function reloadFavorites(): Promise<void> {
|
||||
userCache.invalidate(cacheKeys.avatarFavorites());
|
||||
await loadFavoritedAvatars();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ export function httpStatusOf(err: unknown): number | undefined {
|
||||
return statusOf((err ?? {}) as HttpLike);
|
||||
}
|
||||
|
||||
export function isTransientError(err: unknown): boolean {
|
||||
const status = httpStatusOf(err);
|
||||
if (status === undefined) return true;
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
function retryAfterOf(e: HttpLike): number | undefined {
|
||||
const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"];
|
||||
const n = raw != null ? Number(raw) : NaN;
|
||||
@@ -47,6 +53,13 @@ export function toApiError(err: unknown): ApiError {
|
||||
else if (status === 429) code = "rate_limited";
|
||||
else if (status === undefined && /network|fetch|ENOTFOUND|ECONN/i.test(message)) code = "network";
|
||||
|
||||
if (status === 429) {
|
||||
return { code, message: "VRChat API rate limit hit. Try again shortly.", retryAfter: retryAfterOf(e) };
|
||||
}
|
||||
if (status !== undefined && status >= 500) {
|
||||
return { code, message: "VRChat API is temporarily unavailable. Try again shortly." };
|
||||
}
|
||||
|
||||
return { code, message, retryAfter: retryAfterOf(e) };
|
||||
}
|
||||
|
||||
|
||||
+185
-49
@@ -8,7 +8,7 @@ import type {
|
||||
MoveResult,
|
||||
World,
|
||||
} from "../../shared/types/world";
|
||||
import { httpStatusOf } from "./errors";
|
||||
import { httpStatusOf, isTransientError } from "./errors";
|
||||
import {
|
||||
getCategoryWorlds,
|
||||
getFavoriteGroupWorlds,
|
||||
@@ -71,6 +71,7 @@ async function loadFavoriteWorlds(vrc: VRChat, userId: string): Promise<CachedFa
|
||||
const seen = new Set<string>();
|
||||
const members: { id: string; group: string }[] = [];
|
||||
const names = new Map<string, string>();
|
||||
const order = groups.map((g) => g.name);
|
||||
for (const group of groups) {
|
||||
if (group.displayName) names.set(group.name, group.displayName);
|
||||
let raw;
|
||||
@@ -91,12 +92,12 @@ async function loadFavoriteWorlds(vrc: VRChat, userId: string): Promise<CachedFa
|
||||
}
|
||||
broadcast("world:favoriteFolders", {
|
||||
userId,
|
||||
folders: groupIntoFolders(members, names),
|
||||
folders: groupIntoFolders(order, members, names),
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
|
||||
const folders = groupIntoFolders(members, names);
|
||||
const folders = groupIntoFolders(order, members, names);
|
||||
broadcast("world:favoriteFolders", { userId, folders, done: true });
|
||||
return { worlds, folders };
|
||||
}
|
||||
@@ -111,27 +112,26 @@ function isPrivateFavorites(err: unknown): boolean {
|
||||
}
|
||||
|
||||
function groupIntoFolders(
|
||||
order: string[],
|
||||
members: { id: string; group: string }[],
|
||||
names: Map<string, string>,
|
||||
): FavoriteWorldFolder[] {
|
||||
const order: string[] = [];
|
||||
const byGroup = new Map<string, string[]>();
|
||||
for (const { id, group } of members) {
|
||||
const ids = byGroup.get(group);
|
||||
if (ids) ids.push(id);
|
||||
else {
|
||||
byGroup.set(group, [id]);
|
||||
order.push(group);
|
||||
}
|
||||
else byGroup.set(group, [id]);
|
||||
}
|
||||
return order.map((name) => ({
|
||||
name,
|
||||
displayName: names.get(name) ?? prettyFolderName(name),
|
||||
worldIds: byGroup.get(name)!,
|
||||
worldIds: byGroup.get(name) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
function prettyFolderName(key: string): string {
|
||||
const vp = /^vrcPlusWorlds(\d+)$/.exec(key);
|
||||
if (vp) return `VRC+ Group ${vp[1]}`;
|
||||
const m = /^worlds(\d+)$/.exec(key);
|
||||
if (m) return `Group ${m[1]}`;
|
||||
return key.charAt(0).toUpperCase() + key.slice(1);
|
||||
@@ -175,6 +175,13 @@ async function loadCategory(
|
||||
|
||||
const DEFAULT_FOLDER = "worlds1";
|
||||
|
||||
type WorldFavoriteRecord = {
|
||||
id: string;
|
||||
favoriteId: string;
|
||||
tags: string[];
|
||||
type: WorldFavoriteGroupType;
|
||||
};
|
||||
|
||||
export async function loadMyFavoriteWorlds(): Promise<void> {
|
||||
const me = await currentUser();
|
||||
const { groups, limits } = await cachedRead(
|
||||
@@ -208,13 +215,13 @@ async function fetchMyFavorites(
|
||||
vrc: VRChat,
|
||||
userId: string,
|
||||
): Promise<{ groups: FavoriteGroupInput[]; limits: FavoriteLimits }> {
|
||||
const [{ data: rawGroups }, limits] = await Promise.all([
|
||||
const [{ data: rawGroups }, { limits, caps }] = 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[] = [];
|
||||
const existing: FavoriteGroupInput[] = [];
|
||||
for (const group of worldGroups) {
|
||||
let worlds: World[] = [];
|
||||
try {
|
||||
@@ -228,21 +235,64 @@ async function fetchMyFavorites(
|
||||
} catch (err) {
|
||||
if (!isPrivateFavorites(err)) throw err;
|
||||
}
|
||||
groups.push({
|
||||
existing.push({
|
||||
name: group.name,
|
||||
displayName: group.displayName || prettyFolderName(group.name),
|
||||
visibility: normalizeVisibility(group.visibility),
|
||||
worlds,
|
||||
vrcPlus: (group.type as WorldFavoriteGroupType) === "vrcPlusWorld",
|
||||
});
|
||||
}
|
||||
return { groups, limits };
|
||||
return { groups: fillSlots(existing, caps), limits };
|
||||
}
|
||||
|
||||
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
|
||||
function fillSlots(existing: FavoriteGroupInput[], caps: WorldFavoriteCaps): FavoriteGroupInput[] {
|
||||
const out: FavoriteGroupInput[] = [];
|
||||
for (const [prefix, vrcPlus, max] of [
|
||||
["worlds", false, caps.world],
|
||||
["vrcPlusWorlds", true, caps.vrcPlusWorld],
|
||||
] as const) {
|
||||
const mine = orderSlots(
|
||||
existing.filter((g) => g.vrcPlus === vrcPlus),
|
||||
prefix,
|
||||
);
|
||||
const taken = new Set(mine.map((g) => g.name));
|
||||
for (let i = 1; mine.length < max && i <= max; i++) {
|
||||
const name = `${prefix}${i}`;
|
||||
if (taken.has(name)) continue;
|
||||
mine.push({ name, displayName: prettyFolderName(name), visibility: "private", worlds: [], vrcPlus });
|
||||
}
|
||||
out.push(...mine);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function orderSlots<T extends { name: string }>(groups: T[], prefix: string): T[] {
|
||||
const slotNum = (name: string) => {
|
||||
const m = new RegExp(`^${prefix}(\\d+)$`).exec(name);
|
||||
return m ? Number(m[1]) : null;
|
||||
};
|
||||
const custom = groups.filter((g) => slotNum(g.name) === null);
|
||||
const numbered = groups
|
||||
.filter((g) => slotNum(g.name) !== null)
|
||||
.sort((a, b) => slotNum(a.name)! - slotNum(b.name)!);
|
||||
return [...custom, ...numbered];
|
||||
}
|
||||
|
||||
type WorldFavoriteCaps = { world: number; vrcPlusWorld: number };
|
||||
|
||||
async function fetchFavoriteLimits(
|
||||
vrc: VRChat,
|
||||
): Promise<{ limits: FavoriteLimits; caps: WorldFavoriteCaps }> {
|
||||
const { data } = await vrc.getFavoriteLimits({ throwOnError: true });
|
||||
const world = data.maxFavoriteGroups?.world ?? data.defaultMaxFavoriteGroups;
|
||||
const vrcPlusWorld = data.maxFavoriteGroups?.vrcPlusWorld ?? 0;
|
||||
return {
|
||||
maxGroups: data.maxFavoriteGroups?.world ?? data.defaultMaxFavoriteGroups,
|
||||
maxPerGroup: data.maxFavoritesPerGroup?.world ?? data.defaultMaxFavoritesPerGroup,
|
||||
limits: {
|
||||
maxGroups: world + vrcPlusWorld,
|
||||
maxPerGroup: data.maxFavoritesPerGroup?.world ?? data.defaultMaxFavoritesPerGroup,
|
||||
},
|
||||
caps: { world, vrcPlusWorld },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,7 +303,7 @@ function normalizeVisibility(v: string): FavoriteVisibility {
|
||||
export async function favoriteWorld(worldId: string, folder = DEFAULT_FOLDER): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
await vrc.addFavorite({
|
||||
body: { type: "world", favoriteId: worldId, tags: [folder] },
|
||||
body: favoriteBody(favoriteTypeForFolder(folder), worldId, [folder]),
|
||||
throwOnError: true,
|
||||
});
|
||||
await reloadMyFavorites();
|
||||
@@ -266,17 +316,31 @@ export async function unfavoriteWorld(worldId: string): Promise<void> {
|
||||
await reloadMyFavorites();
|
||||
}
|
||||
|
||||
export async function moveWorldToFolder(worldId: string, folder: string): Promise<MoveResult> {
|
||||
export async function moveWorldToFolder(
|
||||
worldId: string,
|
||||
folder: string,
|
||||
reload = true,
|
||||
): Promise<MoveResult> {
|
||||
const vrc = requireActiveClient();
|
||||
const me = await currentUser();
|
||||
const fav = await findFavoriteRecord(vrc, worldId);
|
||||
if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] };
|
||||
if (isOnlyInFolder(fav, folder)) return { moved: 0, skipped: [] };
|
||||
if (!(await canRefavorite(vrc, worldId))) return { moved: 0, skipped: [worldId] };
|
||||
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
|
||||
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();
|
||||
try {
|
||||
await vrc.addFavorite({
|
||||
body: favoriteBody(type, worldId, [folder]),
|
||||
throwOnError: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (fav) await restoreWorldFavorite(vrc, fav);
|
||||
if (reload) await reloadMyFavorites();
|
||||
if (isTransientError(err)) throw err;
|
||||
return { moved: 0, skipped: [worldId] };
|
||||
}
|
||||
if (reload) await reloadMyFavorites();
|
||||
worldFavoritesStore.moveWorld(worldId, folder);
|
||||
return { moved: 1, skipped: [] };
|
||||
}
|
||||
|
||||
@@ -284,8 +348,8 @@ 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 });
|
||||
const fav = records.get(id);
|
||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||
}
|
||||
await reloadMyFavorites();
|
||||
}
|
||||
@@ -295,7 +359,9 @@ export async function moveWorldsToFolder(
|
||||
folder: string,
|
||||
): Promise<MoveResult> {
|
||||
const vrc = requireActiveClient();
|
||||
const me = await currentUser();
|
||||
const records = await favoriteRecords(vrc);
|
||||
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
|
||||
const skipped: string[] = [];
|
||||
let moved = 0;
|
||||
for (const id of worldIds) {
|
||||
@@ -303,23 +369,48 @@ export async function moveWorldsToFolder(
|
||||
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,
|
||||
});
|
||||
const fav = records.get(id);
|
||||
if (isOnlyInFolder(fav, folder)) continue;
|
||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||
try {
|
||||
await vrc.addFavorite({
|
||||
body: favoriteBody(type, id, [folder]),
|
||||
throwOnError: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (fav) await restoreWorldFavorite(vrc, fav);
|
||||
if (isTransientError(err)) {
|
||||
await reloadMyFavorites();
|
||||
throw err;
|
||||
}
|
||||
skipped.push(id);
|
||||
continue;
|
||||
}
|
||||
moved++;
|
||||
}
|
||||
await reloadMyFavorites();
|
||||
for (const id of worldIds) if (!skipped.includes(id)) worldFavoritesStore.moveWorld(id, folder);
|
||||
return { moved, skipped };
|
||||
}
|
||||
|
||||
async function restoreWorldFavorite(vrc: VRChat, fav: WorldFavoriteRecord): Promise<void> {
|
||||
const tags = fav.tags.length ? fav.tags : [DEFAULT_FOLDER];
|
||||
await vrc.addFavorite({
|
||||
body: favoriteBody(fav.type, fav.favoriteId, tags),
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
function isOnlyInFolder(fav: WorldFavoriteRecord | undefined, folder: string): boolean {
|
||||
return fav?.tags.length === 1 && fav.tags[0] === folder;
|
||||
}
|
||||
|
||||
export async function clearFavoriteWorldFolder(folder: string): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
const me = await currentUser();
|
||||
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
|
||||
await vrc.clearFavoriteGroup({
|
||||
path: { favoriteGroupType: "world", favoriteGroupName: folder, userId: me.id },
|
||||
path: { favoriteGroupType: type, favoriteGroupName: folder, userId: me.id },
|
||||
throwOnError: true,
|
||||
});
|
||||
await reloadMyFavorites();
|
||||
@@ -331,8 +422,9 @@ export async function updateFavoriteWorldFolder(
|
||||
): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
const me = await currentUser();
|
||||
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
|
||||
await vrc.updateFavoriteGroup({
|
||||
path: { favoriteGroupType: "world", favoriteGroupName: folder, userId: me.id },
|
||||
path: { favoriteGroupType: type, favoriteGroupName: folder, userId: me.id },
|
||||
body: {
|
||||
displayName: edit.displayName,
|
||||
visibility: edit.visibility as never,
|
||||
@@ -344,35 +436,79 @@ export async function updateFavoriteWorldFolder(
|
||||
|
||||
async function canRefavorite(vrc: VRChat, worldId: string): Promise<boolean> {
|
||||
try {
|
||||
await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
||||
return true;
|
||||
} catch {
|
||||
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
||||
const me = await currentUser();
|
||||
return data.releaseStatus !== "private" || data.authorId === me.id;
|
||||
} catch (err) {
|
||||
if (isTransientError(err)) throw err;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function favoriteTypeForFolder(folder: string): WorldFavoriteGroupType {
|
||||
return folder.startsWith("vrcPlusWorlds") ? "vrcPlusWorld" : "world";
|
||||
}
|
||||
|
||||
async function favoriteTypeForExistingFolder(
|
||||
vrc: VRChat,
|
||||
userId: string,
|
||||
folder: string,
|
||||
): Promise<WorldFavoriteGroupType> {
|
||||
const { data } = await vrc.getFavoriteGroups({
|
||||
query: { ownerId: userId, n: 100 },
|
||||
throwOnError: true,
|
||||
});
|
||||
return (
|
||||
data.find((g) => g.name === folder && isWorldGroupType(g.type))?.type ??
|
||||
favoriteTypeForFolder(folder)
|
||||
);
|
||||
}
|
||||
|
||||
type AddFavoriteBody = NonNullable<Parameters<VRChat["addFavorite"]>[0]>["body"];
|
||||
function favoriteBody(
|
||||
type: WorldFavoriteGroupType,
|
||||
favoriteId: string,
|
||||
tags: string[],
|
||||
): AddFavoriteBody {
|
||||
return { type, favoriteId, tags } as AddFavoriteBody;
|
||||
}
|
||||
|
||||
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 favoriteRecords(vrc: VRChat): Promise<Map<string, WorldFavoriteRecord>> {
|
||||
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f]));
|
||||
}
|
||||
|
||||
async function favoriteRecordEntries(vrc: VRChat) {
|
||||
async function favoriteRecordEntries(vrc: VRChat): Promise<WorldFavoriteRecord[]> {
|
||||
const me = await currentUser();
|
||||
const { data: rawGroups } = await vrc.getFavoriteGroups({
|
||||
query: { ownerId: me.id, n: 100 },
|
||||
throwOnError: true,
|
||||
});
|
||||
const groups = rawGroups.filter((g) => isWorldGroupType(g.type));
|
||||
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;
|
||||
const entries: WorldFavoriteRecord[] = [];
|
||||
for (const group of groups) {
|
||||
let groupCount = 0;
|
||||
for (let offset = 0; ; offset += pageSize) {
|
||||
const res = await vrc.client.get({
|
||||
url: `/favorites/groups/${group.type}/${encodeURIComponent(group.name)}`,
|
||||
query: { ownerId: me.id, n: pageSize, offset },
|
||||
throwOnError: true,
|
||||
});
|
||||
const data = res.data as { favorites?: WorldFavoriteRecord[]; totalCount?: number };
|
||||
const page = (data.favorites ?? []).map((f) => ({ ...f, type: group.type }));
|
||||
entries.push(...page);
|
||||
groupCount += page.length;
|
||||
if (page.length < pageSize || groupCount >= (data.totalCount ?? groupCount)) break;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function reloadMyFavorites(): Promise<void> {
|
||||
export async function reloadMyFavorites(): Promise<void> {
|
||||
userCache.invalidate(cacheKeys.myFavoriteWorlds());
|
||||
const me = await currentUser();
|
||||
userCache.invalidate(cacheKeys.favoriteWorlds(me.id));
|
||||
|
||||
Reference in New Issue
Block a user