mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-14 03:10:01 +00:00
✨ feat: Groups info page and store
This commit is contained in:
Vendored
+2
@@ -13,6 +13,7 @@ export const policies = {
|
||||
avatarFavorites: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
|
||||
userGroups: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
|
||||
representedGroup: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
|
||||
group: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 },
|
||||
} satisfies Record<string, CachePolicy>;
|
||||
|
||||
export const cacheKeys = {
|
||||
@@ -29,4 +30,5 @@ export const cacheKeys = {
|
||||
avatarFavorites: () => "avatar:favorites",
|
||||
userGroups: (id: string) => `user:groups:${id}`,
|
||||
representedGroup: (id: string) => `user:group:represented:${id}`,
|
||||
group: (id: string) => `group:${id}`,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as appConfig from "../config/appConfig";
|
||||
import * as game from "../game/launch";
|
||||
import { socialSnapshot } from "../store/social";
|
||||
import { worldStore } from "../store/worldStore";
|
||||
import { groupStore } from "../store/groupStore";
|
||||
import { openDebugWindow } from "../windows";
|
||||
|
||||
const handlers = {
|
||||
@@ -50,6 +51,8 @@ const handlers = {
|
||||
|
||||
"group:byUser": (userId) => guard(() => groups.getUserGroups(userId)),
|
||||
"group:represented": (userId) => guard(() => groups.getRepresentedGroup(userId)),
|
||||
"group:get": (groupId) => guard(() => groups.getGroup(groupId)),
|
||||
"group:snapshot": () => guard(async () => groupStore.snapshot()),
|
||||
|
||||
"social:snapshot": () => guard(async () => socialSnapshot()),
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Group, GroupSnapshot } from "../../shared/types/group";
|
||||
import type { FieldSource } from "../../shared/types/repository";
|
||||
import { repos } from "./repository/manager";
|
||||
|
||||
export type { GroupSnapshot };
|
||||
|
||||
type Change = { type: "seed"; snapshot: GroupSnapshot } | { type: "upsert"; group: Group };
|
||||
type Listener = (change: Change) => void;
|
||||
|
||||
class GroupStore {
|
||||
private readonly listeners = new Set<Listener>();
|
||||
private byUser = new Map<string, Set<string>>();
|
||||
private representedByUser = new Map<string, string>();
|
||||
private wired = false;
|
||||
|
||||
onChange(fn: Listener): () => void {
|
||||
this.wire();
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
private wire(): void {
|
||||
if (this.wired || !repos.hasActive) return;
|
||||
this.wired = true;
|
||||
repos.active.groups.onChange((c) => {
|
||||
this.emit({ type: "upsert", group: c.entity });
|
||||
});
|
||||
}
|
||||
|
||||
setUserGroups(userId: string, groups: Group[]): void {
|
||||
this.byUser.set(userId, new Set(groups.map((g) => g.id)));
|
||||
repos.active.groups.upsertMany(groups, "rest:list");
|
||||
this.emit({ type: "seed", snapshot: this.snapshot() });
|
||||
}
|
||||
|
||||
setRepresented(userId: string, group: Group | null): void {
|
||||
if (group) {
|
||||
this.representedByUser.set(userId, group.id);
|
||||
repos.active.groups.upsert(group, "rest:list");
|
||||
} else {
|
||||
this.representedByUser.delete(userId);
|
||||
}
|
||||
this.emit({ type: "seed", snapshot: this.snapshot() });
|
||||
}
|
||||
|
||||
addGroup(group: Group, src: FieldSource = group.detailed ? "rest:detail" : "rest:list"): void {
|
||||
repos.active.groups.upsert(group, src);
|
||||
}
|
||||
|
||||
get(groupId: string): Group | undefined {
|
||||
return repos.active.groups.get(groupId);
|
||||
}
|
||||
|
||||
snapshot(): GroupSnapshot {
|
||||
return {
|
||||
groups: repos.hasActive ? repos.active.groups.all() : [],
|
||||
byUser: Object.fromEntries([...this.byUser].map(([k, v]) => [k, [...v]])),
|
||||
representedByUser: Object.fromEntries(this.representedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.byUser.clear();
|
||||
this.representedByUser.clear();
|
||||
this.wired = false;
|
||||
this.wire();
|
||||
this.emit({ type: "seed", snapshot: this.snapshot() });
|
||||
}
|
||||
|
||||
private emit(change: Change): void {
|
||||
for (const fn of this.listeners) fn(change);
|
||||
}
|
||||
}
|
||||
|
||||
export const groupStore = new GroupStore();
|
||||
@@ -82,3 +82,11 @@ export const avatarFieldPolicy = table<import("../../../shared/types/avatar").Av
|
||||
},
|
||||
{ identity: 30 * DAY, stat: 6 * HOUR, live: 1 * MIN },
|
||||
);
|
||||
|
||||
export const groupFieldPolicy = table<import("../../../shared/types/group").Group>(
|
||||
{
|
||||
memberCount: "stat",
|
||||
onlineMemberCount: "live",
|
||||
},
|
||||
{ identity: 30 * DAY, stat: 6 * HOUR, live: 2 * MIN },
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { rmSync } from "node:fs";
|
||||
import type { World } from "../../../shared/types/world";
|
||||
import type { UserProfile } from "../../../shared/types/user";
|
||||
import type { Avatar } from "../../../shared/types/avatar";
|
||||
import type { Group } from "../../../shared/types/group";
|
||||
import type { RepoStats, StoredEntity } from "../../../shared/types/repository";
|
||||
|
||||
interface InspectableRepo {
|
||||
@@ -13,12 +14,18 @@ interface InspectableRepo {
|
||||
}
|
||||
import { Repository } from "./repository";
|
||||
import { JsonlBackend } from "./backend";
|
||||
import { avatarFieldPolicy, userFieldPolicy, worldFieldPolicy } from "./fieldPolicy";
|
||||
import {
|
||||
avatarFieldPolicy,
|
||||
groupFieldPolicy,
|
||||
userFieldPolicy,
|
||||
worldFieldPolicy,
|
||||
} from "./fieldPolicy";
|
||||
|
||||
export interface AccountRepos {
|
||||
worlds: Repository<World>;
|
||||
users: Repository<UserProfile>;
|
||||
avatars: Repository<Avatar>;
|
||||
groups: Repository<Group>;
|
||||
}
|
||||
|
||||
function dbDir(): string {
|
||||
@@ -53,6 +60,11 @@ class RepositoryManager {
|
||||
policy: avatarFieldPolicy,
|
||||
backend: new JsonlBackend<Avatar>(fileFor(accountId, "avatars")),
|
||||
}),
|
||||
groups: new Repository<Group>({
|
||||
name: "groups",
|
||||
policy: groupFieldPolicy,
|
||||
backend: new JsonlBackend<Group>(fileFor(accountId, "groups")),
|
||||
}),
|
||||
};
|
||||
this.accounts.set(accountId, repos);
|
||||
return repos;
|
||||
@@ -78,6 +90,7 @@ class RepositoryManager {
|
||||
repos.worlds.flush();
|
||||
repos.users.flush();
|
||||
repos.avatars.flush();
|
||||
repos.groups.flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +100,10 @@ class RepositoryManager {
|
||||
repos.worlds.flush();
|
||||
repos.users.flush();
|
||||
repos.avatars.flush();
|
||||
repos.groups.flush();
|
||||
this.accounts.delete(accountId);
|
||||
}
|
||||
for (const type of ["worlds", "users", "avatars"]) {
|
||||
for (const type of ["worlds", "users", "avatars", "groups"]) {
|
||||
const base = fileFor(accountId, type);
|
||||
rmSync(`${base}.json`, { force: true });
|
||||
rmSync(`${base}.log`, { force: true });
|
||||
@@ -100,7 +114,7 @@ class RepositoryManager {
|
||||
stats(): RepoStats[] {
|
||||
if (!this.activeId) return [];
|
||||
const repos = this.open(this.activeId);
|
||||
return [repos.worlds.stats(), repos.users.stats(), repos.avatars.stats()];
|
||||
return [repos.worlds.stats(), repos.users.stats(), repos.avatars.stats(), repos.groups.stats()];
|
||||
}
|
||||
|
||||
private repoByName(name: string): InspectableRepo | null {
|
||||
@@ -109,6 +123,7 @@ class RepositoryManager {
|
||||
if (name === "worlds") return r.worlds;
|
||||
if (name === "users") return r.users;
|
||||
if (name === "avatars") return r.avatars;
|
||||
if (name === "groups") return r.groups;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WS_STRING_FIELDS } from "./repository/fieldPolicy";
|
||||
import { activeId } from "../accounts/store";
|
||||
import { entityStore, type SocialSnapshot } from "./entityStore";
|
||||
import { worldStore } from "./worldStore";
|
||||
import { groupStore } from "./groupStore";
|
||||
import { repos } from "./repository/manager";
|
||||
import { broadcast } from "../windows";
|
||||
import { logger } from "../debug/logger";
|
||||
@@ -170,12 +171,14 @@ export async function seedActiveAccount(force = false): Promise<void> {
|
||||
repos.setActive(id);
|
||||
if (!id) {
|
||||
worldStore.reset();
|
||||
groupStore.reset();
|
||||
entityStore.reset();
|
||||
return;
|
||||
}
|
||||
if (!force && alreadyActive) return;
|
||||
|
||||
worldStore.reset();
|
||||
groupStore.reset();
|
||||
entityStore.reset();
|
||||
|
||||
const vrc = getActiveClient();
|
||||
@@ -204,4 +207,9 @@ export function startSocialBridge(): void {
|
||||
if (c.type === "seed") broadcast("world:seed", c.snapshot);
|
||||
else broadcast("world:upsert", c.world);
|
||||
});
|
||||
|
||||
groupStore.onChange((c) => {
|
||||
if (c.type === "seed") broadcast("group:seed", c.snapshot);
|
||||
else broadcast("group:upsert", c.group);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import type { Group } from "../../shared/types/group";
|
||||
import { toGroup } from "./mappers";
|
||||
import { toGroup, toGroupDetail } from "./mappers";
|
||||
import { cachedRead } from "./cachedRead";
|
||||
import { groupStore } from "../store/groupStore";
|
||||
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 async function getGroup(groupId: string): Promise<Group> {
|
||||
const group = await cachedRead(cacheKeys.group(groupId), policies.group, async (vrc) => {
|
||||
const { data } = await vrc.getGroup({ path: { groupId }, throwOnError: true });
|
||||
return toGroupDetail(data);
|
||||
});
|
||||
groupStore.addGroup(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
export async function getUserGroups(userId: string): Promise<Group[]> {
|
||||
const groups = await 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);
|
||||
},
|
||||
);
|
||||
groupStore.setUserGroups(userId, groups);
|
||||
return groups;
|
||||
}
|
||||
|
||||
export async function getRepresentedGroup(userId: string): Promise<Group | null> {
|
||||
const group = await cachedRead(
|
||||
cacheKeys.representedGroup(userId),
|
||||
policies.representedGroup,
|
||||
async (vrc) => {
|
||||
const { data } = await vrc.getUserRepresentedGroup({ path: { userId }, throwOnError: true });
|
||||
return data?.groupId ? toGroup(data) : null;
|
||||
},
|
||||
);
|
||||
groupStore.setRepresented(userId, group);
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
FavoritedWorld,
|
||||
LimitedUserGroups,
|
||||
RepresentedGroup,
|
||||
Group as SdkGroup,
|
||||
User,
|
||||
CurrentUser,
|
||||
LimitedUserFriend,
|
||||
@@ -208,6 +209,29 @@ export function toGroup(raw: LimitedUserGroups | RepresentedGroup): Group {
|
||||
};
|
||||
}
|
||||
|
||||
export function toGroupDetail(raw: SdkGroup): Group {
|
||||
return {
|
||||
id: raw.id ?? "",
|
||||
detailed: true,
|
||||
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,
|
||||
onlineMemberCount: raw.onlineMemberCount,
|
||||
joinState: raw.joinState ?? undefined,
|
||||
isVerified: raw.isVerified ?? undefined,
|
||||
rules: raw.rules || undefined,
|
||||
languages: raw.languages ?? undefined,
|
||||
links: raw.links ?? undefined,
|
||||
tags: raw.tags ?? undefined,
|
||||
createdAt: toIso(raw.createdAt as unknown as string),
|
||||
};
|
||||
}
|
||||
|
||||
function platformsOf(pkgs: ReadonlyArray<{ platform: string }>): WorldPlatforms {
|
||||
let pc = false;
|
||||
let android = false;
|
||||
|
||||
Reference in New Issue
Block a user