feat: Groups info page and store

This commit is contained in:
2026-06-28 00:17:59 +07:00
parent aeff519ae0
commit a4f052727a
19 changed files with 529 additions and 35 deletions
+2
View File
@@ -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}`,
};
+3
View File
@@ -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()),
+75
View File
@@ -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();
+8
View File
@@ -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 },
);
+18 -3
View File
@@ -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
View File
@@ -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);
});
}
+32 -10
View File
@@ -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;
}
+24
View File
@@ -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;