mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +00:00
🐛 fix: store listener leak, friends cap, auth resilience
This commit is contained in:
Vendored
+2
-13
@@ -1,7 +1,7 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { writeFileAtomic, writeFileAtomicSync } from "../lib/atomicFile";
|
import { writeFileAtomic, writeFileAtomicSync } from "../lib/atomicFile";
|
||||||
import type { CacheEntryInfo, CacheStats } from "../../shared/types/debug";
|
import type { CacheEntryInfo, CacheStats } from "../../shared/types/debug";
|
||||||
import { httpStatusOf } from "../vrchat/errors";
|
import { rateLimitDelayMs } from "../lib/http";
|
||||||
|
|
||||||
interface Entry<T> {
|
interface Entry<T> {
|
||||||
value: T;
|
value: T;
|
||||||
@@ -31,17 +31,6 @@ function byteSize(value: unknown): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryAfterMs(err: unknown): number | null {
|
|
||||||
if (httpStatusOf(err) !== 429) return null;
|
|
||||||
const e = (err ?? {}) as {
|
|
||||||
response?: { headers?: Record<string, string> };
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CachePolicy {
|
export interface CachePolicy {
|
||||||
ttl: number;
|
ttl: number;
|
||||||
staleWhileRevalidate?: number;
|
staleWhileRevalidate?: number;
|
||||||
@@ -230,7 +219,7 @@ export class TtlCache {
|
|||||||
return value;
|
return value;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const ms = retryAfterMs(err);
|
const ms = rateLimitDelayMs(err);
|
||||||
if (ms != null) this.rateLimitedUntil = Date.now() + ms;
|
if (ms != null) this.rateLimitedUntil = Date.now() + ms;
|
||||||
throw err;
|
throw err;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const handlers = {
|
|||||||
"auth:status": () => guard(() => auth.checkStatus()),
|
"auth:status": () => guard(() => auth.checkStatus()),
|
||||||
"auth:login": (creds) => guard(() => auth.login(creds)),
|
"auth:login": (creds) => guard(() => auth.login(creds)),
|
||||||
"auth:verify2fa": (payload) => guard(() => auth.verify2fa(payload)),
|
"auth:verify2fa": (payload) => guard(() => auth.verify2fa(payload)),
|
||||||
|
"auth:cancel2fa": () => guard(async () => auth.cancel2fa()),
|
||||||
"auth:logout": () => guard(() => auth.logout()),
|
"auth:logout": () => guard(() => auth.logout()),
|
||||||
|
|
||||||
"accounts:list": () => guard(async () => auth.listAccountsState()),
|
"accounts:list": () => guard(async () => auth.listAccountsState()),
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export interface HttpLike {
|
||||||
|
status?: number;
|
||||||
|
statusCode?: number;
|
||||||
|
status_code?: number;
|
||||||
|
response?: { status?: number; headers?: Record<string, string> };
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
message?: string;
|
||||||
|
error?: { status_code?: number; statusCode?: number; status?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function httpStatusOf(err: unknown): number | undefined {
|
||||||
|
const e = (err ?? {}) as HttpLike;
|
||||||
|
return (
|
||||||
|
e.status ??
|
||||||
|
e.statusCode ??
|
||||||
|
e.status_code ??
|
||||||
|
e.response?.status ??
|
||||||
|
e.error?.status_code ??
|
||||||
|
e.error?.statusCode ??
|
||||||
|
e.error?.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryAfterSecondsOf(err: unknown): number | undefined {
|
||||||
|
const e = (err ?? {}) as HttpLike;
|
||||||
|
const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"];
|
||||||
|
const n = raw != null ? Number(raw) : NaN;
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rateLimitDelayMs(err: unknown): number | null {
|
||||||
|
if (httpStatusOf(err) !== 429) return null;
|
||||||
|
const secs = retryAfterSecondsOf(err);
|
||||||
|
return secs !== undefined ? secs * 1000 : 8000;
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ class AvatarStore {
|
|||||||
private mineIds = new Set<string>();
|
private mineIds = new Set<string>();
|
||||||
private favorites: FavoriteAvatarFolder[] = [];
|
private favorites: FavoriteAvatarFolder[] = [];
|
||||||
private favoriteLimits: FavoriteLimits = DEFAULT_LIMITS;
|
private favoriteLimits: FavoriteLimits = DEFAULT_LIMITS;
|
||||||
private wired = false;
|
private unwire: (() => void) | null = null;
|
||||||
|
|
||||||
onChange(fn: Listener): () => void {
|
onChange(fn: Listener): () => void {
|
||||||
this.wire();
|
this.wire();
|
||||||
@@ -36,9 +36,8 @@ class AvatarStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private wire(): void {
|
private wire(): void {
|
||||||
if (this.wired || !repos.hasActive) return;
|
if (this.unwire || !repos.hasActive) return;
|
||||||
this.wired = true;
|
this.unwire = repos.active.avatars.onChange((c) => {
|
||||||
repos.active.avatars.onChange((c) => {
|
|
||||||
this.emit({ type: "upsert", avatar: c.entity });
|
this.emit({ type: "upsert", avatar: c.entity });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -91,7 +90,8 @@ class AvatarStore {
|
|||||||
this.mineIds.clear();
|
this.mineIds.clear();
|
||||||
this.favorites = [];
|
this.favorites = [];
|
||||||
this.favoriteLimits = DEFAULT_LIMITS;
|
this.favoriteLimits = DEFAULT_LIMITS;
|
||||||
this.wired = false;
|
this.unwire?.();
|
||||||
|
this.unwire = null;
|
||||||
this.wire();
|
this.wire();
|
||||||
this.emit({ type: "seed", snapshot: this.snapshot() });
|
this.emit({ type: "seed", snapshot: this.snapshot() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type Listener = (change: Change) => void;
|
|||||||
class EntityStore {
|
class EntityStore {
|
||||||
private readonly listeners = new Set<Listener>();
|
private readonly listeners = new Set<Listener>();
|
||||||
private selfId: string | null = null;
|
private selfId: string | null = null;
|
||||||
private wired = false;
|
private unwire: (() => void) | null = null;
|
||||||
|
|
||||||
onChange(fn: Listener): () => void {
|
onChange(fn: Listener): () => void {
|
||||||
this.wire();
|
this.wire();
|
||||||
@@ -19,16 +19,20 @@ class EntityStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private wire(): void {
|
private wire(): void {
|
||||||
if (this.wired || !repos.hasActive) return;
|
if (this.unwire || !repos.hasActive) return;
|
||||||
this.wired = true;
|
this.unwire = repos.active.users.onChange((c) => {
|
||||||
repos.active.users.onChange((c) => {
|
|
||||||
this.emit({ type: "upsert", user: c.entity });
|
this.emit({ type: "upsert", user: c.entity });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
seed(self: UserProfile, friends: UserProfile[]): void {
|
private rewire(): void {
|
||||||
this.wired = false;
|
this.unwire?.();
|
||||||
|
this.unwire = null;
|
||||||
this.wire();
|
this.wire();
|
||||||
|
}
|
||||||
|
|
||||||
|
seed(self: UserProfile, friends: UserProfile[]): void {
|
||||||
|
this.rewire();
|
||||||
this.selfId = self.id;
|
this.selfId = self.id;
|
||||||
const users = repos.active.users;
|
const users = repos.active.users;
|
||||||
users.upsert(self, "rest:detail");
|
users.upsert(self, "rest:detail");
|
||||||
@@ -70,8 +74,7 @@ class EntityStore {
|
|||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.selfId = null;
|
this.selfId = null;
|
||||||
this.wired = false;
|
this.rewire();
|
||||||
this.wire();
|
|
||||||
this.emit({ type: "seed", snapshot: this.snapshot() });
|
this.emit({ type: "seed", snapshot: this.snapshot() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class GroupStore {
|
|||||||
private readonly listeners = new Set<Listener>();
|
private readonly listeners = new Set<Listener>();
|
||||||
private byUser = new Map<string, Set<string>>();
|
private byUser = new Map<string, Set<string>>();
|
||||||
private representedByUser = new Map<string, string>();
|
private representedByUser = new Map<string, string>();
|
||||||
private wired = false;
|
private unwire: (() => void) | null = null;
|
||||||
|
|
||||||
onChange(fn: Listener): () => void {
|
onChange(fn: Listener): () => void {
|
||||||
this.wire();
|
this.wire();
|
||||||
@@ -20,9 +20,8 @@ class GroupStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private wire(): void {
|
private wire(): void {
|
||||||
if (this.wired || !repos.hasActive) return;
|
if (this.unwire || !repos.hasActive) return;
|
||||||
this.wired = true;
|
this.unwire = repos.active.groups.onChange((c) => {
|
||||||
repos.active.groups.onChange((c) => {
|
|
||||||
this.emit({ type: "upsert", group: c.entity });
|
this.emit({ type: "upsert", group: c.entity });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -62,7 +61,8 @@ class GroupStore {
|
|||||||
reset(): void {
|
reset(): void {
|
||||||
this.byUser.clear();
|
this.byUser.clear();
|
||||||
this.representedByUser.clear();
|
this.representedByUser.clear();
|
||||||
this.wired = false;
|
this.unwire?.();
|
||||||
|
this.unwire = null;
|
||||||
this.wire();
|
this.wire();
|
||||||
this.emit({ type: "seed", snapshot: this.snapshot() });
|
this.emit({ type: "seed", snapshot: this.snapshot() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type Listener = (change: Change) => void;
|
|||||||
class WorldStore {
|
class WorldStore {
|
||||||
private readonly listeners = new Set<Listener>();
|
private readonly listeners = new Set<Listener>();
|
||||||
private byAuthor = new Map<string, Set<string>>();
|
private byAuthor = new Map<string, Set<string>>();
|
||||||
private wired = false;
|
private unwire: (() => void) | null = null;
|
||||||
|
|
||||||
onChange(fn: Listener): () => void {
|
onChange(fn: Listener): () => void {
|
||||||
this.wire();
|
this.wire();
|
||||||
@@ -19,9 +19,8 @@ class WorldStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private wire(): void {
|
private wire(): void {
|
||||||
if (this.wired || !repos.hasActive) return;
|
if (this.unwire || !repos.hasActive) return;
|
||||||
this.wired = true;
|
this.unwire = repos.active.worlds.onChange((c) => {
|
||||||
repos.active.worlds.onChange((c) => {
|
|
||||||
this.emit({ type: "upsert", world: c.entity });
|
this.emit({ type: "upsert", world: c.entity });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,7 +54,8 @@ class WorldStore {
|
|||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.byAuthor.clear();
|
this.byAuthor.clear();
|
||||||
this.wired = false;
|
this.unwire?.();
|
||||||
|
this.unwire = null;
|
||||||
this.wire();
|
this.wire();
|
||||||
this.emit({ type: "seed", snapshot: this.snapshot() });
|
this.emit({ type: "seed", snapshot: this.snapshot() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
setActive,
|
setActive,
|
||||||
} from "../accounts/store";
|
} from "../accounts/store";
|
||||||
import { toCurrentUserSummary } from "./mappers";
|
import { toCurrentUserSummary } from "./mappers";
|
||||||
|
import { isTransientError } from "./errors";
|
||||||
import { userCache } from "./userService";
|
import { userCache } from "./userService";
|
||||||
import { clearSessionCookies, syncSessionCookies } from "./cookies";
|
import { clearSessionCookies, syncSessionCookies } from "./cookies";
|
||||||
import { logger } from "../debug/logger";
|
import { logger } from "../debug/logger";
|
||||||
@@ -47,12 +48,14 @@ function isRealUser(data: unknown): data is RawUser {
|
|||||||
|
|
||||||
async function summaryFrom(vrc: VRChatLike): Promise<CurrentUserSummary> {
|
async function summaryFrom(vrc: VRChatLike): Promise<CurrentUserSummary> {
|
||||||
const { data } = await vrc.getCurrentUser({ throwOnError: true });
|
const { data } = await vrc.getCurrentUser({ throwOnError: true });
|
||||||
if (!isRealUser(data)) throw new Error("not authenticated");
|
if (!isRealUser(data)) throw { status: 401, message: "Not authenticated" };
|
||||||
return toCurrentUserSummary(data);
|
return toCurrentUserSummary(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pendingTwoFactor: {
|
let pendingTwoFactor: {
|
||||||
|
creds: LoginCredentials;
|
||||||
resolveCode: (code: string) => void;
|
resolveCode: (code: string) => void;
|
||||||
|
rejectCode: (err: unknown) => void;
|
||||||
methods: TwoFactorMethod[];
|
methods: TwoFactorMethod[];
|
||||||
loginDone: Promise<AuthStatus>;
|
loginDone: Promise<AuthStatus>;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
@@ -81,7 +84,8 @@ export async function checkStatus(): Promise<AuthStatus> {
|
|||||||
await syncSessionCookies(vrc);
|
await syncSessionCookies(vrc);
|
||||||
void seedActiveAccount();
|
void seedActiveAccount();
|
||||||
return { state: "authenticated", user };
|
return { state: "authenticated", user };
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (isTransientError(err)) throw err;
|
||||||
return { state: "unauthenticated" };
|
return { state: "unauthenticated" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,6 +100,8 @@ export async function login(creds: LoginCredentials): Promise<AuthStatus> {
|
|||||||
resolveCode = res;
|
resolveCode = res;
|
||||||
rejectCode = rej;
|
rejectCode = rej;
|
||||||
});
|
});
|
||||||
|
// nothing awaits this if login fails before 2fa is even asked, don't let it reject unhandled
|
||||||
|
codePromise.catch(() => {});
|
||||||
|
|
||||||
let signalAwaiting!: (methods: TwoFactorMethod[]) => void;
|
let signalAwaiting!: (methods: TwoFactorMethod[]) => void;
|
||||||
const awaiting = new Promise<TwoFactorMethod[]>((res) => {
|
const awaiting = new Promise<TwoFactorMethod[]>((res) => {
|
||||||
@@ -128,24 +134,40 @@ export async function login(creds: LoginCredentials): Promise<AuthStatus> {
|
|||||||
return winner.status;
|
return winner.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingTwoFactor = { resolveCode, methods: winner.methods, loginDone };
|
pendingTwoFactor = { creds, resolveCode, rejectCode, methods: winner.methods, loginDone };
|
||||||
return { state: "awaiting2fa", methods: winner.methods };
|
return { state: "awaiting2fa", methods: winner.methods };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verify2fa(payload: TwoFactorPayload): Promise<AuthStatus> {
|
export async function verify2fa(payload: TwoFactorPayload): Promise<AuthStatus> {
|
||||||
if (!pendingTwoFactor) return { state: "unauthenticated" };
|
if (!pendingTwoFactor) return { state: "unauthenticated" };
|
||||||
const { resolveCode, loginDone } = pendingTwoFactor;
|
const { resolveCode, loginDone, creds } = pendingTwoFactor;
|
||||||
resolveCode(payload.code);
|
resolveCode(payload.code);
|
||||||
try {
|
try {
|
||||||
const status = await loginDone;
|
const status = await loginDone;
|
||||||
pendingTwoFactor = null;
|
pendingTwoFactor = null;
|
||||||
return status;
|
return status;
|
||||||
} catch {
|
} catch (err) {
|
||||||
pendingTwoFactor = null;
|
pendingTwoFactor = null;
|
||||||
return { state: "unauthenticated" };
|
if (isTransientError(err)) throw err;
|
||||||
|
const next = await login(creds);
|
||||||
|
if (next.state === "awaiting2fa") {
|
||||||
|
throw { code: "invalid_2fa", message: "Invalid two-factor code." };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancel2fa(): AuthStatus {
|
||||||
|
if (pendingTwoFactor) {
|
||||||
|
pendingTwoFactor.rejectCode({ status: 401, message: "Two-factor auth cancelled" });
|
||||||
|
pendingTwoFactor.loginDone.catch(() => {});
|
||||||
|
pendingTwoFactor = null;
|
||||||
|
}
|
||||||
|
clearLoginClient();
|
||||||
|
clearPending();
|
||||||
|
return { state: "unauthenticated" };
|
||||||
|
}
|
||||||
|
|
||||||
export function listAccountsState(): AccountsState {
|
export function listAccountsState(): AccountsState {
|
||||||
return listAccounts();
|
return listAccounts();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
MoveResult,
|
MoveResult,
|
||||||
} from "../../shared/types/avatar";
|
} from "../../shared/types/avatar";
|
||||||
import { toAvatar } from "./mappers";
|
import { toAvatar } from "./mappers";
|
||||||
|
import { fillSlots, normalizeVisibility } from "./favoriteFolders";
|
||||||
|
import { moveOneFavorite, moveManyFavorites, pause, type FavoriteMover } from "./favoriteMove";
|
||||||
import { httpStatusOf, isTransientError } from "./errors";
|
import { httpStatusOf, isTransientError } from "./errors";
|
||||||
import { cachedRead } from "./cachedRead";
|
import { cachedRead } from "./cachedRead";
|
||||||
import { requireActiveClient } from "./client";
|
import { requireActiveClient } from "./client";
|
||||||
@@ -89,26 +91,12 @@ async function fetchFavorites(vrc: VRChat): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fillAvatarSlots(existing: FavoriteFolder[], max: number): FavoriteFolder[] {
|
function fillAvatarSlots(existing: FavoriteFolder[], max: number): FavoriteFolder[] {
|
||||||
const out = orderSlots(existing, "avatars");
|
return fillSlots(existing, "avatars", max, (name) => ({
|
||||||
const taken = new Set(out.map((f) => f.name));
|
name,
|
||||||
for (let i = 1; out.length < max && i <= max; i++) {
|
displayName: prettyFolderName(name),
|
||||||
const name = `avatars${i}`;
|
visibility: "private",
|
||||||
if (taken.has(name)) continue;
|
avatars: [],
|
||||||
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> {
|
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
|
||||||
@@ -119,10 +107,6 @@ async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeVisibility(v: string): FavoriteVisibility {
|
|
||||||
return v === "friends" || v === "public" ? v : "private";
|
|
||||||
}
|
|
||||||
|
|
||||||
function prettyFolderName(key: string): string {
|
function prettyFolderName(key: string): string {
|
||||||
const m = /^avatars(\d+)$/.exec(key);
|
const m = /^avatars(\d+)$/.exec(key);
|
||||||
if (m) return `Group ${m[1]}`;
|
if (m) return `Group ${m[1]}`;
|
||||||
@@ -187,6 +171,26 @@ export async function unfavoriteAvatar(avatarId: string): Promise<void> {
|
|||||||
await reloadFavorites();
|
await reloadFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AvatarFavoriteRecord = { id: string; favoriteId: string; tags: string[] };
|
||||||
|
|
||||||
|
function avatarMover(vrc: VRChat): FavoriteMover<AvatarFavoriteRecord> {
|
||||||
|
return {
|
||||||
|
skip: (fav, folder) => Boolean(fav?.tags?.includes(folder)),
|
||||||
|
canRefavorite: (id) => canRefavorite(vrc, id),
|
||||||
|
remove: async (fav) => {
|
||||||
|
await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||||
|
},
|
||||||
|
add: async (id, folder) => {
|
||||||
|
await vrc.addFavorite({
|
||||||
|
body: { type: "avatar", favoriteId: id, tags: [folder] },
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
restore: (fav) => restoreAvatarFavorite(vrc, fav),
|
||||||
|
reload: () => reloadFavorites(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function moveAvatarToFolder(
|
export async function moveAvatarToFolder(
|
||||||
avatarId: string,
|
avatarId: string,
|
||||||
folder: string,
|
folder: string,
|
||||||
@@ -194,30 +198,19 @@ export async function moveAvatarToFolder(
|
|||||||
): Promise<MoveResult> {
|
): Promise<MoveResult> {
|
||||||
const vrc = requireActiveClient();
|
const vrc = requireActiveClient();
|
||||||
const fav = await findFavoriteRecord(vrc, avatarId);
|
const fav = await findFavoriteRecord(vrc, avatarId);
|
||||||
if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] };
|
return moveOneFavorite(avatarMover(vrc), fav, avatarId, folder, reload);
|
||||||
if (!(await canRefavorite(vrc, avatarId))) return { moved: 0, skipped: [avatarId] };
|
|
||||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
|
||||||
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: [] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unfavoriteAvatars(avatarIds: string[]): Promise<void> {
|
export async function unfavoriteAvatars(avatarIds: string[]): Promise<void> {
|
||||||
const vrc = requireActiveClient();
|
const vrc = requireActiveClient();
|
||||||
const records = await favoriteRecords(vrc);
|
const records = await favoriteRecords(vrc);
|
||||||
|
let first = true;
|
||||||
for (const id of avatarIds) {
|
for (const id of avatarIds) {
|
||||||
const fav = records.get(id);
|
const fav = records.get(id);
|
||||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
if (!fav) continue;
|
||||||
|
if (!first) await pause();
|
||||||
|
first = false;
|
||||||
|
await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||||
}
|
}
|
||||||
await reloadFavorites();
|
await reloadFavorites();
|
||||||
}
|
}
|
||||||
@@ -228,34 +221,7 @@ export async function moveAvatarsToFolder(
|
|||||||
): Promise<MoveResult> {
|
): Promise<MoveResult> {
|
||||||
const vrc = requireActiveClient();
|
const vrc = requireActiveClient();
|
||||||
const records = await favoriteRecords(vrc);
|
const records = await favoriteRecords(vrc);
|
||||||
const skipped: string[] = [];
|
return moveManyFavorites(avatarMover(vrc), records, avatarIds, folder);
|
||||||
let moved = 0;
|
|
||||||
for (const id of avatarIds) {
|
|
||||||
if (!(await canRefavorite(vrc, id))) {
|
|
||||||
skipped.push(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const fav = records.get(id);
|
|
||||||
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();
|
|
||||||
return { moved, skipped };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function canRefavorite(vrc: VRChat, avatarId: string): Promise<boolean> {
|
async function canRefavorite(vrc: VRChat, avatarId: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,31 +1,11 @@
|
|||||||
import type { ApiError, ApiErrorCode, IpcResult } from "../../shared/types/result";
|
import type { ApiError, ApiErrorCode, IpcResult } from "../../shared/types/result";
|
||||||
|
import { httpStatusOf, retryAfterSecondsOf, type HttpLike } from "../lib/http";
|
||||||
|
|
||||||
interface HttpLike {
|
export { httpStatusOf };
|
||||||
status?: number;
|
|
||||||
statusCode?: number;
|
interface ErrorLike extends HttpLike {
|
||||||
status_code?: number;
|
|
||||||
response?: { status?: number; headers?: Record<string, string> };
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
message?: string;
|
|
||||||
code?: ApiErrorCode;
|
code?: ApiErrorCode;
|
||||||
methods?: ("totp" | "emailOtp")[];
|
methods?: ("totp" | "emailOtp")[];
|
||||||
error?: { status_code?: number; statusCode?: number; status?: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusOf(e: HttpLike): number | undefined {
|
|
||||||
return (
|
|
||||||
e.status ??
|
|
||||||
e.statusCode ??
|
|
||||||
e.status_code ??
|
|
||||||
e.response?.status ??
|
|
||||||
e.error?.status_code ??
|
|
||||||
e.error?.statusCode ??
|
|
||||||
e.error?.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function httpStatusOf(err: unknown): number | undefined {
|
|
||||||
return statusOf((err ?? {}) as HttpLike);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTransientError(err: unknown): boolean {
|
export function isTransientError(err: unknown): boolean {
|
||||||
@@ -34,15 +14,9 @@ export function isTransientError(err: unknown): boolean {
|
|||||||
return status === 429 || status >= 500;
|
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;
|
|
||||||
return Number.isFinite(n) ? n : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toApiError(err: unknown): ApiError {
|
export function toApiError(err: unknown): ApiError {
|
||||||
const e = (err ?? {}) as HttpLike;
|
const e = (err ?? {}) as ErrorLike;
|
||||||
const status = statusOf(e);
|
const status = httpStatusOf(e);
|
||||||
const message = e.message ?? "Unexpected error";
|
const message = e.message ?? "Unexpected error";
|
||||||
|
|
||||||
if (e.code) return { code: e.code, message, methods: e.methods };
|
if (e.code) return { code: e.code, message, methods: e.methods };
|
||||||
@@ -57,14 +31,14 @@ export function toApiError(err: unknown): ApiError {
|
|||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
message: "VRChat API rate limit hit. Try again shortly.",
|
message: "VRChat API rate limit hit. Try again shortly.",
|
||||||
retryAfter: retryAfterOf(e),
|
retryAfter: retryAfterSecondsOf(e),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (status !== undefined && status >= 500) {
|
if (status !== undefined && status >= 500) {
|
||||||
return { code, message: "VRChat API is temporarily unavailable. Try again shortly." };
|
return { code, message: "VRChat API is temporarily unavailable. Try again shortly." };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { code, message, retryAfter: retryAfterOf(e) };
|
return { code, message, retryAfter: retryAfterSecondsOf(e) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function guard<T>(fn: () => Promise<T>): Promise<IpcResult<T>> {
|
export async function guard<T>(fn: () => Promise<T>): Promise<IpcResult<T>> {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FavoriteVisibility } from "../../shared/types/favorites";
|
||||||
|
|
||||||
|
export function normalizeVisibility(v: string): FavoriteVisibility {
|
||||||
|
return v === "friends" || v === "public" ? v : "private";
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fillSlots<T extends { name: string }>(
|
||||||
|
existing: T[],
|
||||||
|
prefix: string,
|
||||||
|
max: number,
|
||||||
|
makeEmpty: (name: string) => T,
|
||||||
|
): T[] {
|
||||||
|
const out = orderSlots(existing, prefix);
|
||||||
|
const taken = new Set(out.map((g) => g.name));
|
||||||
|
for (let i = 1; out.length < max && i <= max; i++) {
|
||||||
|
const name = `${prefix}${i}`;
|
||||||
|
if (!taken.has(name)) out.push(makeEmpty(name));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { MoveResult } from "../../shared/types/favorites";
|
||||||
|
import { isTransientError } from "./errors";
|
||||||
|
|
||||||
|
export interface FavoriteMover<Rec> {
|
||||||
|
skip: (rec: Rec | undefined, folder: string) => boolean;
|
||||||
|
canRefavorite: (id: string) => Promise<boolean>;
|
||||||
|
remove: (rec: Rec) => Promise<void>;
|
||||||
|
add: (id: string, folder: string) => Promise<void>;
|
||||||
|
restore: (rec: Rec) => Promise<void>;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
onMoved?: (id: string, folder: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BULK_PACE_MS = 350;
|
||||||
|
|
||||||
|
export function pause(ms = BULK_PACE_MS): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveOneFavorite<Rec>(
|
||||||
|
m: FavoriteMover<Rec>,
|
||||||
|
rec: Rec | undefined,
|
||||||
|
id: string,
|
||||||
|
folder: string,
|
||||||
|
reload: boolean,
|
||||||
|
): Promise<MoveResult> {
|
||||||
|
if (m.skip(rec, folder)) return { moved: 0, skipped: [] };
|
||||||
|
if (!(await m.canRefavorite(id))) return { moved: 0, skipped: [id] };
|
||||||
|
if (rec) await m.remove(rec);
|
||||||
|
try {
|
||||||
|
await m.add(id, folder);
|
||||||
|
} catch (err) {
|
||||||
|
if (rec) await m.restore(rec);
|
||||||
|
if (reload) await m.reload();
|
||||||
|
if (isTransientError(err)) throw err;
|
||||||
|
return { moved: 0, skipped: [id] };
|
||||||
|
}
|
||||||
|
if (reload) await m.reload();
|
||||||
|
m.onMoved?.(id, folder);
|
||||||
|
return { moved: 1, skipped: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveManyFavorites<Rec>(
|
||||||
|
m: FavoriteMover<Rec>,
|
||||||
|
records: Map<string, Rec>,
|
||||||
|
ids: string[],
|
||||||
|
folder: string,
|
||||||
|
): Promise<MoveResult> {
|
||||||
|
const skipped: string[] = [];
|
||||||
|
let moved = 0;
|
||||||
|
let first = true;
|
||||||
|
for (const id of ids) {
|
||||||
|
const rec = records.get(id);
|
||||||
|
if (m.skip(rec, folder)) continue;
|
||||||
|
if (!first) await pause();
|
||||||
|
first = false;
|
||||||
|
if (!(await m.canRefavorite(id))) {
|
||||||
|
skipped.push(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rec) await m.remove(rec);
|
||||||
|
try {
|
||||||
|
await m.add(id, folder);
|
||||||
|
} catch (err) {
|
||||||
|
if (rec) await m.restore(rec);
|
||||||
|
if (isTransientError(err)) {
|
||||||
|
await m.reload();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
skipped.push(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
m.onMoved?.(id, folder);
|
||||||
|
moved++;
|
||||||
|
}
|
||||||
|
await m.reload();
|
||||||
|
return { moved, skipped };
|
||||||
|
}
|
||||||
@@ -13,21 +13,40 @@ const order: Record<string, number> = {
|
|||||||
offline: 4,
|
offline: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
type FriendPage = NonNullable<
|
||||||
|
Awaited<ReturnType<ReturnType<typeof requireActiveClient>["getFriends"]>>["data"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
async function allFriendPages(
|
||||||
|
vrc: ReturnType<typeof requireActiveClient>,
|
||||||
|
offline: boolean,
|
||||||
|
): Promise<FriendPage> {
|
||||||
|
const out: FriendPage = [];
|
||||||
|
for (let offset = 0; ; offset += PAGE_SIZE) {
|
||||||
|
const { data } = await vrc.getFriends({
|
||||||
|
query: { offline, n: PAGE_SIZE, offset },
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
const page = data ?? [];
|
||||||
|
out.push(...page);
|
||||||
|
if (page.length < PAGE_SIZE) return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listFriends(): Promise<UserProfile[]> {
|
export async function listFriends(): Promise<UserProfile[]> {
|
||||||
const vrc = requireActiveClient();
|
const vrc = requireActiveClient();
|
||||||
const self = (await currentUser()).id;
|
const self = (await currentUser()).id;
|
||||||
|
|
||||||
return userCache.get(cacheKeys.friends(), policies.friends, async () => {
|
return userCache.get(cacheKeys.friends(), policies.friends, async () => {
|
||||||
const [online, offline] = await Promise.all([
|
const [online, offline] = await Promise.all([
|
||||||
vrc.getFriends({ query: { offline: false, n: 100 }, throwOnError: true }),
|
allFriendPages(vrc, false),
|
||||||
vrc.getFriends({ query: { offline: true, n: 100 }, throwOnError: true }),
|
allFriendPages(vrc, true),
|
||||||
]);
|
]);
|
||||||
const friends = [
|
const friends = [
|
||||||
...(online.data ?? []).map((u) => ({ ...toUserProfile(u, self), state: "online" as const })),
|
...online.map((u) => ({ ...toUserProfile(u, self), state: "online" as const })),
|
||||||
...(offline.data ?? []).map((u) => ({
|
...offline.map((u) => ({ ...toUserProfile(u, self), state: "offline" as const })),
|
||||||
...toUserProfile(u, self),
|
|
||||||
state: "offline" as const,
|
|
||||||
})),
|
|
||||||
];
|
];
|
||||||
return friends.sort(
|
return friends.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type {
|
|||||||
DiscoverCategory,
|
DiscoverCategory,
|
||||||
FavoriteGroupEdit,
|
FavoriteGroupEdit,
|
||||||
FavoriteLimits,
|
FavoriteLimits,
|
||||||
FavoriteVisibility,
|
|
||||||
FavoriteWorldFolder,
|
FavoriteWorldFolder,
|
||||||
MoveResult,
|
MoveResult,
|
||||||
World,
|
World,
|
||||||
@@ -16,6 +15,8 @@ import {
|
|||||||
type WorldFavoriteGroupType,
|
type WorldFavoriteGroupType,
|
||||||
} from "./rawEndpoints";
|
} from "./rawEndpoints";
|
||||||
import { toWorld } from "./mappers";
|
import { toWorld } from "./mappers";
|
||||||
|
import { fillSlots, normalizeVisibility } from "./favoriteFolders";
|
||||||
|
import { moveOneFavorite, moveManyFavorites, pause, type FavoriteMover } from "./favoriteMove";
|
||||||
import { cachedRead } from "./cachedRead";
|
import { cachedRead } from "./cachedRead";
|
||||||
import { requireActiveClient } from "./client";
|
import { requireActiveClient } from "./client";
|
||||||
import { userCache, currentUser } from "./userService";
|
import { userCache, currentUser } from "./userService";
|
||||||
@@ -97,6 +98,8 @@ async function loadFavoriteWorlds(vrc: VRChat, userId: string): Promise<CachedFa
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// looks redundant with the caller's broadcast, but on a background revalidate
|
||||||
|
// nobody awaits us and this is the only done:true the ui gets
|
||||||
const folders = groupIntoFolders(order, members, names);
|
const folders = groupIntoFolders(order, members, names);
|
||||||
broadcast("world:favoriteFolders", { userId, folders, done: true });
|
broadcast("world:favoriteFolders", { userId, folders, done: true });
|
||||||
return { worlds, folders };
|
return { worlds, folders };
|
||||||
@@ -243,48 +246,36 @@ async function fetchMyFavorites(
|
|||||||
vrcPlus: (group.type as WorldFavoriteGroupType) === "vrcPlusWorld",
|
vrcPlus: (group.type as WorldFavoriteGroupType) === "vrcPlusWorld",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { groups: fillSlots(existing, caps), limits };
|
return { groups: fillWorldSlots(existing, caps), limits };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillSlots(existing: FavoriteGroupInput[], caps: WorldFavoriteCaps): FavoriteGroupInput[] {
|
function fillWorldSlots(
|
||||||
|
existing: FavoriteGroupInput[],
|
||||||
|
caps: WorldFavoriteCaps,
|
||||||
|
): FavoriteGroupInput[] {
|
||||||
const out: FavoriteGroupInput[] = [];
|
const out: FavoriteGroupInput[] = [];
|
||||||
for (const [prefix, vrcPlus, max] of [
|
for (const [prefix, vrcPlus, max] of [
|
||||||
["worlds", false, caps.world],
|
["worlds", false, caps.world],
|
||||||
["vrcPlusWorlds", true, caps.vrcPlusWorld],
|
["vrcPlusWorlds", true, caps.vrcPlusWorld],
|
||||||
] as const) {
|
] as const) {
|
||||||
const mine = orderSlots(
|
out.push(
|
||||||
existing.filter((g) => g.vrcPlus === vrcPlus),
|
...fillSlots(
|
||||||
prefix,
|
existing.filter((g) => g.vrcPlus === vrcPlus),
|
||||||
|
prefix,
|
||||||
|
max,
|
||||||
|
(name): FavoriteGroupInput => ({
|
||||||
|
name,
|
||||||
|
displayName: prettyFolderName(name),
|
||||||
|
visibility: "private",
|
||||||
|
worlds: [],
|
||||||
|
vrcPlus,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
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;
|
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 };
|
type WorldFavoriteCaps = { world: number; vrcPlusWorld: number };
|
||||||
|
|
||||||
async function fetchFavoriteLimits(
|
async function fetchFavoriteLimits(
|
||||||
@@ -302,10 +293,6 @@ async function fetchFavoriteLimits(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeVisibility(v: string): FavoriteVisibility {
|
|
||||||
return v === "friends" || v === "public" ? v : "private";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function favoriteWorld(worldId: string, folder = DEFAULT_FOLDER): Promise<void> {
|
export async function favoriteWorld(worldId: string, folder = DEFAULT_FOLDER): Promise<void> {
|
||||||
const vrc = requireActiveClient();
|
const vrc = requireActiveClient();
|
||||||
await vrc.addFavorite({
|
await vrc.addFavorite({
|
||||||
@@ -322,6 +309,28 @@ export async function unfavoriteWorld(worldId: string): Promise<void> {
|
|||||||
await reloadMyFavorites();
|
await reloadMyFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function worldMover(vrc: VRChat, meId: string): FavoriteMover<WorldFavoriteRecord> {
|
||||||
|
let typeOf: Promise<WorldFavoriteGroupType> | null = null;
|
||||||
|
const folderType = (folder: string) =>
|
||||||
|
(typeOf ??= favoriteTypeForExistingFolder(vrc, meId, folder));
|
||||||
|
return {
|
||||||
|
skip: isOnlyInFolder,
|
||||||
|
canRefavorite: (id) => canRefavorite(vrc, id),
|
||||||
|
remove: async (fav) => {
|
||||||
|
await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||||
|
},
|
||||||
|
add: async (id, folder) => {
|
||||||
|
await vrc.addFavorite({
|
||||||
|
body: favoriteBody(await folderType(folder), id, [folder]),
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
restore: (fav) => restoreWorldFavorite(vrc, fav),
|
||||||
|
reload: () => reloadMyFavorites(),
|
||||||
|
onMoved: (id, folder) => worldFavoritesStore.moveWorld(id, folder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function moveWorldToFolder(
|
export async function moveWorldToFolder(
|
||||||
worldId: string,
|
worldId: string,
|
||||||
folder: string,
|
folder: string,
|
||||||
@@ -330,32 +339,19 @@ export async function moveWorldToFolder(
|
|||||||
const vrc = requireActiveClient();
|
const vrc = requireActiveClient();
|
||||||
const me = await currentUser();
|
const me = await currentUser();
|
||||||
const fav = await findFavoriteRecord(vrc, worldId);
|
const fav = await findFavoriteRecord(vrc, worldId);
|
||||||
if (isOnlyInFolder(fav, folder)) return { moved: 0, skipped: [] };
|
return moveOneFavorite(worldMover(vrc, me.id), fav, worldId, folder, reload);
|
||||||
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 });
|
|
||||||
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: [] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unfavoriteWorlds(worldIds: string[]): Promise<void> {
|
export async function unfavoriteWorlds(worldIds: string[]): Promise<void> {
|
||||||
const vrc = requireActiveClient();
|
const vrc = requireActiveClient();
|
||||||
const records = await favoriteRecords(vrc);
|
const records = await favoriteRecords(vrc);
|
||||||
|
let first = true;
|
||||||
for (const id of worldIds) {
|
for (const id of worldIds) {
|
||||||
const fav = records.get(id);
|
const fav = records.get(id);
|
||||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
if (!fav) continue;
|
||||||
|
if (!first) await pause();
|
||||||
|
first = false;
|
||||||
|
await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||||
}
|
}
|
||||||
await reloadMyFavorites();
|
await reloadMyFavorites();
|
||||||
}
|
}
|
||||||
@@ -364,36 +360,7 @@ export async function moveWorldsToFolder(worldIds: string[], folder: string): Pr
|
|||||||
const vrc = requireActiveClient();
|
const vrc = requireActiveClient();
|
||||||
const me = await currentUser();
|
const me = await currentUser();
|
||||||
const records = await favoriteRecords(vrc);
|
const records = await favoriteRecords(vrc);
|
||||||
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
|
return moveManyFavorites(worldMover(vrc, me.id), records, worldIds, folder);
|
||||||
const skipped: string[] = [];
|
|
||||||
let moved = 0;
|
|
||||||
for (const id of worldIds) {
|
|
||||||
if (!(await canRefavorite(vrc, id))) {
|
|
||||||
skipped.push(id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
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> {
|
async function restoreWorldFavorite(vrc: VRChat, fav: WorldFavoriteRecord): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Check, ChevronDown, LogOut, Plus, Users, X } from "lucide-react";
|
import { Check, ChevronDown, LogOut, Plus, Users, X } from "lucide-react";
|
||||||
import { useAuth } from "./AuthContext";
|
import { useAuth } from "./AuthContext";
|
||||||
import { Avatar, StatusDot } from "../../components/ui";
|
import { Avatar, StatusDot } from "../../components/ui";
|
||||||
@@ -161,10 +161,13 @@ function StatusPicker() {
|
|||||||
const current = self?.status ?? "offline";
|
const current = self?.status ?? "offline";
|
||||||
const [desc, setDesc] = useState(self?.statusDescription ?? "");
|
const [desc, setDesc] = useState(self?.statusDescription ?? "");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const descKey = `${self?.id ?? ""}\n${self?.statusDescription ?? ""}`;
|
||||||
|
const [prevDescKey, setPrevDescKey] = useState(descKey);
|
||||||
|
|
||||||
useEffect(() => {
|
if (descKey !== prevDescKey) {
|
||||||
|
setPrevDescKey(descKey);
|
||||||
setDesc(self?.statusDescription ?? "");
|
setDesc(self?.statusDescription ?? "");
|
||||||
}, [self?.id, self?.statusDescription]);
|
}
|
||||||
|
|
||||||
async function apply(status: UserStatus, description: string) {
|
async function apply(status: UserStatus, description: string) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { AccountsState, AuthStatus, TwoFactorMethod } from "../../../../shared/types/auth";
|
import type { AccountsState, AuthStatus, TwoFactorMethod } from "../../../../shared/types/auth";
|
||||||
import { api, events } from "../../lib/api";
|
import { api, events, isRetryableApiError } from "../../lib/api";
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
status: AuthStatus;
|
status: AuthStatus;
|
||||||
@@ -17,6 +17,7 @@ interface AuthContextValue {
|
|||||||
adding: boolean;
|
adding: boolean;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
verify2fa: (method: TwoFactorMethod, code: string) => Promise<void>;
|
verify2fa: (method: TwoFactorMethod, code: string) => Promise<void>;
|
||||||
|
cancel2fa: () => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
switchAccount: (id: string) => Promise<void>;
|
switchAccount: (id: string) => Promise<void>;
|
||||||
removeAccount: (id: string) => Promise<void>;
|
removeAccount: (id: string) => Promise<void>;
|
||||||
@@ -27,6 +28,10 @@ interface AuthContextValue {
|
|||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
const EMPTY: AccountsState = { accounts: [], activeId: null };
|
const EMPTY: AccountsState = { accounts: [], activeId: null };
|
||||||
|
|
||||||
|
function statusOrLoggedOut(): Promise<AuthStatus> {
|
||||||
|
return api.auth.status().catch(() => ({ state: "unauthenticated" as const }));
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [status, setStatus] = useState<AuthStatus>({ state: "unauthenticated" });
|
const [status, setStatus] = useState<AuthStatus>({ state: "unauthenticated" });
|
||||||
const [accounts, setAccounts] = useState<AccountsState>(EMPTY);
|
const [accounts, setAccounts] = useState<AccountsState>(EMPTY);
|
||||||
@@ -41,16 +46,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.auth
|
let cancelled = false;
|
||||||
.status()
|
const check = async (attempt: number): Promise<void> => {
|
||||||
.then(setStatus)
|
try {
|
||||||
.catch(() => setStatus({ state: "unauthenticated" }))
|
const next = await api.auth.status();
|
||||||
.finally(() => setLoading(false));
|
if (cancelled) return;
|
||||||
|
setStatus(next);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (isRetryableApiError(err) && attempt < 3) {
|
||||||
|
window.setTimeout(() => void check(attempt + 1), 1500 * 2 ** attempt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus({ state: "unauthenticated" });
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void check(0);
|
||||||
refreshAccounts();
|
refreshAccounts();
|
||||||
|
|
||||||
const offAuth = events.on("auth:changed", setStatus);
|
const offAuth = events.on("auth:changed", setStatus);
|
||||||
const offAcc = events.on("accounts:changed", setAccounts);
|
const offAcc = events.on("accounts:changed", setAccounts);
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
offAuth();
|
offAuth();
|
||||||
offAcc();
|
offAcc();
|
||||||
};
|
};
|
||||||
@@ -80,10 +99,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
[refreshAccounts],
|
[refreshAccounts],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const cancel2fa = useCallback(async () => {
|
||||||
|
setStatus(await api.auth.cancel2fa());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await api.auth.logout();
|
await api.auth.logout();
|
||||||
const next = await api.auth.status();
|
setStatus(await statusOrLoggedOut());
|
||||||
setStatus(next);
|
|
||||||
refreshAccounts();
|
refreshAccounts();
|
||||||
}, [refreshAccounts]);
|
}, [refreshAccounts]);
|
||||||
|
|
||||||
@@ -98,7 +120,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const removeAccount = useCallback(async (id: string) => {
|
const removeAccount = useCallback(async (id: string) => {
|
||||||
setAccounts(await api.accounts.remove(id));
|
setAccounts(await api.accounts.remove(id));
|
||||||
setStatus(await api.auth.status());
|
setStatus(await statusOrLoggedOut());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const beginAddAccount = useCallback(() => setAdding(true), []);
|
const beginAddAccount = useCallback(() => setAdding(true), []);
|
||||||
@@ -112,6 +134,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
adding,
|
adding,
|
||||||
login,
|
login,
|
||||||
verify2fa,
|
verify2fa,
|
||||||
|
cancel2fa,
|
||||||
logout,
|
logout,
|
||||||
switchAccount,
|
switchAccount,
|
||||||
removeAccount,
|
removeAccount,
|
||||||
@@ -125,6 +148,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
adding,
|
adding,
|
||||||
login,
|
login,
|
||||||
verify2fa,
|
verify2fa,
|
||||||
|
cancel2fa,
|
||||||
logout,
|
logout,
|
||||||
switchAccount,
|
switchAccount,
|
||||||
removeAccount,
|
removeAccount,
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import { Banner, Button, Field } from "../../components/ui";
|
|||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
export function LoginScreen() {
|
export function LoginScreen() {
|
||||||
const { status, login, verify2fa, adding, cancelAddAccount, accounts } = useAuth();
|
const { status, login, verify2fa, cancel2fa, adding, cancelAddAccount, accounts } = useAuth();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const awaiting2fa = status.state === "awaiting2fa";
|
const awaiting2fa = status.state === "awaiting2fa";
|
||||||
const canGoBack = adding && accounts.accounts.length > 0;
|
const canGoBack = awaiting2fa || (adding && accounts.accounts.length > 0);
|
||||||
|
const goBack = awaiting2fa ? () => void cancel2fa() : cancelAddAccount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative grid h-full place-items-center p-6">
|
<div className="relative grid h-full place-items-center p-6">
|
||||||
@@ -19,7 +20,7 @@ export function LoginScreen() {
|
|||||||
{canGoBack ? (
|
{canGoBack ? (
|
||||||
<button
|
<button
|
||||||
className="absolute left-4 top-4 inline-flex items-center gap-1 rounded-sm px-2 py-[5px] text-[13px] text-muted transition-[color,background] duration-[var(--dur)] ease-[var(--ease)] hover:bg-surface-2 hover:text-text"
|
className="absolute left-4 top-4 inline-flex items-center gap-1 rounded-sm px-2 py-[5px] text-[13px] text-muted transition-[color,background] duration-[var(--dur)] ease-[var(--ease)] hover:bg-surface-2 hover:text-text"
|
||||||
onClick={cancelAddAccount}
|
onClick={goBack}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={15} /> {t("auth:back")}
|
<ArrowLeft size={15} /> {t("auth:back")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight, Trash2 } from "lucide-react";
|
import { ChevronDown, ChevronRight, Trash2 } from "lucide-react";
|
||||||
import type { RepoStats, StoredEntity } from "../../../../../shared/types/repository";
|
import type { RepoStats, StoredEntity } from "../../../../../shared/types/repository";
|
||||||
import { Button, CodeBlock, Panel, Stat } from "../../../components/ui";
|
import { Button, CodeBlock, Panel, Stat } from "../../../components/ui";
|
||||||
@@ -137,16 +137,28 @@ function RepoInspector({ name, onBack, now }: { name: string; onBack: () => void
|
|||||||
const [entities, setEntities] = useState<StoredEntity<{ id: string }>[]>([]);
|
const [entities, setEntities] = useState<StoredEntity<{ id: string }>[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [prevName, setPrevName] = useState(name);
|
||||||
|
|
||||||
const reload = () => {
|
if (prevName !== name) {
|
||||||
|
setPrevName(name);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.debug
|
}
|
||||||
|
|
||||||
|
const fetchEntities = useCallback(() => {
|
||||||
|
return api.debug
|
||||||
.repoInspect(name)
|
.repoInspect(name)
|
||||||
.then((e) => setEntities(e))
|
.then((e) => setEntities(e))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
setLoading(true);
|
||||||
|
void fetchEntities();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(reload, [name]);
|
useEffect(() => {
|
||||||
|
void fetchEntities();
|
||||||
|
}, [fetchEntities]);
|
||||||
|
|
||||||
const shown = useMemo(() => {
|
const shown = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ export function GalleryView() {
|
|||||||
const { snap, loading, error, reload, remove, recent } = useGallery();
|
const { snap, loading, error, reload, remove, recent } = useGallery();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
const [selectedRaw, setSelected] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const bandRef = useRef<HTMLDivElement>(null);
|
const bandRef = useRef<HTMLDivElement>(null);
|
||||||
const [width, gridRef] = useWidth();
|
const [width, gridRef] = useWidth();
|
||||||
|
|
||||||
const photos = snap?.photos ?? [];
|
const photos = useMemo(() => snap?.photos ?? [], [snap]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -61,19 +61,15 @@ export function GalleryView() {
|
|||||||
|
|
||||||
const activeIndex = activeId ? filtered.findIndex((p) => p.id === activeId) : -1;
|
const activeIndex = activeId ? filtered.findIndex((p) => p.id === activeId) : -1;
|
||||||
|
|
||||||
useEffect(() => {
|
if (activeId && activeIndex < 0) setActiveId(null);
|
||||||
if (activeId && !filtered.some((p) => p.id === activeId)) setActiveId(null);
|
|
||||||
}, [activeId, filtered]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const selected = useMemo(() => {
|
||||||
setSelected((s) => {
|
if (selectedRaw.size === 0) return selectedRaw;
|
||||||
if (s.size === 0) return s;
|
const live = new Set(photos.map((p) => p.id));
|
||||||
const live = new Set(photos.map((p) => p.id));
|
const next = new Set<string>();
|
||||||
const next = new Set<string>();
|
for (const id of selectedRaw) if (live.has(id)) next.add(id);
|
||||||
for (const id of s) if (live.has(id)) next.add(id);
|
return next.size === selectedRaw.size ? selectedRaw : next;
|
||||||
return next.size === s.size ? s : next;
|
}, [selectedRaw, photos]);
|
||||||
});
|
|
||||||
}, [photos]);
|
|
||||||
|
|
||||||
const selecting = selected.size > 0;
|
const selecting = selected.size > 0;
|
||||||
const toggle = useCallback((id: string) => {
|
const toggle = useCallback((id: string) => {
|
||||||
@@ -128,7 +124,9 @@ export function GalleryView() {
|
|||||||
}, [activeIndex, filtered, deleteIds]);
|
}, [activeIndex, filtered, deleteIds]);
|
||||||
|
|
||||||
const selectedRef = useRef(selected);
|
const selectedRef = useRef(selected);
|
||||||
selectedRef.current = selected;
|
useEffect(() => {
|
||||||
|
selectedRef.current = selected;
|
||||||
|
}, [selected]);
|
||||||
const startRef = useRef<{ x: number; y: number } | null>(null);
|
const startRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const baseRef = useRef<Set<string>>(new Set());
|
const baseRef = useRef<Set<string>>(new Set());
|
||||||
const rectsRef = useRef<{ id: string; r: DOMRect }[]>([]);
|
const rectsRef = useRef<{ id: string; r: DOMRect }[]>([]);
|
||||||
|
|||||||
@@ -19,16 +19,20 @@ export function useGallery(): GalleryData {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [recent, setRecent] = useState<ReadonlySet<string>>(() => new Set());
|
const [recent, setRecent] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const fetchSnapshot = useCallback(() => {
|
||||||
setLoading(true);
|
return api.gallery
|
||||||
setError(null);
|
|
||||||
api.gallery
|
|
||||||
.snapshot()
|
.snapshot()
|
||||||
.then(setSnap)
|
.then(setSnap)
|
||||||
.catch((e) => setError(errorMessage(e, "Could not load your gallery.")))
|
.catch((e) => setError(errorMessage(e, "Could not load your gallery.")))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
void fetchSnapshot();
|
||||||
|
}, [fetchSnapshot]);
|
||||||
|
|
||||||
const remove = useCallback(
|
const remove = useCallback(
|
||||||
async (ids: string[]) => {
|
async (ids: string[]) => {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
@@ -44,7 +48,9 @@ export function useGallery(): GalleryData {
|
|||||||
[reload],
|
[reload],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(reload, [reload]);
|
useEffect(() => {
|
||||||
|
void fetchSnapshot();
|
||||||
|
}, [fetchSnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return events.on("gallery:added", (photo) => {
|
return events.on("gallery:added", (photo) => {
|
||||||
|
|||||||
@@ -310,10 +310,12 @@ function GameSection() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [ok, setOk] = useState<string | null>(null);
|
const [ok, setOk] = useState<string | null>(null);
|
||||||
|
const [prevPath, setPrevPath] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
if ((config?.gamePath ?? "") !== prevPath) {
|
||||||
|
setPrevPath(config?.gamePath ?? "");
|
||||||
setPath(config?.gamePath ?? "");
|
setPath(config?.gamePath ?? "");
|
||||||
}, [config?.gamePath]);
|
}
|
||||||
|
|
||||||
async function run(p: Promise<AppConfig>, okMsg: string) {
|
async function run(p: Promise<AppConfig>, okMsg: string) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const api = {
|
|||||||
login: (username: string, password: string) => call("auth:login", { username, password }),
|
login: (username: string, password: string) => call("auth:login", { username, password }),
|
||||||
verify2fa: (method: "totp" | "emailOtp", code: string) =>
|
verify2fa: (method: "totp" | "emailOtp", code: string) =>
|
||||||
call("auth:verify2fa", { method, code }),
|
call("auth:verify2fa", { method, code }),
|
||||||
|
cancel2fa: () => call("auth:cancel2fa"),
|
||||||
logout: () => call("auth:logout"),
|
logout: () => call("auth:logout"),
|
||||||
},
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
|
|||||||
@@ -6,16 +6,25 @@ export type Async<T> =
|
|||||||
| { status: "error"; message: string }
|
| { status: "error"; message: string }
|
||||||
| { status: "ready"; data: T };
|
| { status: "ready"; data: T };
|
||||||
|
|
||||||
|
function sameDeps(a: DependencyList, b: DependencyList): boolean {
|
||||||
|
return a.length === b.length && a.every((v, i) => Object.is(v, b[i]));
|
||||||
|
}
|
||||||
|
|
||||||
export function useAsync<T>(
|
export function useAsync<T>(
|
||||||
load: () => Promise<T>,
|
load: () => Promise<T>,
|
||||||
deps: DependencyList,
|
deps: DependencyList,
|
||||||
fallback = "Something went wrong.",
|
fallback = "Something went wrong.",
|
||||||
): Async<T> {
|
): Async<T> {
|
||||||
const [state, setState] = useState<Async<T>>({ status: "loading" });
|
const [state, setState] = useState<Async<T>>({ status: "loading" });
|
||||||
|
const [prevDeps, setPrevDeps] = useState(deps);
|
||||||
|
|
||||||
|
if (!sameDeps(prevDeps, deps)) {
|
||||||
|
setPrevDeps(deps);
|
||||||
|
setState({ status: "loading" });
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setState({ status: "loading" });
|
|
||||||
load()
|
load()
|
||||||
.then((data) => active && setState({ status: "ready", data }))
|
.then((data) => active && setState({ status: "ready", data }))
|
||||||
.catch(
|
.catch(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import type {
|
import type {
|
||||||
@@ -47,13 +47,15 @@ const failed = new Set<string>();
|
|||||||
|
|
||||||
export function useAvatar(avatarId?: string): { avatar?: Avatar; failed: boolean } {
|
export function useAvatar(avatarId?: string): { avatar?: Avatar; failed: boolean } {
|
||||||
const avatar = useAvatars((s) => (avatarId ? s.avatars[avatarId] : undefined));
|
const avatar = useAvatars((s) => (avatarId ? s.avatars[avatarId] : undefined));
|
||||||
if (avatarId && !avatar && !fetching.has(avatarId) && !failed.has(avatarId)) {
|
const missing = Boolean(avatarId) && !avatar;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!missing || !avatarId || fetching.has(avatarId) || failed.has(avatarId)) return;
|
||||||
fetching.add(avatarId);
|
fetching.add(avatarId);
|
||||||
api.avatar
|
api.avatar
|
||||||
.get(avatarId)
|
.get(avatarId)
|
||||||
.catch(() => failed.add(avatarId))
|
.catch(() => failed.add(avatarId))
|
||||||
.finally(() => fetching.delete(avatarId));
|
.finally(() => fetching.delete(avatarId));
|
||||||
}
|
}, [missing, avatarId]);
|
||||||
return { avatar, failed: avatarId ? failed.has(avatarId) : false };
|
return { avatar, failed: avatarId ? failed.has(avatarId) : false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import type { Group, GroupSnapshot } from "../../../shared/types/group";
|
import type { Group, GroupSnapshot } from "../../../shared/types/group";
|
||||||
@@ -36,13 +37,15 @@ const failed = new Set<string>();
|
|||||||
|
|
||||||
export function useGroup(groupId?: string): Group | undefined {
|
export function useGroup(groupId?: string): Group | undefined {
|
||||||
const group = useGroups((s) => (groupId ? s.groups[groupId] : undefined));
|
const group = useGroups((s) => (groupId ? s.groups[groupId] : undefined));
|
||||||
if (groupId && !group?.detailed && !fetching.has(groupId) && !failed.has(groupId)) {
|
const missing = Boolean(groupId) && !group?.detailed;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!missing || !groupId || fetching.has(groupId) || failed.has(groupId)) return;
|
||||||
fetching.add(groupId);
|
fetching.add(groupId);
|
||||||
api.group
|
api.group
|
||||||
.get(groupId)
|
.get(groupId)
|
||||||
.catch(() => failed.add(groupId))
|
.catch(() => failed.add(groupId))
|
||||||
.finally(() => fetching.delete(groupId));
|
.finally(() => fetching.delete(groupId));
|
||||||
}
|
}, [missing, groupId]);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import type { World, WorldSnapshot } from "../../../shared/types/world";
|
import type { World, WorldSnapshot } from "../../../shared/types/world";
|
||||||
@@ -56,13 +57,15 @@ const failed = new Set<string>();
|
|||||||
|
|
||||||
export function useWorld(worldId?: string): World | undefined {
|
export function useWorld(worldId?: string): World | undefined {
|
||||||
const world = useWorlds((s) => (worldId ? s.worlds[worldId] : undefined));
|
const world = useWorlds((s) => (worldId ? s.worlds[worldId] : undefined));
|
||||||
if (worldId && !world && !fetching.has(worldId) && !failed.has(worldId)) {
|
const missing = Boolean(worldId) && !world;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!missing || !worldId || fetching.has(worldId) || failed.has(worldId)) return;
|
||||||
fetching.add(worldId);
|
fetching.add(worldId);
|
||||||
api.world
|
api.world
|
||||||
.get(worldId)
|
.get(worldId)
|
||||||
.catch(() => failed.add(worldId))
|
.catch(() => failed.add(worldId))
|
||||||
.finally(() => fetching.delete(worldId));
|
.finally(() => fetching.delete(worldId));
|
||||||
}
|
}, [missing, worldId]);
|
||||||
return world;
|
return world;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface IpcRequests {
|
|||||||
"auth:status": () => IpcResult<AuthStatus>;
|
"auth:status": () => IpcResult<AuthStatus>;
|
||||||
"auth:login": (creds: LoginCredentials) => IpcResult<AuthStatus>;
|
"auth:login": (creds: LoginCredentials) => IpcResult<AuthStatus>;
|
||||||
"auth:verify2fa": (payload: TwoFactorPayload) => IpcResult<AuthStatus>;
|
"auth:verify2fa": (payload: TwoFactorPayload) => IpcResult<AuthStatus>;
|
||||||
|
"auth:cancel2fa": () => IpcResult<AuthStatus>;
|
||||||
"auth:logout": () => IpcResult<void>;
|
"auth:logout": () => IpcResult<void>;
|
||||||
|
|
||||||
"accounts:list": () => IpcResult<AccountsState>;
|
"accounts:list": () => IpcResult<AccountsState>;
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ export interface AvatarEdit {
|
|||||||
releaseStatus?: string;
|
releaseStatus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FavoriteVisibility = "private" | "friends" | "public";
|
export type {
|
||||||
|
FavoriteVisibility,
|
||||||
|
FavoriteLimits,
|
||||||
|
FavoriteGroupEdit,
|
||||||
|
MoveResult,
|
||||||
|
} from "./favorites";
|
||||||
|
import type { FavoriteVisibility, FavoriteLimits } from "./favorites";
|
||||||
|
|
||||||
export interface FavoriteAvatarFolder {
|
export interface FavoriteAvatarFolder {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -38,21 +44,6 @@ export interface FavoriteAvatarFolder {
|
|||||||
avatarIds: string[];
|
avatarIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FavoriteLimits {
|
|
||||||
maxGroups: number;
|
|
||||||
maxPerGroup: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FavoriteGroupEdit {
|
|
||||||
displayName?: string;
|
|
||||||
visibility?: FavoriteVisibility;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MoveResult {
|
|
||||||
moved: number;
|
|
||||||
skipped: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AvatarSnapshot {
|
export interface AvatarSnapshot {
|
||||||
avatars: Avatar[];
|
avatars: Avatar[];
|
||||||
mineIds: string[];
|
mineIds: string[];
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type FavoriteVisibility = "private" | "friends" | "public";
|
||||||
|
|
||||||
|
export interface FavoriteLimits {
|
||||||
|
maxGroups: number;
|
||||||
|
maxPerGroup: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoriteGroupEdit {
|
||||||
|
displayName?: string;
|
||||||
|
visibility?: FavoriteVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveResult {
|
||||||
|
moved: number;
|
||||||
|
skipped: string[];
|
||||||
|
}
|
||||||
@@ -47,7 +47,13 @@ export interface FavoriteWorldFolder {
|
|||||||
worldIds: string[];
|
worldIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FavoriteVisibility = "private" | "friends" | "public";
|
export type {
|
||||||
|
FavoriteVisibility,
|
||||||
|
FavoriteLimits,
|
||||||
|
FavoriteGroupEdit,
|
||||||
|
MoveResult,
|
||||||
|
} from "./favorites";
|
||||||
|
import type { FavoriteVisibility, FavoriteLimits } from "./favorites";
|
||||||
|
|
||||||
export interface FavoriteWorldGroup {
|
export interface FavoriteWorldGroup {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -57,21 +63,6 @@ export interface FavoriteWorldGroup {
|
|||||||
vrcPlus: boolean;
|
vrcPlus: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FavoriteLimits {
|
|
||||||
maxGroups: number;
|
|
||||||
maxPerGroup: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FavoriteGroupEdit {
|
|
||||||
displayName?: string;
|
|
||||||
visibility?: FavoriteVisibility;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MoveResult {
|
|
||||||
moved: number;
|
|
||||||
skipped: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorldFavoritesSnapshot {
|
export interface WorldFavoritesSnapshot {
|
||||||
groups: FavoriteWorldGroup[];
|
groups: FavoriteWorldGroup[];
|
||||||
limits: FavoriteLimits;
|
limits: FavoriteLimits;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { fillSlots, normalizeVisibility, orderSlots } from "../src/main/vrchat/favoriteFolders";
|
||||||
|
|
||||||
|
const folder = (name: string) => ({ name });
|
||||||
|
|
||||||
|
describe("normalizeVisibility", () => {
|
||||||
|
it("passes through known values and defaults the rest to private", () => {
|
||||||
|
expect(normalizeVisibility("friends")).toBe("friends");
|
||||||
|
expect(normalizeVisibility("public")).toBe("public");
|
||||||
|
expect(normalizeVisibility("private")).toBe("private");
|
||||||
|
expect(normalizeVisibility("whatever")).toBe("private");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("orderSlots", () => {
|
||||||
|
it("puts custom-named folders before numbered slots, slots sorted numerically", () => {
|
||||||
|
const out = orderSlots(
|
||||||
|
[folder("worlds10"), folder("cool stuff"), folder("worlds2"), folder("worlds1")],
|
||||||
|
"worlds",
|
||||||
|
);
|
||||||
|
expect(out.map((f) => f.name)).toEqual(["cool stuff", "worlds1", "worlds2", "worlds10"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fillSlots", () => {
|
||||||
|
it("pads with empty numbered slots up to the cap, skipping taken names", () => {
|
||||||
|
const out = fillSlots([folder("worlds2")], "worlds", 3, folder);
|
||||||
|
expect(out.map((f) => f.name)).toEqual(["worlds2", "worlds1", "worlds3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pad past the cap when custom folders use up slots", () => {
|
||||||
|
const out = fillSlots([folder("a"), folder("b")], "worlds", 2, folder);
|
||||||
|
expect(out.map((f) => f.name)).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
moveOneFavorite,
|
||||||
|
moveManyFavorites,
|
||||||
|
type FavoriteMover,
|
||||||
|
} from "../src/main/vrchat/favoriteMove";
|
||||||
|
|
||||||
|
interface Rec {
|
||||||
|
id: string;
|
||||||
|
favoriteId: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mover(overrides: Partial<FavoriteMover<Rec>> = {}) {
|
||||||
|
const m = {
|
||||||
|
skip: vi.fn((rec: Rec | undefined, folder: string) => rec?.tags.includes(folder) ?? false),
|
||||||
|
canRefavorite: vi.fn(async () => true),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
|
add: vi.fn(async () => {}),
|
||||||
|
restore: vi.fn(async () => {}),
|
||||||
|
reload: vi.fn(async () => {}),
|
||||||
|
onMoved: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = (id: string, tags: string[]): Rec => ({ id: `fav-${id}`, favoriteId: id, tags });
|
||||||
|
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe("moveOneFavorite", () => {
|
||||||
|
it("moves: remove, add, reload, notify", async () => {
|
||||||
|
const m = mover();
|
||||||
|
const result = await moveOneFavorite(m, rec("w1", ["old"]), "w1", "new", true);
|
||||||
|
expect(result).toEqual({ moved: 1, skipped: [] });
|
||||||
|
expect(m.remove).toHaveBeenCalledOnce();
|
||||||
|
expect(m.add).toHaveBeenCalledWith("w1", "new");
|
||||||
|
expect(m.reload).toHaveBeenCalledOnce();
|
||||||
|
expect(m.onMoved).toHaveBeenCalledWith("w1", "new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops when already in the folder", async () => {
|
||||||
|
const m = mover();
|
||||||
|
const result = await moveOneFavorite(m, rec("w1", ["new"]), "w1", "new", true);
|
||||||
|
expect(result).toEqual({ moved: 0, skipped: [] });
|
||||||
|
expect(m.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips without removing when the item can't be re-favorited", async () => {
|
||||||
|
const m = mover({ canRefavorite: vi.fn(async () => false) });
|
||||||
|
const result = await moveOneFavorite(m, rec("w1", ["old"]), "w1", "new", true);
|
||||||
|
expect(result).toEqual({ moved: 0, skipped: ["w1"] });
|
||||||
|
expect(m.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the old favorite when add fails", async () => {
|
||||||
|
const m = mover({ add: vi.fn(async () => Promise.reject({ status: 400 })) });
|
||||||
|
const old = rec("w1", ["old"]);
|
||||||
|
const result = await moveOneFavorite(m, old, "w1", "new", true);
|
||||||
|
expect(result).toEqual({ moved: 0, skipped: ["w1"] });
|
||||||
|
expect(m.restore).toHaveBeenCalledWith(old);
|
||||||
|
expect(m.onMoved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rethrows transient failures after restoring", async () => {
|
||||||
|
const m = mover({ add: vi.fn(async () => Promise.reject({ status: 429 })) });
|
||||||
|
await expect(moveOneFavorite(m, rec("w1", ["old"]), "w1", "new", true)).rejects.toMatchObject({
|
||||||
|
status: 429,
|
||||||
|
});
|
||||||
|
expect(m.restore).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moveManyFavorites", () => {
|
||||||
|
it("moves everything movable and reports the rest as skipped", async () => {
|
||||||
|
const m = mover({
|
||||||
|
canRefavorite: vi.fn(async (id: string) => id !== "w3"),
|
||||||
|
});
|
||||||
|
const records = new Map([
|
||||||
|
["w1", rec("w1", ["old"])],
|
||||||
|
["w2", rec("w2", ["new"])],
|
||||||
|
["w3", rec("w3", ["old"])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const done = moveManyFavorites(m, records, ["w1", "w2", "w3", "w4"], "new");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result = await done;
|
||||||
|
|
||||||
|
expect(result).toEqual({ moved: 2, skipped: ["w3"] });
|
||||||
|
expect(m.add).toHaveBeenCalledWith("w1", "new");
|
||||||
|
expect(m.add).toHaveBeenCalledWith("w4", "new");
|
||||||
|
expect(m.reload).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts on a transient failure after restoring the current item", async () => {
|
||||||
|
const m = mover({
|
||||||
|
add: vi.fn(async (id: string) => {
|
||||||
|
if (id === "w2") throw { status: 500 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const records = new Map([
|
||||||
|
["w1", rec("w1", ["old"])],
|
||||||
|
["w2", rec("w2", ["old"])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const done = moveManyFavorites(m, records, ["w1", "w2"], "new");
|
||||||
|
done.catch(() => {});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
await expect(done).rejects.toMatchObject({ status: 500 });
|
||||||
|
expect(m.restore).toHaveBeenCalledWith(records.get("w2"));
|
||||||
|
expect(m.reload).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { Repository } from "../src/main/store/repository/repository";
|
||||||
|
import type { StorageBackend } from "../src/main/store/repository/backend";
|
||||||
|
import type { StoredEntity } from "../src/shared/types/repository";
|
||||||
|
import type { FieldPolicy } from "../src/main/store/repository/fieldPolicy";
|
||||||
|
|
||||||
|
interface Thing {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
bio?: string;
|
||||||
|
occupants?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryBackend<T>(): StorageBackend<T> {
|
||||||
|
const map = new Map<string, StoredEntity<T>>();
|
||||||
|
return {
|
||||||
|
load: () => new Map(map),
|
||||||
|
put: (id, e) => void map.set(id, e),
|
||||||
|
remove: (id) => void map.delete(id),
|
||||||
|
flush: () => {},
|
||||||
|
clear: () => map.clear(),
|
||||||
|
file: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy: FieldPolicy<Thing> = {
|
||||||
|
classOf: (f) => (f === "occupants" || f === "bio" ? "live" : "identity"),
|
||||||
|
maxAge: { identity: 60_000, stat: 60_000, live: 1000 },
|
||||||
|
keepNonEmpty: new Set(["bio"]),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeRepo() {
|
||||||
|
return new Repository<Thing>({ name: "things", policy, backend: memoryBackend() });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe("Repository.upsert — source priority on live fields", () => {
|
||||||
|
it("a lower-priority source cannot overwrite a live field set by ws", () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
repo.upsert({ id: "a", name: "A", occupants: 5 }, "ws");
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
repo.upsert({ id: "a", occupants: 1 }, "rest:list");
|
||||||
|
expect(repo.get("a")?.occupants).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("equal-priority live writes apply when newer", () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
repo.upsert({ id: "a", name: "A", occupants: 5 }, "ws");
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
repo.upsert({ id: "a", occupants: 9 }, "ws");
|
||||||
|
expect(repo.get("a")?.occupants).toBe(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Repository.upsert — timestamp ordering", () => {
|
||||||
|
it("ignores writes older than the stored field", () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const now = Date.now();
|
||||||
|
repo.upsert({ id: "a", name: "new" }, "rest:detail", now);
|
||||||
|
repo.upsert({ id: "a", name: "old" }, "rest:detail", now - 1000);
|
||||||
|
expect(repo.get("a")?.name).toBe("new");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Repository.upsert — empty-value guards", () => {
|
||||||
|
it("an empty identity value never wipes a resolved one", () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
repo.upsert({ id: "a", name: "Resolved" }, "rest:detail");
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
repo.upsert({ id: "a", name: "" }, "rest:detail");
|
||||||
|
expect(repo.get("a")?.name).toBe("Resolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keepNonEmpty fields survive empty writes from lower-priority sources", () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
repo.upsert({ id: "a", name: "A", bio: "hello" }, "rest:detail");
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
repo.upsert({ id: "a", bio: "" }, "rest:list");
|
||||||
|
expect(repo.get("a")?.bio).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rest:detail read may clear a keepNonEmpty field", () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
repo.upsert({ id: "a", name: "A", bio: "hello" }, "rest:detail");
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
repo.upsert({ id: "a", bio: "" }, "rest:detail");
|
||||||
|
expect(repo.get("a")?.bio).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Repository.upsert — change notification", () => {
|
||||||
|
it("emits on real changes and stays silent on no-op writes", () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const seen = vi.fn();
|
||||||
|
repo.onChange(seen);
|
||||||
|
|
||||||
|
repo.upsert({ id: "a", name: "A" }, "rest:detail");
|
||||||
|
expect(seen).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
repo.upsert({ id: "a", name: "" }, "rest:detail");
|
||||||
|
expect(seen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Repository.isStale", () => {
|
||||||
|
it("reports live fields stale after their max age and identity fields fresh", () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
repo.upsert({ id: "a", name: "A", occupants: 3 }, "ws");
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
expect(repo.isStale("a", "occupants")).toBe(true);
|
||||||
|
expect(repo.isStale("a", "name")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user