mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
✨ feat: Initial commit
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
AccountsState,
|
||||
AuthStatus,
|
||||
CurrentUserSummary,
|
||||
LoginCredentials,
|
||||
TwoFactorMethod,
|
||||
TwoFactorPayload,
|
||||
} from "../../shared/types/auth";
|
||||
import { clearLoginClient, createLoginClient, dropClient, getActiveClient } from "./client";
|
||||
import {
|
||||
clearPending,
|
||||
listAccounts,
|
||||
promotePending,
|
||||
removeAccount,
|
||||
setActive,
|
||||
} from "../accounts/store";
|
||||
import { toCurrentUserSummary } from "./mappers";
|
||||
import { userCache } from "./userService";
|
||||
import { clearSessionCookies, syncSessionCookies } from "./cookies";
|
||||
import { logger } from "../debug/logger";
|
||||
import { seedActiveAccount } from "../store/social";
|
||||
import { entityStore } from "../store/entityStore";
|
||||
import { repos } from "../store/repository/manager";
|
||||
|
||||
interface VRChatLike {
|
||||
login: (opts: {
|
||||
username: string;
|
||||
password: string;
|
||||
twoFactorCode?: () => Promise<string> | string;
|
||||
throwOnError?: boolean;
|
||||
}) => Promise<unknown>;
|
||||
getCurrentUser: (opts?: { throwOnError?: boolean }) => Promise<{ data?: unknown }>;
|
||||
logout?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
type RawUser = Parameters<typeof toCurrentUserSummary>[0];
|
||||
|
||||
function isRealUser(data: unknown): data is RawUser {
|
||||
return (
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
"id" in data &&
|
||||
"displayName" in data &&
|
||||
!("requiresTwoFactorAuth" in data)
|
||||
);
|
||||
}
|
||||
|
||||
async function summaryFrom(vrc: VRChatLike): Promise<CurrentUserSummary> {
|
||||
const { data } = await vrc.getCurrentUser({ throwOnError: true });
|
||||
if (!isRealUser(data)) throw new Error("not authenticated");
|
||||
return toCurrentUserSummary(data);
|
||||
}
|
||||
|
||||
let pendingTwoFactor: {
|
||||
resolveCode: (code: string) => void;
|
||||
methods: TwoFactorMethod[];
|
||||
loginDone: Promise<AuthStatus>;
|
||||
} | null = null;
|
||||
|
||||
async function finalizeLogin(vrc: VRChatLike): Promise<AuthStatus> {
|
||||
const user = await summaryFrom(vrc);
|
||||
promotePending({
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
userIcon: user.userIcon || user.currentAvatarThumbnailImageUrl,
|
||||
});
|
||||
clearLoginClient();
|
||||
userCache.clear();
|
||||
await syncSessionCookies(vrc);
|
||||
logger.info("auth", `signed in as ${user.displayName}`);
|
||||
void seedActiveAccount(true);
|
||||
return { state: "authenticated", user };
|
||||
}
|
||||
|
||||
export async function checkStatus(): Promise<AuthStatus> {
|
||||
const vrc = getActiveClient() as unknown as VRChatLike | null;
|
||||
if (!vrc) return { state: "unauthenticated" };
|
||||
try {
|
||||
const user = await summaryFrom(vrc);
|
||||
await syncSessionCookies(vrc);
|
||||
void seedActiveAccount();
|
||||
return { state: "authenticated", user };
|
||||
} catch {
|
||||
return { state: "unauthenticated" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(creds: LoginCredentials): Promise<AuthStatus> {
|
||||
clearPending();
|
||||
const vrc = createLoginClient() as unknown as VRChatLike;
|
||||
|
||||
let resolveCode!: (code: string) => void;
|
||||
let rejectCode!: (err: unknown) => void;
|
||||
const codePromise = new Promise<string>((res, rej) => {
|
||||
resolveCode = res;
|
||||
rejectCode = rej;
|
||||
});
|
||||
|
||||
let signalAwaiting!: (methods: TwoFactorMethod[]) => void;
|
||||
const awaiting = new Promise<TwoFactorMethod[]>((res) => {
|
||||
signalAwaiting = res;
|
||||
});
|
||||
|
||||
const loginDone: Promise<AuthStatus> = vrc
|
||||
.login({
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
throwOnError: true,
|
||||
twoFactorCode: async () => {
|
||||
signalAwaiting(["totp", "emailOtp"]);
|
||||
return codePromise;
|
||||
},
|
||||
})
|
||||
.then(() => finalizeLogin(vrc))
|
||||
.catch((err) => {
|
||||
rejectCode(err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
const winner = await Promise.race([
|
||||
loginDone.then((status) => ({ kind: "done" as const, status })),
|
||||
awaiting.then((methods) => ({ kind: "await" as const, methods })),
|
||||
]);
|
||||
|
||||
if (winner.kind === "done") {
|
||||
pendingTwoFactor = null;
|
||||
return winner.status;
|
||||
}
|
||||
|
||||
pendingTwoFactor = { resolveCode, methods: winner.methods, loginDone };
|
||||
return { state: "awaiting2fa", methods: winner.methods };
|
||||
}
|
||||
|
||||
export async function verify2fa(payload: TwoFactorPayload): Promise<AuthStatus> {
|
||||
if (!pendingTwoFactor) return { state: "unauthenticated" };
|
||||
const { resolveCode, loginDone } = pendingTwoFactor;
|
||||
resolveCode(payload.code);
|
||||
try {
|
||||
const status = await loginDone;
|
||||
pendingTwoFactor = null;
|
||||
return status;
|
||||
} catch {
|
||||
pendingTwoFactor = null;
|
||||
return { state: "unauthenticated" };
|
||||
}
|
||||
}
|
||||
|
||||
export function listAccountsState(): AccountsState {
|
||||
return listAccounts();
|
||||
}
|
||||
|
||||
export async function switchAccount(id: string): Promise<AuthStatus> {
|
||||
setActive(id);
|
||||
userCache.clear();
|
||||
logger.info("auth", `switched account → ${id}`);
|
||||
return checkStatus();
|
||||
}
|
||||
|
||||
export function removeAccountAction(id: string): AccountsState {
|
||||
dropClient(id);
|
||||
userCache.clear();
|
||||
repos.destroy(id);
|
||||
return removeAccount(id);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
const id = listAccounts().activeId;
|
||||
const vrc = getActiveClient() as unknown as VRChatLike | null;
|
||||
try {
|
||||
await vrc?.logout?.();
|
||||
} catch {}
|
||||
pendingTwoFactor = null;
|
||||
entityStore.clear();
|
||||
await clearSessionCookies();
|
||||
if (id) removeAccountAction(id);
|
||||
const next = getActiveClient();
|
||||
if (next) {
|
||||
await syncSessionCookies(next);
|
||||
void seedActiveAccount(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Avatar } from "../../shared/types/avatar";
|
||||
import { toAvatar } from "./mappers";
|
||||
import { cachedRead } from "./cachedRead";
|
||||
import { repos } from "../store/repository/manager";
|
||||
import { cacheKeys, policies } from "../cache/policies";
|
||||
|
||||
export async function getAvatar(avatarId: string): Promise<Avatar> {
|
||||
const avatar = await cachedRead(cacheKeys.avatar(avatarId), policies.avatar, async (vrc) => {
|
||||
const { data } = await vrc.getAvatar({ path: { avatarId }, throwOnError: true });
|
||||
return toAvatar(data);
|
||||
});
|
||||
repos.active.avatars.upsert(avatar, "rest:detail");
|
||||
return avatar;
|
||||
}
|
||||
|
||||
export async function getFavoritedAvatars(): Promise<Avatar[]> {
|
||||
const avatars = await cachedRead(
|
||||
cacheKeys.avatarFavorites(),
|
||||
policies.avatarFavorites,
|
||||
async (vrc) => {
|
||||
const { data } = await vrc.getFavoritedAvatars({ query: { n: 100 }, throwOnError: true });
|
||||
return data.map(toAvatar);
|
||||
},
|
||||
);
|
||||
repos.active.avatars.upsertMany(avatars, "rest:list");
|
||||
return avatars;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { VRChat } from "vrchat";
|
||||
import type { CachePolicy } from "../cache/cache";
|
||||
import { requireActiveClient } from "./client";
|
||||
import { userCache } from "./userService";
|
||||
|
||||
export function cachedRead<T>(
|
||||
key: string,
|
||||
policy: CachePolicy,
|
||||
load: (vrc: VRChat) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const vrc = requireActiveClient();
|
||||
return userCache.get(key, policy, () => load(vrc));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { app } from "electron";
|
||||
import { KeyvFile } from "keyv-file";
|
||||
import { VRChat } from "vrchat";
|
||||
import { activeId, pendingFile, sessionFile } from "../accounts/store";
|
||||
|
||||
const APP_META = {
|
||||
name: "VRC-Circle",
|
||||
version: app.getVersion(),
|
||||
contact: "contact@kirameki.cafe",
|
||||
} as const;
|
||||
|
||||
const clients = new Map<string, VRChat>();
|
||||
let loginClient: VRChat | null = null;
|
||||
|
||||
function build(filename: string): VRChat {
|
||||
const store = new KeyvFile({ filename });
|
||||
return new VRChat({
|
||||
application: APP_META,
|
||||
keyv: store as unknown as ConstructorParameters<typeof VRChat>[0]["keyv"],
|
||||
authentication: { optimistic: false },
|
||||
});
|
||||
}
|
||||
|
||||
export function getClient(id: string): VRChat {
|
||||
let c = clients.get(id);
|
||||
if (!c) {
|
||||
c = build(sessionFile(id));
|
||||
clients.set(id, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
export function getActiveClient(): VRChat | null {
|
||||
const id = activeId();
|
||||
return id ? getClient(id) : null;
|
||||
}
|
||||
|
||||
export function requireActiveClient(): VRChat {
|
||||
const vrc = getActiveClient();
|
||||
if (!vrc) throw { status: 401, message: "No active account" };
|
||||
return vrc;
|
||||
}
|
||||
|
||||
export function createLoginClient(): VRChat {
|
||||
loginClient = build(pendingFile());
|
||||
return loginClient;
|
||||
}
|
||||
|
||||
export function clearLoginClient(): void {
|
||||
loginClient = null;
|
||||
}
|
||||
|
||||
export function dropClient(id: string): void {
|
||||
clients.delete(id);
|
||||
}
|
||||
|
||||
export function closeClients(): void {
|
||||
for (const client of clients.values()) client.pipeline.close();
|
||||
loginClient?.pipeline.close();
|
||||
}
|
||||
|
||||
export async function getPipelineAuthToken(vrc: VRChat): Promise<string | null> {
|
||||
const getCookies = (
|
||||
vrc as unknown as { getCookies?: () => Promise<{ name: string; value: string }[]> }
|
||||
).getCookies;
|
||||
const cookies = (await getCookies?.()) ?? [];
|
||||
return cookies.find((c) => c.name === "auth")?.value ?? null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { session } from "electron";
|
||||
|
||||
interface RawCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
expires?: number | null;
|
||||
}
|
||||
interface CookieSource {
|
||||
getCookies?: () => Promise<RawCookie[]>;
|
||||
}
|
||||
|
||||
const AUTH_COOKIES = new Set(["auth", "twoFactorAuth"]);
|
||||
|
||||
export async function syncSessionCookies(vrc: unknown): Promise<void> {
|
||||
const src = vrc as CookieSource | null;
|
||||
if (!src?.getCookies) return;
|
||||
|
||||
let cookies: RawCookie[];
|
||||
try {
|
||||
cookies = await src.getCookies();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const jar = session.defaultSession.cookies;
|
||||
for (const c of cookies) {
|
||||
if (!AUTH_COOKIES.has(c.name)) continue;
|
||||
try {
|
||||
await jar.set({
|
||||
url: "https://api.vrchat.cloud",
|
||||
domain: ".vrchat.cloud",
|
||||
path: "/",
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
expirationDate: c.expires ? c.expires / 1000 : undefined,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearSessionCookies(): Promise<void> {
|
||||
const jar = session.defaultSession.cookies;
|
||||
for (const name of AUTH_COOKIES) {
|
||||
try {
|
||||
await jar.remove("https://api.vrchat.cloud", name);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ApiError, ApiErrorCode, IpcResult } from "../../shared/types/result";
|
||||
|
||||
interface HttpLike {
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
status_code?: number;
|
||||
response?: { status?: number; headers?: Record<string, string> };
|
||||
headers?: Record<string, string>;
|
||||
message?: string;
|
||||
code?: ApiErrorCode;
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
const e = (err ?? {}) as HttpLike;
|
||||
const status = statusOf(e);
|
||||
const message = e.message ?? "Unexpected error";
|
||||
|
||||
if (e.code) return { code: e.code, message, methods: e.methods };
|
||||
|
||||
let code: ApiErrorCode = "unknown";
|
||||
if (status === 401) code = "unauthorized";
|
||||
else if (status === 404) code = "not_found";
|
||||
else if (status === 429) code = "rate_limited";
|
||||
else if (status === undefined && /network|fetch|ENOTFOUND|ECONN/i.test(message)) code = "network";
|
||||
|
||||
return { code, message, retryAfter: retryAfterOf(e) };
|
||||
}
|
||||
|
||||
export async function guard<T>(fn: () => Promise<T>): Promise<IpcResult<T>> {
|
||||
try {
|
||||
return { ok: true, data: await fn() };
|
||||
} catch (err) {
|
||||
return { ok: false, error: toApiError(err) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { UserProfile } from "../../shared/types/user";
|
||||
import { requireActiveClient } from "./client";
|
||||
import { toUserProfile } from "./mappers";
|
||||
import { currentUser, userCache } from "./userService";
|
||||
import { cacheKeys, policies } from "../cache/policies";
|
||||
|
||||
const order: Record<string, number> = {
|
||||
"join me": 0,
|
||||
active: 1,
|
||||
"ask me": 2,
|
||||
busy: 3,
|
||||
offline: 4,
|
||||
};
|
||||
|
||||
export async function listFriends(): Promise<UserProfile[]> {
|
||||
const vrc = requireActiveClient();
|
||||
const self = (await currentUser()).id;
|
||||
|
||||
return userCache.get(cacheKeys.friends(), policies.friends, async () => {
|
||||
const [online, offline] = await Promise.all([
|
||||
vrc.getFriends({ query: { offline: false, n: 100 }, throwOnError: true }),
|
||||
vrc.getFriends({ query: { offline: true, n: 100 }, throwOnError: true }),
|
||||
]);
|
||||
const friends = [
|
||||
...(online.data ?? []).map((u) => ({ ...toUserProfile(u, self), state: "online" as const })),
|
||||
...(offline.data ?? []).map((u) => ({
|
||||
...toUserProfile(u, self),
|
||||
state: "offline" as const,
|
||||
})),
|
||||
];
|
||||
return friends.sort(
|
||||
(a, b) =>
|
||||
(order[a.status] ?? 5) - (order[b.status] ?? 5) ||
|
||||
a.displayName.localeCompare(b.displayName),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Group } from "../../shared/types/group";
|
||||
import { toGroup } from "./mappers";
|
||||
import { cachedRead } from "./cachedRead";
|
||||
import { cacheKeys, policies } from "../cache/policies";
|
||||
|
||||
export function getUserGroups(userId: string): Promise<Group[]> {
|
||||
return cachedRead(cacheKeys.userGroups(userId), policies.userGroups, async (vrc) => {
|
||||
const { data } = await vrc.getUserGroups({ path: { userId }, throwOnError: true });
|
||||
return data.map(toGroup).filter((g) => g.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function getRepresentedGroup(userId: string): Promise<Group | null> {
|
||||
return cachedRead(cacheKeys.representedGroup(userId), policies.representedGroup, async (vrc) => {
|
||||
const { data } = await vrc.getUserRepresentedGroup({ path: { userId }, throwOnError: true });
|
||||
return data?.groupId ? toGroup(data) : null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type {
|
||||
World as SdkWorld,
|
||||
LimitedWorld,
|
||||
FavoritedWorld,
|
||||
LimitedUserGroups,
|
||||
RepresentedGroup,
|
||||
User,
|
||||
CurrentUser,
|
||||
LimitedUserFriend,
|
||||
} from "vrchat";
|
||||
import type { TrustRank, UserProfile, UserStatus } from "../../shared/types/user";
|
||||
import type { CurrentUserSummary } from "../../shared/types/auth";
|
||||
import type { ReleaseStatus, World, WorldPlatforms } from "../../shared/types/world";
|
||||
import type { Avatar } from "../../shared/types/avatar";
|
||||
import type { Group } from "../../shared/types/group";
|
||||
|
||||
interface RawUser {
|
||||
id: string;
|
||||
displayName: string;
|
||||
bio?: string;
|
||||
bioLinks?: string[];
|
||||
statusDescription?: string;
|
||||
status?: string;
|
||||
tags?: string[];
|
||||
userIcon?: string;
|
||||
profilePicOverride?: string;
|
||||
profilePicOverrideThumbnail?: string;
|
||||
currentAvatarImageUrl?: string;
|
||||
currentAvatarThumbnailImageUrl?: string;
|
||||
currentAvatarTags?: string[];
|
||||
location?: string;
|
||||
lastPlatform?: string;
|
||||
last_platform?: string;
|
||||
lastLogin?: string;
|
||||
last_login?: string | Date | null;
|
||||
lastActivity?: string;
|
||||
last_activity?: string | Date | null;
|
||||
state?: string;
|
||||
platform?: string;
|
||||
isFriend?: boolean;
|
||||
friendKey?: string;
|
||||
developerType?: string;
|
||||
ageVerificationStatus?: string;
|
||||
ageVerified?: boolean;
|
||||
pronouns?: string;
|
||||
date_joined?: string | Date;
|
||||
dateJoined?: string;
|
||||
note?: string;
|
||||
pastDisplayNames?: { displayName: string; updated_at?: string | Date }[];
|
||||
badges?: {
|
||||
badgeId: string;
|
||||
badgeName: string;
|
||||
badgeDescription: string;
|
||||
badgeImageUrl: string;
|
||||
showcased?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
type Mappable = Omit<User | CurrentUser | LimitedUserFriend, "last_login" | "last_activity">;
|
||||
const _rawUserCheck = (u: Mappable & { id: string; displayName: string }): RawUser => u;
|
||||
void _rawUserCheck;
|
||||
|
||||
// VRChat's trust tag names lag behind the labels shown in-app.
|
||||
export function trustRankFromTags(tags: string[] = []): TrustRank {
|
||||
const has = (t: string) => tags.includes(`system_trust_${t}`);
|
||||
if (tags.includes("system_troll") || tags.includes("system_probable_troll")) return "troll";
|
||||
if (has("legend")) return "veteran";
|
||||
if (has("veteran")) return "trusted";
|
||||
if (has("trusted")) return "known";
|
||||
if (has("known")) return "user";
|
||||
if (has("basic")) return "new";
|
||||
return "visitor";
|
||||
}
|
||||
|
||||
function languagesFromTags(tags: string[] = []): string[] {
|
||||
return tags.filter((t) => t.startsWith("language_")).map((t) => t.slice("language_".length));
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string): UserStatus {
|
||||
switch (status) {
|
||||
case "join me":
|
||||
case "active":
|
||||
case "ask me":
|
||||
case "busy":
|
||||
case "offline":
|
||||
return status;
|
||||
default:
|
||||
return "offline";
|
||||
}
|
||||
}
|
||||
|
||||
function toIso(v?: string | Date | null): string | undefined {
|
||||
if (!v) return undefined;
|
||||
const s = v instanceof Date ? v.toISOString() : v;
|
||||
return s === "" ? undefined : s;
|
||||
}
|
||||
|
||||
export function toUserProfile(raw: RawUser, selfId: string): UserProfile {
|
||||
const tags = raw.tags ?? [];
|
||||
return {
|
||||
id: raw.id,
|
||||
displayName: raw.displayName,
|
||||
bio: raw.bio ?? "",
|
||||
bioLinks: raw.bioLinks ?? [],
|
||||
statusDescription: raw.statusDescription ?? "",
|
||||
status: normalizeStatus(raw.status),
|
||||
trustRank: trustRankFromTags(tags),
|
||||
tags,
|
||||
|
||||
userIcon: raw.userIcon ?? "",
|
||||
profilePicOverride: raw.profilePicOverride ?? "",
|
||||
profilePicOverrideThumbnail: raw.profilePicOverrideThumbnail ?? "",
|
||||
currentAvatarImageUrl: raw.currentAvatarImageUrl ?? "",
|
||||
currentAvatarThumbnailImageUrl: raw.currentAvatarThumbnailImageUrl ?? "",
|
||||
currentAvatarTags: raw.currentAvatarTags ?? [],
|
||||
|
||||
location: raw.location,
|
||||
lastPlatform: raw.lastPlatform ?? raw.last_platform,
|
||||
lastLogin: toIso(raw.lastLogin ?? raw.last_login),
|
||||
lastActivity: toIso(raw.lastActivity ?? raw.last_activity),
|
||||
state: raw.state as UserProfile["state"],
|
||||
platform: raw.platform,
|
||||
|
||||
isFriend: raw.isFriend ?? false,
|
||||
friendKey: raw.friendKey,
|
||||
|
||||
developerType: raw.developerType,
|
||||
ageVerificationStatus: raw.ageVerificationStatus,
|
||||
ageVerified: raw.ageVerified,
|
||||
pronouns: raw.pronouns,
|
||||
languages: languagesFromTags(tags),
|
||||
dateJoined: raw.dateJoined ?? toIso(raw.date_joined),
|
||||
pastDisplayNames: raw.pastDisplayNames?.map((p) => ({
|
||||
displayName: p.displayName,
|
||||
updatedAt: toIso(p.updated_at),
|
||||
})),
|
||||
note: raw.note || undefined,
|
||||
badges:
|
||||
raw.badges?.map((b) => ({
|
||||
id: b.badgeId,
|
||||
name: b.badgeName,
|
||||
description: b.badgeDescription,
|
||||
imageUrl: b.badgeImageUrl,
|
||||
showcased: b.showcased ?? false,
|
||||
})) ?? [],
|
||||
|
||||
isSelf: raw.id === selfId,
|
||||
};
|
||||
}
|
||||
|
||||
export function toCurrentUserSummary(raw: RawUser): CurrentUserSummary {
|
||||
return {
|
||||
id: raw.id,
|
||||
displayName: raw.displayName,
|
||||
userIcon: raw.userIcon ?? "",
|
||||
currentAvatarThumbnailImageUrl: raw.currentAvatarThumbnailImageUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
type RawWorld = SdkWorld | LimitedWorld | FavoritedWorld;
|
||||
|
||||
export function toWorld(raw: RawWorld): World {
|
||||
const platforms = raw.unityPackages ? platformsOf(raw.unityPackages) : undefined;
|
||||
const detailed = "visits" in raw;
|
||||
return {
|
||||
id: raw.id,
|
||||
detailed,
|
||||
name: raw.name,
|
||||
authorId: raw.authorId ?? "",
|
||||
authorName: raw.authorName,
|
||||
description: "description" in raw ? (raw.description ?? "") : "",
|
||||
imageUrl: raw.imageUrl ?? "",
|
||||
thumbnailImageUrl: raw.thumbnailImageUrl ?? "",
|
||||
releaseStatus: (raw.releaseStatus as ReleaseStatus) ?? "private",
|
||||
capacity: raw.capacity ?? 0,
|
||||
favorites: raw.favorites ?? 0,
|
||||
visits: "visits" in raw ? (raw.visits ?? 0) : 0,
|
||||
occupants: raw.occupants ?? 0,
|
||||
heat: raw.heat ?? 0,
|
||||
tags: raw.tags ?? [],
|
||||
createdAt: toIso(raw.created_at),
|
||||
updatedAt: toIso(raw.updated_at),
|
||||
|
||||
recommendedCapacity: raw.recommendedCapacity,
|
||||
popularity: raw.popularity,
|
||||
version: "version" in raw ? raw.version : undefined,
|
||||
publishedAt: validDate(raw.publicationDate),
|
||||
labsPublishedAt: validDate(raw.labsPublicationDate),
|
||||
previewYoutubeId: raw.previewYoutubeId ?? undefined,
|
||||
platforms,
|
||||
publicOccupants: "publicOccupants" in raw ? raw.publicOccupants : undefined,
|
||||
privateOccupants: "privateOccupants" in raw ? raw.privateOccupants : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function toGroup(raw: LimitedUserGroups | RepresentedGroup): Group {
|
||||
return {
|
||||
id: raw.groupId ?? "",
|
||||
name: raw.name ?? "",
|
||||
shortCode: raw.shortCode ?? undefined,
|
||||
description: raw.description || undefined,
|
||||
iconUrl: raw.iconUrl ?? undefined,
|
||||
bannerUrl: raw.bannerUrl ?? undefined,
|
||||
ownerId: raw.ownerId ?? undefined,
|
||||
memberCount: raw.memberCount,
|
||||
privacy: raw.privacy ?? undefined,
|
||||
isRepresenting: raw.isRepresenting ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function platformsOf(pkgs: ReadonlyArray<{ platform: string }>): WorldPlatforms {
|
||||
let pc = false;
|
||||
let android = false;
|
||||
for (const p of pkgs) {
|
||||
if (p.platform === "standalonewindows") pc = true;
|
||||
else if (p.platform === "android") android = true;
|
||||
}
|
||||
return { pc, android };
|
||||
}
|
||||
|
||||
function validDate(v?: string): string | undefined {
|
||||
if (!v || v === "none") return undefined;
|
||||
return toIso(v);
|
||||
}
|
||||
|
||||
interface RawAvatar {
|
||||
id: string;
|
||||
name: string;
|
||||
authorId?: string;
|
||||
authorName?: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
thumbnailImageUrl?: string;
|
||||
releaseStatus?: string;
|
||||
tags?: string[];
|
||||
favorites?: number;
|
||||
created_at?: string | Date;
|
||||
updated_at?: string | Date;
|
||||
}
|
||||
|
||||
export function toAvatar(raw: RawAvatar): Avatar {
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
authorId: raw.authorId ?? "",
|
||||
authorName: raw.authorName ?? "",
|
||||
description: raw.description ?? "",
|
||||
imageUrl: raw.imageUrl ?? "",
|
||||
thumbnailImageUrl: raw.thumbnailImageUrl ?? "",
|
||||
releaseStatus: raw.releaseStatus ?? "private",
|
||||
tags: raw.tags ?? [],
|
||||
favorites: raw.favorites ?? 0,
|
||||
createdAt: toIso(raw.created_at),
|
||||
updatedAt: toIso(raw.updated_at),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { VRChat, FavoritedWorld } from "vrchat";
|
||||
|
||||
// VRChat web routes that are missing from the SDK.
|
||||
|
||||
interface FavoriteGroupItem {
|
||||
favoriteId: string;
|
||||
id: string;
|
||||
tags: string[];
|
||||
type: string;
|
||||
world: FavoritedWorld;
|
||||
}
|
||||
|
||||
interface FavoriteGroupItems {
|
||||
favorites: FavoriteGroupItem[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export type WorldFavoriteGroupType = "world" | "vrcPlusWorld";
|
||||
|
||||
export async function getFavoriteGroupWorlds(
|
||||
vrc: VRChat,
|
||||
groupType: WorldFavoriteGroupType,
|
||||
groupName: string,
|
||||
ownerId: string,
|
||||
): Promise<FavoritedWorld[]> {
|
||||
const worlds: FavoritedWorld[] = [];
|
||||
const pageSize = 100;
|
||||
for (let offset = 0; ; offset += pageSize) {
|
||||
const { data } = await vrc.client.get<FavoriteGroupItems, unknown, true>({
|
||||
url: `/favorites/groups/${groupType}/${encodeURIComponent(groupName)}`,
|
||||
query: { ownerId, n: pageSize, offset },
|
||||
throwOnError: true,
|
||||
});
|
||||
const page = data.favorites ?? [];
|
||||
for (const f of page) worlds.push(f.world);
|
||||
if (page.length < pageSize || worlds.length >= (data.totalCount ?? worlds.length)) break;
|
||||
}
|
||||
return worlds;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { CurrentUser, VRChat } from "vrchat";
|
||||
import type {
|
||||
AccountSettings,
|
||||
ContentFilterKey,
|
||||
Pending2Fa,
|
||||
RecoveryCode,
|
||||
} from "../../shared/types/settings";
|
||||
import type { TwoFactorMethod } from "../../shared/types/auth";
|
||||
import type { UserStatus } from "../../shared/types/user";
|
||||
import { requireActiveClient } from "./client";
|
||||
import { userCache } from "./userService";
|
||||
import { cacheKeys } from "../cache/policies";
|
||||
import { entityStore } from "../store/entityStore";
|
||||
|
||||
const CONTENT_FILTER_KEYS: ContentFilterKey[] = [
|
||||
"content_sex",
|
||||
"content_adult",
|
||||
"content_violence",
|
||||
"content_gore",
|
||||
"content_horror",
|
||||
];
|
||||
|
||||
function toIso(d?: Date | string | null): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const date = typeof d === "string" ? new Date(d) : d;
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
|
||||
function toSettings(u: CurrentUser): AccountSettings {
|
||||
const filters = (u.contentFilters ?? []).filter((t): t is ContentFilterKey =>
|
||||
(CONTENT_FILTER_KEYS as string[]).includes(t),
|
||||
);
|
||||
const lastPast = [...(u.pastDisplayNames ?? [])].sort(
|
||||
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
|
||||
)[0];
|
||||
return {
|
||||
id: u.id,
|
||||
displayName: u.displayName,
|
||||
displayNameChangedAt: toIso(lastPast?.updated_at),
|
||||
previousDisplayName: lastPast?.displayName,
|
||||
supporter: (u.tags ?? []).includes("system_supporter"),
|
||||
pronouns: u.pronouns ?? "",
|
||||
email: u.obfuscatedEmail ?? "",
|
||||
emailVerified: u.emailVerified,
|
||||
pendingEmail: u.hasPendingEmail ? (u.obfuscatedPendingEmail ?? undefined) : undefined,
|
||||
twoFactorEnabled: u.twoFactorAuthEnabled,
|
||||
twoFactorEnabledDate: toIso(u.twoFactorAuthEnabledDate),
|
||||
usesGeneratedPassword: u.usesGeneratedPassword,
|
||||
ageVerificationStatus: u.ageVerificationStatus,
|
||||
ageVerified: u.ageVerified,
|
||||
isAdult: u.isAdult,
|
||||
contentFilters: filters,
|
||||
contentFiltersLocked: u.hideContentFilterSettings ?? false,
|
||||
sharedConnectionsHidden: Boolean(
|
||||
(u as { hasSharedConnectionsOptOut?: boolean }).hasSharedConnectionsOptOut,
|
||||
),
|
||||
discordFriendsHidden: Boolean(
|
||||
(u as { hasDiscordFriendsOptOut?: boolean }).hasDiscordFriendsOptOut,
|
||||
),
|
||||
discord: { linked: Boolean(u.discordId), label: u.discordDetails?.global_name },
|
||||
google: { linked: Boolean(u.googleId) },
|
||||
accountDeletionDate: toIso(u.accountDeletionDate) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateSelf(): void {
|
||||
userCache.invalidate(cacheKeys.currentUser());
|
||||
}
|
||||
|
||||
function stepUpIfNeeded(data: unknown): void {
|
||||
if (!data || typeof data !== "object" || !("requiresTwoFactorAuth" in data)) return;
|
||||
const raw = (data as { requiresTwoFactorAuth?: string[] }).requiresTwoFactorAuth ?? [];
|
||||
const methods: TwoFactorMethod[] = [];
|
||||
if (raw.some((m) => m.toLowerCase() === "totp" || m.toLowerCase() === "otp"))
|
||||
methods.push("totp");
|
||||
if (raw.some((m) => m.toLowerCase() === "emailotp")) methods.push("emailOtp");
|
||||
throw {
|
||||
code: "requires_2fa",
|
||||
message: "Enter your two-factor code to continue.",
|
||||
methods: methods.length ? methods : (["totp"] as TwoFactorMethod[]),
|
||||
};
|
||||
}
|
||||
|
||||
export async function reverify2fa(
|
||||
method: TwoFactorMethod,
|
||||
code: string,
|
||||
): Promise<{ verified: boolean }> {
|
||||
const vrc = requireActiveClient();
|
||||
const { data } =
|
||||
method === "emailOtp"
|
||||
? await vrc.verify2FaEmailCode({ body: { code }, throwOnError: true })
|
||||
: await vrc.verify2Fa({ body: { code }, throwOnError: true });
|
||||
return { verified: data.verified };
|
||||
}
|
||||
|
||||
async function fetchCurrentUser(vrc: VRChat): Promise<CurrentUser> {
|
||||
const { data } = await vrc.getCurrentUser({ throwOnError: true });
|
||||
if ("requiresTwoFactorAuth" in data) throw { status: 401, message: "Session expired" };
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<AccountSettings> {
|
||||
return toSettings(await fetchCurrentUser(requireActiveClient()));
|
||||
}
|
||||
|
||||
type UpdateBody = Parameters<VRChat["updateUser"]>[0]["body"];
|
||||
|
||||
async function patch(body: UpdateBody): Promise<AccountSettings> {
|
||||
const vrc = requireActiveClient();
|
||||
const me = await fetchCurrentUser(vrc);
|
||||
const { data } = await vrc.updateUser({ path: { userId: me.id }, body, throwOnError: true });
|
||||
invalidateSelf();
|
||||
return toSettings(data);
|
||||
}
|
||||
|
||||
export function setDisplayName(
|
||||
displayName: string,
|
||||
currentPassword: string,
|
||||
): Promise<AccountSettings> {
|
||||
return patch({ displayName, currentPassword });
|
||||
}
|
||||
|
||||
export function revertDisplayName(currentPassword: string): Promise<AccountSettings> {
|
||||
return patch({ revertDisplayName: true, currentPassword });
|
||||
}
|
||||
|
||||
export function setEmail(email: string, currentPassword: string): Promise<AccountSettings> {
|
||||
return patch({ email, currentPassword });
|
||||
}
|
||||
|
||||
export function setPassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<AccountSettings> {
|
||||
return patch({ currentPassword, password: newPassword });
|
||||
}
|
||||
|
||||
export function setPrivacy(p: {
|
||||
sharedConnectionsHidden?: boolean;
|
||||
discordFriendsHidden?: boolean;
|
||||
}): Promise<AccountSettings> {
|
||||
const body: Record<string, boolean> = {};
|
||||
if (p.sharedConnectionsHidden !== undefined)
|
||||
body.hasSharedConnectionsOptOut = p.sharedConnectionsHidden;
|
||||
if (p.discordFriendsHidden !== undefined) body.hasDiscordFriendsOptOut = p.discordFriendsHidden;
|
||||
return patch(body as UpdateBody);
|
||||
}
|
||||
|
||||
export async function setPresence(status: UserStatus, statusDescription: string): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
const me = await fetchCurrentUser(vrc);
|
||||
await vrc.updateUser({
|
||||
path: { userId: me.id },
|
||||
body: { status: status as never, statusDescription },
|
||||
throwOnError: true,
|
||||
});
|
||||
invalidateSelf();
|
||||
entityStore.upsertFrom({ id: me.id, status, statusDescription }, "ws", Date.now());
|
||||
}
|
||||
|
||||
export function setContentFilters(filters: ContentFilterKey[]): Promise<AccountSettings> {
|
||||
const ordered = CONTENT_FILTER_KEYS.filter((k) => filters.includes(k));
|
||||
return patch({ contentFilters: ordered });
|
||||
}
|
||||
|
||||
export async function beginTwoFactorSetup(): Promise<Pending2Fa> {
|
||||
const vrc = requireActiveClient();
|
||||
const { data } = await vrc.enable2Fa({ throwOnError: true });
|
||||
return { secret: data.secret, qrCodeDataUrl: data.qrCodeDataUrl };
|
||||
}
|
||||
|
||||
export async function verifyTwoFactorSetup(code: string): Promise<{ verified: boolean }> {
|
||||
const vrc = requireActiveClient();
|
||||
const { data } = await vrc.verifyPending2Fa({ body: { code }, throwOnError: true });
|
||||
if (data.verified) invalidateSelf();
|
||||
return { verified: data.verified };
|
||||
}
|
||||
|
||||
export async function disableTwoFactor(): Promise<AccountSettings> {
|
||||
const vrc = requireActiveClient();
|
||||
const { data } = await vrc.disable2Fa({ throwOnError: true });
|
||||
stepUpIfNeeded(data);
|
||||
if (!data.removed) throw { status: 400, message: "VRChat did not remove two-factor auth." };
|
||||
invalidateSelf();
|
||||
const settings = toSettings(await fetchCurrentUser(vrc));
|
||||
return { ...settings, twoFactorEnabled: false, twoFactorEnabledDate: undefined };
|
||||
}
|
||||
|
||||
export async function getRecoveryCodes(): Promise<RecoveryCode[]> {
|
||||
const vrc = requireActiveClient();
|
||||
const { data } = await vrc.getRecoveryCodes({ throwOnError: true });
|
||||
stepUpIfNeeded(data);
|
||||
return (data.otp ?? []).map((o) => ({ code: o.code, used: o.used }));
|
||||
}
|
||||
|
||||
export async function resetUserData(): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
const me = await fetchCurrentUser(vrc);
|
||||
await vrc.deleteAllUserPersistenceData({ path: { userId: me.id }, throwOnError: true });
|
||||
}
|
||||
|
||||
export async function deleteAccount(): Promise<AccountSettings> {
|
||||
const vrc = requireActiveClient();
|
||||
const me = await fetchCurrentUser(vrc);
|
||||
const { data } = await vrc.deleteUser({ path: { userId: me.id }, throwOnError: true });
|
||||
invalidateSelf();
|
||||
return toSettings(data);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { UserProfile } from "../../shared/types/user";
|
||||
import { requireActiveClient } from "./client";
|
||||
import { toUserProfile } from "./mappers";
|
||||
import { TtlCache } from "../cache/cache";
|
||||
import { cacheKeys, policies } from "../cache/policies";
|
||||
import { entityStore } from "../store/entityStore";
|
||||
|
||||
export const userCache = new TtlCache();
|
||||
|
||||
async function selfId(): Promise<string> {
|
||||
return (await currentUser()).id;
|
||||
}
|
||||
|
||||
function dropPresence(p: UserProfile): Partial<UserProfile> & { id: string } {
|
||||
const { state: _s, location: _l, status: _st, ...rest } = p;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function refreshStore(p: UserProfile, key: string): void {
|
||||
const known = !p.isSelf && entityStore.get(p.id)?.isFriend;
|
||||
const stamped = p.isSelf || known ? dropPresence(p) : p;
|
||||
entityStore.upsertFrom(stamped, "rest:detail", userCache.createdAt(key) ?? Date.now());
|
||||
}
|
||||
|
||||
export async function currentUser(): Promise<UserProfile> {
|
||||
const vrc = requireActiveClient();
|
||||
const key = cacheKeys.currentUser();
|
||||
const profile = await userCache.get(key, policies.currentUser, async () => {
|
||||
const { data } = await vrc.getCurrentUser({ throwOnError: true });
|
||||
if (!("id" in data)) throw { status: 401, message: "Not authenticated" };
|
||||
return toUserProfile(data, data.id);
|
||||
});
|
||||
refreshStore(profile, key);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function getUser(userId: string): Promise<UserProfile> {
|
||||
const vrc = requireActiveClient();
|
||||
const self = await selfId();
|
||||
const key = cacheKeys.user(userId);
|
||||
const profile = await userCache.get(key, policies.user, async () => {
|
||||
const { data } = await vrc.getUser({ path: { userId }, throwOnError: true });
|
||||
return toUserProfile(data, self);
|
||||
});
|
||||
refreshStore(profile, key);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function getUserByName(username: string): Promise<UserProfile> {
|
||||
const vrc = requireActiveClient();
|
||||
const self = await selfId();
|
||||
return userCache.get(cacheKeys.userByName(username), policies.user, async () => {
|
||||
const { data } = await vrc.getUserByName({ path: { username }, throwOnError: true });
|
||||
return toUserProfile(data, self);
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchUsers(query: string): Promise<UserProfile[]> {
|
||||
const vrc = requireActiveClient();
|
||||
const self = await selfId();
|
||||
return userCache.get(cacheKeys.userSearch(query), policies.userSearch, async () => {
|
||||
const { data } = await vrc.searchUsers({ query: { search: query, n: 25 }, throwOnError: true });
|
||||
return data.map((u) => toUserProfile(u, self));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { VRChat } from "vrchat";
|
||||
import type { FavoriteWorldFolder, World } from "../../shared/types/world";
|
||||
import { httpStatusOf } from "./errors";
|
||||
import { getFavoriteGroupWorlds, type WorldFavoriteGroupType } from "./rawEndpoints";
|
||||
import { toWorld } from "./mappers";
|
||||
import { cachedRead } from "./cachedRead";
|
||||
import { worldStore } from "../store/worldStore";
|
||||
import { broadcast } from "../windows";
|
||||
import { cacheKeys, policies } from "../cache/policies";
|
||||
|
||||
export async function getWorld(worldId: string): Promise<World> {
|
||||
const world = await cachedRead(cacheKeys.world(worldId), policies.world, async (vrc) => {
|
||||
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
||||
return toWorld(data);
|
||||
});
|
||||
worldStore.addWorld(world);
|
||||
return world;
|
||||
}
|
||||
|
||||
type CachedFavorites = { worlds: World[]; folders: FavoriteWorldFolder[] };
|
||||
|
||||
export async function getFavoriteWorlds(userId: string): Promise<FavoriteWorldFolder[]> {
|
||||
const { worlds, folders } = await cachedRead<CachedFavorites>(
|
||||
cacheKeys.favoriteWorlds(userId),
|
||||
policies.favoriteWorlds,
|
||||
(vrc) => loadFavoriteWorlds(vrc, userId),
|
||||
);
|
||||
for (const w of worlds) worldStore.addWorld(w);
|
||||
broadcast("world:favoriteFolders", { userId, folders, done: true });
|
||||
return folders;
|
||||
}
|
||||
|
||||
async function loadFavoriteWorlds(vrc: VRChat, userId: string): Promise<CachedFavorites> {
|
||||
let groups;
|
||||
try {
|
||||
const { data } = await vrc.getFavoriteGroups({
|
||||
query: { ownerId: userId, n: 100 },
|
||||
throwOnError: true,
|
||||
});
|
||||
groups = data.filter((g): g is (typeof data)[number] & { type: WorldFavoriteGroupType } =>
|
||||
isWorldGroupType(g.type),
|
||||
);
|
||||
} catch (err) {
|
||||
if (isPrivateFavorites(err)) return { worlds: [], folders: [] };
|
||||
throw err;
|
||||
}
|
||||
|
||||
const worlds: World[] = [];
|
||||
const seen = new Set<string>();
|
||||
const members: { id: string; group: string }[] = [];
|
||||
const names = new Map<string, string>();
|
||||
for (const group of groups) {
|
||||
if (group.displayName) names.set(group.name, group.displayName);
|
||||
let raw;
|
||||
try {
|
||||
raw = await getFavoriteGroupWorlds(vrc, group.type, group.name, userId);
|
||||
} catch (err) {
|
||||
if (isPrivateFavorites(err)) continue;
|
||||
throw err;
|
||||
}
|
||||
for (const rawWorld of raw) {
|
||||
members.push({ id: rawWorld.id, group: group.name });
|
||||
if (!seen.has(rawWorld.id)) {
|
||||
seen.add(rawWorld.id);
|
||||
const world = toWorld(rawWorld);
|
||||
worlds.push(world);
|
||||
worldStore.addWorld(world);
|
||||
}
|
||||
}
|
||||
broadcast("world:favoriteFolders", {
|
||||
userId,
|
||||
folders: groupIntoFolders(members, names),
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
|
||||
const folders = groupIntoFolders(members, names);
|
||||
broadcast("world:favoriteFolders", { userId, folders, done: true });
|
||||
return { worlds, folders };
|
||||
}
|
||||
|
||||
function isWorldGroupType(type: string): type is WorldFavoriteGroupType {
|
||||
return type === "world" || type === "vrcPlusWorld";
|
||||
}
|
||||
|
||||
function isPrivateFavorites(err: unknown): boolean {
|
||||
const status = httpStatusOf(err);
|
||||
return status === 401 || status === 403;
|
||||
}
|
||||
|
||||
function groupIntoFolders(
|
||||
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);
|
||||
}
|
||||
}
|
||||
return order.map((name) => ({
|
||||
name,
|
||||
displayName: names.get(name) ?? prettyFolderName(name),
|
||||
worldIds: byGroup.get(name)!,
|
||||
}));
|
||||
}
|
||||
|
||||
function prettyFolderName(key: string): string {
|
||||
const m = /^worlds(\d+)$/.exec(key);
|
||||
if (m) return `Group ${m[1]}`;
|
||||
return key.charAt(0).toUpperCase() + key.slice(1);
|
||||
}
|
||||
|
||||
export async function searchWorlds(query: string): Promise<World[]> {
|
||||
const worlds = await cachedRead(
|
||||
cacheKeys.worldSearch(query),
|
||||
policies.worldSearch,
|
||||
async (vrc) => {
|
||||
const { data } = await vrc.searchWorlds({
|
||||
query: { search: query, n: 25, sort: "relevance" as const },
|
||||
throwOnError: true,
|
||||
});
|
||||
return data.map(toWorld);
|
||||
},
|
||||
);
|
||||
for (const w of worlds) worldStore.addWorld(w);
|
||||
return worlds;
|
||||
}
|
||||
|
||||
export async function getUserWorlds(userId: string, isSelf: boolean): Promise<World[]> {
|
||||
const worlds = await cachedRead(
|
||||
cacheKeys.userWorlds(userId),
|
||||
policies.userWorlds,
|
||||
async (vrc) => {
|
||||
const query = isSelf
|
||||
? { user: "me" as const, releaseStatus: "all" as const, n: 50, sort: "updated" as const }
|
||||
: { userId, releaseStatus: "public" as const, n: 50, sort: "updated" as const };
|
||||
const { data } = await vrc.searchWorlds({ query, throwOnError: true });
|
||||
return data.map(toWorld);
|
||||
},
|
||||
);
|
||||
worldStore.setAuthorWorlds(userId, worlds);
|
||||
return worlds;
|
||||
}
|
||||
Reference in New Issue
Block a user