mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +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 },
|
avatarFavorites: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
|
||||||
userGroups: { 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 },
|
representedGroup: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
|
||||||
|
group: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 },
|
||||||
} satisfies Record<string, CachePolicy>;
|
} satisfies Record<string, CachePolicy>;
|
||||||
|
|
||||||
export const cacheKeys = {
|
export const cacheKeys = {
|
||||||
@@ -29,4 +30,5 @@ export const cacheKeys = {
|
|||||||
avatarFavorites: () => "avatar:favorites",
|
avatarFavorites: () => "avatar:favorites",
|
||||||
userGroups: (id: string) => `user:groups:${id}`,
|
userGroups: (id: string) => `user:groups:${id}`,
|
||||||
representedGroup: (id: string) => `user:group:represented:${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 * as game from "../game/launch";
|
||||||
import { socialSnapshot } from "../store/social";
|
import { socialSnapshot } from "../store/social";
|
||||||
import { worldStore } from "../store/worldStore";
|
import { worldStore } from "../store/worldStore";
|
||||||
|
import { groupStore } from "../store/groupStore";
|
||||||
import { openDebugWindow } from "../windows";
|
import { openDebugWindow } from "../windows";
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
@@ -50,6 +51,8 @@ const handlers = {
|
|||||||
|
|
||||||
"group:byUser": (userId) => guard(() => groups.getUserGroups(userId)),
|
"group:byUser": (userId) => guard(() => groups.getUserGroups(userId)),
|
||||||
"group:represented": (userId) => guard(() => groups.getRepresentedGroup(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()),
|
"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 },
|
{ 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 { World } from "../../../shared/types/world";
|
||||||
import type { UserProfile } from "../../../shared/types/user";
|
import type { UserProfile } from "../../../shared/types/user";
|
||||||
import type { Avatar } from "../../../shared/types/avatar";
|
import type { Avatar } from "../../../shared/types/avatar";
|
||||||
|
import type { Group } from "../../../shared/types/group";
|
||||||
import type { RepoStats, StoredEntity } from "../../../shared/types/repository";
|
import type { RepoStats, StoredEntity } from "../../../shared/types/repository";
|
||||||
|
|
||||||
interface InspectableRepo {
|
interface InspectableRepo {
|
||||||
@@ -13,12 +14,18 @@ interface InspectableRepo {
|
|||||||
}
|
}
|
||||||
import { Repository } from "./repository";
|
import { Repository } from "./repository";
|
||||||
import { JsonlBackend } from "./backend";
|
import { JsonlBackend } from "./backend";
|
||||||
import { avatarFieldPolicy, userFieldPolicy, worldFieldPolicy } from "./fieldPolicy";
|
import {
|
||||||
|
avatarFieldPolicy,
|
||||||
|
groupFieldPolicy,
|
||||||
|
userFieldPolicy,
|
||||||
|
worldFieldPolicy,
|
||||||
|
} from "./fieldPolicy";
|
||||||
|
|
||||||
export interface AccountRepos {
|
export interface AccountRepos {
|
||||||
worlds: Repository<World>;
|
worlds: Repository<World>;
|
||||||
users: Repository<UserProfile>;
|
users: Repository<UserProfile>;
|
||||||
avatars: Repository<Avatar>;
|
avatars: Repository<Avatar>;
|
||||||
|
groups: Repository<Group>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dbDir(): string {
|
function dbDir(): string {
|
||||||
@@ -53,6 +60,11 @@ class RepositoryManager {
|
|||||||
policy: avatarFieldPolicy,
|
policy: avatarFieldPolicy,
|
||||||
backend: new JsonlBackend<Avatar>(fileFor(accountId, "avatars")),
|
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);
|
this.accounts.set(accountId, repos);
|
||||||
return repos;
|
return repos;
|
||||||
@@ -78,6 +90,7 @@ class RepositoryManager {
|
|||||||
repos.worlds.flush();
|
repos.worlds.flush();
|
||||||
repos.users.flush();
|
repos.users.flush();
|
||||||
repos.avatars.flush();
|
repos.avatars.flush();
|
||||||
|
repos.groups.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,9 +100,10 @@ class RepositoryManager {
|
|||||||
repos.worlds.flush();
|
repos.worlds.flush();
|
||||||
repos.users.flush();
|
repos.users.flush();
|
||||||
repos.avatars.flush();
|
repos.avatars.flush();
|
||||||
|
repos.groups.flush();
|
||||||
this.accounts.delete(accountId);
|
this.accounts.delete(accountId);
|
||||||
}
|
}
|
||||||
for (const type of ["worlds", "users", "avatars"]) {
|
for (const type of ["worlds", "users", "avatars", "groups"]) {
|
||||||
const base = fileFor(accountId, type);
|
const base = fileFor(accountId, type);
|
||||||
rmSync(`${base}.json`, { force: true });
|
rmSync(`${base}.json`, { force: true });
|
||||||
rmSync(`${base}.log`, { force: true });
|
rmSync(`${base}.log`, { force: true });
|
||||||
@@ -100,7 +114,7 @@ class RepositoryManager {
|
|||||||
stats(): RepoStats[] {
|
stats(): RepoStats[] {
|
||||||
if (!this.activeId) return [];
|
if (!this.activeId) return [];
|
||||||
const repos = this.open(this.activeId);
|
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 {
|
private repoByName(name: string): InspectableRepo | null {
|
||||||
@@ -109,6 +123,7 @@ class RepositoryManager {
|
|||||||
if (name === "worlds") return r.worlds;
|
if (name === "worlds") return r.worlds;
|
||||||
if (name === "users") return r.users;
|
if (name === "users") return r.users;
|
||||||
if (name === "avatars") return r.avatars;
|
if (name === "avatars") return r.avatars;
|
||||||
|
if (name === "groups") return r.groups;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { WS_STRING_FIELDS } from "./repository/fieldPolicy";
|
|||||||
import { activeId } from "../accounts/store";
|
import { activeId } from "../accounts/store";
|
||||||
import { entityStore, type SocialSnapshot } from "./entityStore";
|
import { entityStore, type SocialSnapshot } from "./entityStore";
|
||||||
import { worldStore } from "./worldStore";
|
import { worldStore } from "./worldStore";
|
||||||
|
import { groupStore } from "./groupStore";
|
||||||
import { repos } from "./repository/manager";
|
import { repos } from "./repository/manager";
|
||||||
import { broadcast } from "../windows";
|
import { broadcast } from "../windows";
|
||||||
import { logger } from "../debug/logger";
|
import { logger } from "../debug/logger";
|
||||||
@@ -170,12 +171,14 @@ export async function seedActiveAccount(force = false): Promise<void> {
|
|||||||
repos.setActive(id);
|
repos.setActive(id);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
worldStore.reset();
|
worldStore.reset();
|
||||||
|
groupStore.reset();
|
||||||
entityStore.reset();
|
entityStore.reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!force && alreadyActive) return;
|
if (!force && alreadyActive) return;
|
||||||
|
|
||||||
worldStore.reset();
|
worldStore.reset();
|
||||||
|
groupStore.reset();
|
||||||
entityStore.reset();
|
entityStore.reset();
|
||||||
|
|
||||||
const vrc = getActiveClient();
|
const vrc = getActiveClient();
|
||||||
@@ -204,4 +207,9 @@ export function startSocialBridge(): void {
|
|||||||
if (c.type === "seed") broadcast("world:seed", c.snapshot);
|
if (c.type === "seed") broadcast("world:seed", c.snapshot);
|
||||||
else broadcast("world:upsert", c.world);
|
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 type { Group } from "../../shared/types/group";
|
||||||
import { toGroup } from "./mappers";
|
import { toGroup, toGroupDetail } from "./mappers";
|
||||||
import { cachedRead } from "./cachedRead";
|
import { cachedRead } from "./cachedRead";
|
||||||
|
import { groupStore } from "../store/groupStore";
|
||||||
import { cacheKeys, policies } from "../cache/policies";
|
import { cacheKeys, policies } from "../cache/policies";
|
||||||
|
|
||||||
export function getUserGroups(userId: string): Promise<Group[]> {
|
export async function getGroup(groupId: string): Promise<Group> {
|
||||||
return cachedRead(cacheKeys.userGroups(userId), policies.userGroups, async (vrc) => {
|
const group = await cachedRead(cacheKeys.group(groupId), policies.group, async (vrc) => {
|
||||||
const { data } = await vrc.getUserGroups({ path: { userId }, throwOnError: true });
|
const { data } = await vrc.getGroup({ path: { groupId }, throwOnError: true });
|
||||||
return data.map(toGroup).filter((g) => g.id);
|
return toGroupDetail(data);
|
||||||
});
|
});
|
||||||
|
groupStore.addGroup(group);
|
||||||
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRepresentedGroup(userId: string): Promise<Group | null> {
|
export async function getUserGroups(userId: string): Promise<Group[]> {
|
||||||
return cachedRead(cacheKeys.representedGroup(userId), policies.representedGroup, async (vrc) => {
|
const groups = await cachedRead(
|
||||||
const { data } = await vrc.getUserRepresentedGroup({ path: { userId }, throwOnError: true });
|
cacheKeys.userGroups(userId),
|
||||||
return data?.groupId ? toGroup(data) : null;
|
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,
|
FavoritedWorld,
|
||||||
LimitedUserGroups,
|
LimitedUserGroups,
|
||||||
RepresentedGroup,
|
RepresentedGroup,
|
||||||
|
Group as SdkGroup,
|
||||||
User,
|
User,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
LimitedUserFriend,
|
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 {
|
function platformsOf(pkgs: ReadonlyArray<{ platform: string }>): WorldPlatforms {
|
||||||
let pc = false;
|
let pc = false;
|
||||||
let android = false;
|
let android = false;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { ProfileView } from "../features/profile/ProfileView";
|
import { ProfileView } from "../features/profile/ProfileView";
|
||||||
import { WorldView } from "../features/world/WorldView";
|
import { WorldView } from "../features/world/WorldView";
|
||||||
|
import { GroupView } from "../features/group/GroupView";
|
||||||
import { AccountSettingsView } from "../features/account/AccountSettingsView";
|
import { AccountSettingsView } from "../features/account/AccountSettingsView";
|
||||||
import { SearchView } from "../features/search/SearchView";
|
import { SearchView } from "../features/search/SearchView";
|
||||||
import { SettingsView } from "../features/settings/SettingsView";
|
import { SettingsView } from "../features/settings/SettingsView";
|
||||||
@@ -98,7 +99,7 @@ function Shell() {
|
|||||||
|
|
||||||
const openProfile = (id: "me" | string) => nav.openUser(id);
|
const openProfile = (id: "me" | string) => nav.openUser(id);
|
||||||
const stageKey =
|
const stageKey =
|
||||||
nav.current.kind === "user" || nav.current.kind === "world"
|
nav.current.kind === "user" || nav.current.kind === "world" || nav.current.kind === "group"
|
||||||
? `${nav.current.kind}:${nav.current.id}`
|
? `${nav.current.kind}:${nav.current.id}`
|
||||||
: nav.current.kind;
|
: nav.current.kind;
|
||||||
|
|
||||||
@@ -163,6 +164,8 @@ function Shell() {
|
|||||||
<div key={stageKey} className="stage__inner animate-rise">
|
<div key={stageKey} className="stage__inner animate-rise">
|
||||||
{nav.current.kind === "world" ? (
|
{nav.current.kind === "world" ? (
|
||||||
<WorldView worldId={nav.current.id} />
|
<WorldView worldId={nav.current.id} />
|
||||||
|
) : nav.current.kind === "group" ? (
|
||||||
|
<GroupView groupId={nav.current.id} />
|
||||||
) : nav.current.kind === "account" ? (
|
) : nav.current.kind === "account" ? (
|
||||||
<AccountSettingsView />
|
<AccountSettingsView />
|
||||||
) : nav.current.kind === "settings" ? (
|
) : nav.current.kind === "settings" ? (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSocial } from "../../store/social";
|
import { useSocial } from "../../store/social";
|
||||||
import { useWorlds } from "../../store/worlds";
|
import { useWorlds } from "../../store/worlds";
|
||||||
|
import { useGroups } from "../../store/groups";
|
||||||
import { useCopied, useDebug } from "./useDebug";
|
import { useCopied, useDebug } from "./useDebug";
|
||||||
import { Count, TabButton } from "./ui";
|
import { Count, TabButton } from "./ui";
|
||||||
import { CacheTab } from "./tabs/CacheTab";
|
import { CacheTab } from "./tabs/CacheTab";
|
||||||
@@ -8,10 +9,11 @@ import { ReposTab } from "./tabs/ReposTab";
|
|||||||
import { ThumbnailsTab } from "./tabs/ThumbnailsTab";
|
import { ThumbnailsTab } from "./tabs/ThumbnailsTab";
|
||||||
import { SocialTab } from "./tabs/SocialTab";
|
import { SocialTab } from "./tabs/SocialTab";
|
||||||
import { WorldStorePanel } from "./tabs/WorldsTab";
|
import { WorldStorePanel } from "./tabs/WorldsTab";
|
||||||
|
import { GroupStorePanel } from "./tabs/GroupsTab";
|
||||||
import { WsTab } from "./tabs/WebSocketTab";
|
import { WsTab } from "./tabs/WebSocketTab";
|
||||||
import { LogsTab } from "./tabs/LogsTab";
|
import { LogsTab } from "./tabs/LogsTab";
|
||||||
|
|
||||||
type Tab = "cache" | "repos" | "thumbnails" | "social" | "worlds" | "ws" | "logs";
|
type Tab = "cache" | "repos" | "thumbnails" | "social" | "worlds" | "groups" | "ws" | "logs";
|
||||||
|
|
||||||
export function DebugPanel() {
|
export function DebugPanel() {
|
||||||
const { cache, stats, logs, ws, repoStats, invalidate, clear, clearLogs, clearWs } = useDebug();
|
const { cache, stats, logs, ws, repoStats, invalidate, clear, clearLogs, clearWs } = useDebug();
|
||||||
@@ -19,6 +21,7 @@ export function DebugPanel() {
|
|||||||
|
|
||||||
const friendCount = useSocial((s) => Object.values(s.users).filter((u) => u.isFriend).length);
|
const friendCount = useSocial((s) => Object.values(s.users).filter((u) => u.isFriend).length);
|
||||||
const worldCount = useWorlds((s) => Object.keys(s.worlds).length);
|
const worldCount = useWorlds((s) => Object.keys(s.worlds).length);
|
||||||
|
const groupCount = useGroups((s) => Object.keys(s.groups).length);
|
||||||
const repoCount = repoStats.reduce((sum, r) => sum + r.count, 0);
|
const repoCount = repoStats.reduce((sum, r) => sum + r.count, 0);
|
||||||
const [exported, exportSnapshot] = useCopied();
|
const [exported, exportSnapshot] = useCopied();
|
||||||
|
|
||||||
@@ -30,7 +33,13 @@ export function DebugPanel() {
|
|||||||
platform: window.api?.platform,
|
platform: window.api?.platform,
|
||||||
cacheStats: stats,
|
cacheStats: stats,
|
||||||
repoStats,
|
repoStats,
|
||||||
counts: { friends: friendCount, worlds: worldCount, ws: ws.length, logs: logs.length },
|
counts: {
|
||||||
|
friends: friendCount,
|
||||||
|
worlds: worldCount,
|
||||||
|
groups: groupCount,
|
||||||
|
ws: ws.length,
|
||||||
|
logs: logs.length,
|
||||||
|
},
|
||||||
logs,
|
logs,
|
||||||
ws,
|
ws,
|
||||||
},
|
},
|
||||||
@@ -58,6 +67,9 @@ export function DebugPanel() {
|
|||||||
<TabButton active={tab === "worlds"} onClick={() => setTab("worlds")}>
|
<TabButton active={tab === "worlds"} onClick={() => setTab("worlds")}>
|
||||||
Worlds <Count n={worldCount} />
|
Worlds <Count n={worldCount} />
|
||||||
</TabButton>
|
</TabButton>
|
||||||
|
<TabButton active={tab === "groups"} onClick={() => setTab("groups")}>
|
||||||
|
Groups <Count n={groupCount} />
|
||||||
|
</TabButton>
|
||||||
<TabButton active={tab === "ws"} onClick={() => setTab("ws")}>
|
<TabButton active={tab === "ws"} onClick={() => setTab("ws")}>
|
||||||
WebSocket <Count n={ws.length} />
|
WebSocket <Count n={ws.length} />
|
||||||
</TabButton>
|
</TabButton>
|
||||||
@@ -83,6 +95,8 @@ export function DebugPanel() {
|
|||||||
<SocialTab />
|
<SocialTab />
|
||||||
) : tab === "worlds" ? (
|
) : tab === "worlds" ? (
|
||||||
<WorldStorePanel />
|
<WorldStorePanel />
|
||||||
|
) : tab === "groups" ? (
|
||||||
|
<GroupStorePanel />
|
||||||
) : tab === "ws" ? (
|
) : tab === "ws" ? (
|
||||||
<WsTab ws={ws} onClear={clearWs} />
|
<WsTab ws={ws} onClear={clearWs} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ChevronDown, ChevronRight, Users } from "lucide-react";
|
||||||
|
import type { Group } from "../../../../../shared/types/group";
|
||||||
|
import { Avatar, Badge, Panel } from "../../../components/ui";
|
||||||
|
import { useAllGroups, useGroups } from "../../../store/groups";
|
||||||
|
import { useCopied } from "../useDebug";
|
||||||
|
import { Empty, SearchInput } from "../ui";
|
||||||
|
|
||||||
|
export function GroupStorePanel() {
|
||||||
|
const groups = useAllGroups();
|
||||||
|
const byUser = useGroups((s) => s.byUser);
|
||||||
|
const userCount = Object.keys(byUser).length;
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return [...groups]
|
||||||
|
.filter((g) => (q ? g.name.toLowerCase().includes(q) || g.id.includes(q) : true))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}, [groups, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Group store"
|
||||||
|
meta={`${shown.length}/${groups.length} groups · ${userCount} users`}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
>
|
||||||
|
<div className="shrink-0 border-b border-border px-4 py-2.5">
|
||||||
|
<SearchInput value={query} onChange={setQuery} placeholder="Filter by name or id…" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-auto">
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<Empty>
|
||||||
|
{groups.length === 0
|
||||||
|
? "No groups yet — open a profile to seed it."
|
||||||
|
: "No groups match."}
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
shown.map((g) => <GroupStoreRow key={g.id} group={g} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupStoreRow({ group }: { group: Group }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [copied, copy] = useCopied();
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2.5 px-4 py-2 text-left hover:bg-surface-2"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="flex w-3 shrink-0 justify-center text-faint">
|
||||||
|
{open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||||
|
</span>
|
||||||
|
<Avatar src={group.iconUrl} name={group.name} size={32} className="!rounded" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-[13px] font-medium">{group.name}</span>
|
||||||
|
<code className="truncate font-mono text-[10px] text-faint">{group.id}</code>
|
||||||
|
</div>
|
||||||
|
{group.detailed ? null : <Badge tone="warn">list</Badge>}
|
||||||
|
<span className="flex shrink-0 items-center gap-1 text-[11px] tabular-nums text-faint">
|
||||||
|
<Users size={11} /> {group.memberCount ?? "—"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="px-4 pb-3 pl-[2.4rem]">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
|
||||||
|
onClick={() => copy(JSON.stringify(group, null, 2))}
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
|
||||||
|
{JSON.stringify(group, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { BadgeCheck, Globe, Users, UserCheck } from "lucide-react";
|
||||||
|
import type { Group } from "../../../../shared/types/group";
|
||||||
|
import { Avatar, Banner, Fact, Section, StatTile, Tag } from "../../components/ui";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { useAsync } from "../../lib/useAsync";
|
||||||
|
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
|
||||||
|
import { useNav } from "../navigation/NavContext";
|
||||||
|
import { useGroups } from "../../store/groups";
|
||||||
|
import { COL_WIDE } from "../../lib/layout";
|
||||||
|
import "../profile/profile.css";
|
||||||
|
|
||||||
|
export function GroupView({ groupId }: { groupId: string }) {
|
||||||
|
const cached = useGroups((s) => s.groups[groupId]);
|
||||||
|
const load = useAsync(() => api.group.get(groupId), [groupId], "This group is unavailable.");
|
||||||
|
|
||||||
|
if (load.status === "error" && !cached?.detailed) {
|
||||||
|
return <Banner className="m-10 max-w-[420px]">{load.message}</Banner>;
|
||||||
|
}
|
||||||
|
if (!cached?.detailed) return <GroupSkeleton />;
|
||||||
|
return <GroupCard group={cached} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupCard({ group }: { group: Group }) {
|
||||||
|
const { openUser } = useNav();
|
||||||
|
const authorTags = (group.tags ?? []).filter((t) => t.startsWith("group_tag_"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="profile flex min-h-full w-full flex-col bg-surface pb-12">
|
||||||
|
<div
|
||||||
|
className="profile__banner"
|
||||||
|
style={{ backgroundImage: group.bannerUrl ? `url(${group.bannerUrl})` : undefined }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={`${COL_WIDE} relative flex items-end gap-5`} style={{ marginTop: -64 }}>
|
||||||
|
<Avatar src={group.iconUrl} name={group.name} size={112} className="!rounded-2xl" />
|
||||||
|
<div className="min-w-0 pb-1">
|
||||||
|
<h2 className="flex items-center gap-2 text-[30px] font-bold leading-tight tracking-[-0.6px]">
|
||||||
|
{group.name}
|
||||||
|
{group.isVerified ? <BadgeCheck size={22} className="text-accent" /> : null}
|
||||||
|
</h2>
|
||||||
|
{group.ownerId ? (
|
||||||
|
<p className="mt-1 text-[14px] text-muted">
|
||||||
|
owned by{" "}
|
||||||
|
<button
|
||||||
|
onClick={() => openUser(group.ownerId!)}
|
||||||
|
className="font-semibold text-text transition-colors hover:text-accent"
|
||||||
|
>
|
||||||
|
View owner
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{group.shortCode ? <Tag>{group.shortCode}</Tag> : null}
|
||||||
|
{group.privacy && group.privacy !== "default" ? (
|
||||||
|
<Tag color="var(--status-ask)">{group.privacy}</Tag>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${COL_WIDE} mt-6 flex flex-col gap-5`}>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<StatTile
|
||||||
|
icon={<Users size={15} />}
|
||||||
|
label="Members"
|
||||||
|
value={typeof group.memberCount === "number" ? compactNumber(group.memberCount) : "—"}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon={<UserCheck size={15} />}
|
||||||
|
label="Online"
|
||||||
|
value={
|
||||||
|
typeof group.onlineMemberCount === "number"
|
||||||
|
? compactNumber(group.onlineMemberCount)
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
live={(group.onlineMemberCount ?? 0) > 0}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon={<Globe size={15} />}
|
||||||
|
label="Languages"
|
||||||
|
value={group.languages?.length ? group.languages.join(", ").toUpperCase() : "—"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.description ? (
|
||||||
|
<Section title="About">
|
||||||
|
<p className="whitespace-pre-wrap text-[14px] leading-relaxed text-muted">
|
||||||
|
{group.description}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{group.rules ? (
|
||||||
|
<Section title="Rules">
|
||||||
|
<p className="whitespace-pre-wrap text-[14px] leading-relaxed text-muted">
|
||||||
|
{group.rules}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2">
|
||||||
|
<Section title="Details">
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||||
|
{group.joinState ? <Fact label="Joining" value={group.joinState} /> : null}
|
||||||
|
{group.createdAt ? (
|
||||||
|
<Fact label="Created" value={formatDate(group.createdAt)} />
|
||||||
|
) : null}
|
||||||
|
<Fact label="Group ID" value={group.id} mono />
|
||||||
|
</dl>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{authorTags.length ? (
|
||||||
|
<Section title="Tags">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{authorTags.map((t) => (
|
||||||
|
<Tag key={t}>{prettyTag(t, "group_tag_")}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="profile profile--skeleton flex min-h-full w-full flex-col bg-surface pb-12"
|
||||||
|
aria-busy
|
||||||
|
>
|
||||||
|
<div className="profile__banner" />
|
||||||
|
<div className={`${COL_WIDE} relative flex items-end gap-5`} style={{ marginTop: -64 }}>
|
||||||
|
<div className="sk size-[112px] rounded-2xl" />
|
||||||
|
<div className="flex-1 pb-1">
|
||||||
|
<div className="sk h-[22px] w-2/5 rounded-lg" />
|
||||||
|
<div className="sk mt-3 h-3 w-1/4 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`${COL_WIDE} mt-6 grid grid-cols-3 gap-3`}>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="sk h-[72px] rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { createContext, useContext, useMemo, useState } from "react";
|
|||||||
export type View =
|
export type View =
|
||||||
| { kind: "user"; id: "me" | string }
|
| { kind: "user"; id: "me" | string }
|
||||||
| { kind: "world"; id: string }
|
| { kind: "world"; id: string }
|
||||||
|
| { kind: "group"; id: string }
|
||||||
| { kind: "account" }
|
| { kind: "account" }
|
||||||
| { kind: "settings" }
|
| { kind: "settings" }
|
||||||
| { kind: "enhancements" }
|
| { kind: "enhancements" }
|
||||||
@@ -14,6 +15,7 @@ interface Nav {
|
|||||||
canBack: boolean;
|
canBack: boolean;
|
||||||
openUser: (id: "me" | string) => void;
|
openUser: (id: "me" | string) => void;
|
||||||
openWorld: (id: string) => void;
|
openWorld: (id: string) => void;
|
||||||
|
openGroup: (id: string) => void;
|
||||||
openAccount: () => void;
|
openAccount: () => void;
|
||||||
openSettings: () => void;
|
openSettings: () => void;
|
||||||
openEnhancements: () => void;
|
openEnhancements: () => void;
|
||||||
@@ -42,6 +44,7 @@ export function NavProvider({ children }: { children: React.ReactNode }) {
|
|||||||
canBack: stack.length > 1,
|
canBack: stack.length > 1,
|
||||||
openUser: (id) => push({ kind: "user", id }),
|
openUser: (id) => push({ kind: "user", id }),
|
||||||
openWorld: (id) => push({ kind: "world", id }),
|
openWorld: (id) => push({ kind: "world", id }),
|
||||||
|
openGroup: (id) => push({ kind: "group", id }),
|
||||||
openAccount: () => root({ kind: "account" }),
|
openAccount: () => root({ kind: "account" }),
|
||||||
openSettings: () => root({ kind: "settings" }),
|
openSettings: () => root({ kind: "settings" }),
|
||||||
openEnhancements: () => root({ kind: "enhancements" }),
|
openEnhancements: () => root({ kind: "enhancements" }),
|
||||||
@@ -59,6 +62,7 @@ function sameView(a: View, b: View): boolean {
|
|||||||
if (a.kind !== b.kind) return false;
|
if (a.kind !== b.kind) return false;
|
||||||
if (a.kind === "user" && b.kind === "user") return a.id === b.id;
|
if (a.kind === "user" && b.kind === "user") return a.id === b.id;
|
||||||
if (a.kind === "world" && b.kind === "world") return a.id === b.id;
|
if (a.kind === "world" && b.kind === "world") return a.id === b.id;
|
||||||
|
if (a.kind === "group" && b.kind === "group") return a.id === b.id;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Star, Users } from "lucide-react";
|
import { Star, Users } from "lucide-react";
|
||||||
import type { Group } from "../../../../shared/types/group";
|
import type { Group } from "../../../../shared/types/group";
|
||||||
import { Avatar, CollapsibleCard, SkeletonGrid } from "../../components/ui";
|
import { Avatar, CollapsibleCard, SkeletonGrid } from "../../components/ui";
|
||||||
|
import { useNav } from "../navigation/NavContext";
|
||||||
import { useUserGroups } from "./useUserGroups";
|
import { useUserGroups } from "./useUserGroups";
|
||||||
|
|
||||||
export function GroupsSection({ userId }: { userId: string }) {
|
export function GroupsSection({ userId }: { userId: string }) {
|
||||||
@@ -40,9 +41,11 @@ export function GroupsSection({ userId }: { userId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FeaturedGroup({ group }: { group: Group }) {
|
function FeaturedGroup({ group }: { group: Group }) {
|
||||||
|
const { openGroup } = useNav();
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
className="relative overflow-hidden rounded-lg border border-accent/40 bg-surface p-3.5"
|
onClick={() => openGroup(group.id)}
|
||||||
|
className="relative block w-full overflow-hidden rounded-lg border border-accent/40 bg-surface p-3.5 text-left transition-colors hover:border-accent"
|
||||||
style={{ "--ring": "var(--accent)" } as React.CSSProperties}
|
style={{ "--ring": "var(--accent)" } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
{group.bannerUrl ? (
|
{group.bannerUrl ? (
|
||||||
@@ -67,13 +70,17 @@ function FeaturedGroup({ group }: { group: Group }) {
|
|||||||
<GroupMeta group={group} />
|
<GroupMeta group={group} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupRow({ group }: { group: Group }) {
|
function GroupRow({ group }: { group: Group }) {
|
||||||
|
const { openGroup } = useNav();
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-surface p-2.5">
|
<button
|
||||||
|
onClick={() => openGroup(group.id)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg border border-border bg-surface p-2.5 text-left transition-colors hover:border-accent"
|
||||||
|
>
|
||||||
<Avatar src={group.iconUrl} name={group.name} size={40} className="!rounded-lg" />
|
<Avatar src={group.iconUrl} name={group.name} size={40} className="!rounded-lg" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-[13.5px] font-semibold" title={group.name}>
|
<div className="truncate text-[13.5px] font-semibold" title={group.name}>
|
||||||
@@ -81,7 +88,7 @@ function GroupRow({ group }: { group: Group }) {
|
|||||||
</div>
|
</div>
|
||||||
<GroupMeta group={group} />
|
<GroupMeta group={group} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Group } from "../../../../shared/types/group";
|
import type { Group } from "../../../../shared/types/group";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { useAsync } from "../../lib/useAsync";
|
import { useAsync } from "../../lib/useAsync";
|
||||||
|
import { useUserGroupList, useRepresentedGroup } from "../../store/groups";
|
||||||
|
|
||||||
type Status = "loading" | "ready" | "error";
|
type Status = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -10,22 +11,17 @@ export function useUserGroups(userId: string): {
|
|||||||
represented: Group | null;
|
represented: Group | null;
|
||||||
message?: string;
|
message?: string;
|
||||||
} {
|
} {
|
||||||
|
const groups = useUserGroupList(userId);
|
||||||
|
const represented = useRepresentedGroup(userId) ?? null;
|
||||||
|
|
||||||
const state = useAsync(
|
const state = useAsync(
|
||||||
() =>
|
() => Promise.all([api.group.byUser(userId), api.group.represented(userId)]),
|
||||||
Promise.all([api.group.byUser(userId), api.group.represented(userId)]).then(
|
|
||||||
([groups, represented]) => ({ groups, represented }),
|
|
||||||
),
|
|
||||||
[userId],
|
[userId],
|
||||||
"Failed to load groups.",
|
"Failed to load groups.",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (state.status === "ready") {
|
if (groups.length || represented) return { status: "ready", groups, represented };
|
||||||
return { status: "ready", groups: state.data.groups, represented: state.data.represented };
|
if (state.status === "error")
|
||||||
}
|
return { status: "error", groups, represented, message: state.message };
|
||||||
return {
|
return { status: state.status === "ready" ? "ready" : "loading", groups, represented };
|
||||||
status: state.status,
|
|
||||||
groups: [],
|
|
||||||
represented: null,
|
|
||||||
message: state.status === "error" ? state.message : undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export const api = {
|
|||||||
group: {
|
group: {
|
||||||
byUser: (userId: string) => call("group:byUser", userId),
|
byUser: (userId: string) => call("group:byUser", userId),
|
||||||
represented: (userId: string) => call("group:represented", userId),
|
represented: (userId: string) => call("group:represented", userId),
|
||||||
|
get: (groupId: string) => call("group:get", groupId),
|
||||||
|
snapshot: () => call("group:snapshot"),
|
||||||
},
|
},
|
||||||
social: {
|
social: {
|
||||||
snapshot: () => call("social:snapshot"),
|
snapshot: () => call("social:snapshot"),
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import type { Group, GroupSnapshot } from "../../../shared/types/group";
|
||||||
|
import { api, events } from "../lib/api";
|
||||||
|
|
||||||
|
interface GroupState {
|
||||||
|
groups: Record<string, Group>;
|
||||||
|
byUser: Record<string, string[]>;
|
||||||
|
representedByUser: Record<string, string>;
|
||||||
|
seed: (s: GroupSnapshot) => void;
|
||||||
|
upsert: (g: Group) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGroups = create<GroupState>((set) => ({
|
||||||
|
groups: {},
|
||||||
|
byUser: {},
|
||||||
|
representedByUser: {},
|
||||||
|
seed: (s) =>
|
||||||
|
set({
|
||||||
|
groups: Object.fromEntries(s.groups.map((g) => [g.id, g])),
|
||||||
|
byUser: s.byUser,
|
||||||
|
representedByUser: s.representedByUser,
|
||||||
|
}),
|
||||||
|
upsert: (g) => set((st) => ({ groups: { ...st.groups, [g.id]: g } })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.on("group:seed", (s) => useGroups.getState().seed(s));
|
||||||
|
events.on("group:upsert", (g) => useGroups.getState().upsert(g));
|
||||||
|
api.group
|
||||||
|
.snapshot()
|
||||||
|
.then((s) => useGroups.getState().seed(s))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
const fetching = new Set<string>();
|
||||||
|
const failed = new Set<string>();
|
||||||
|
|
||||||
|
export function useGroup(groupId?: string): Group | undefined {
|
||||||
|
const group = useGroups((s) => (groupId ? s.groups[groupId] : undefined));
|
||||||
|
if (groupId && !group?.detailed && !fetching.has(groupId) && !failed.has(groupId)) {
|
||||||
|
fetching.add(groupId);
|
||||||
|
api.group
|
||||||
|
.get(groupId)
|
||||||
|
.catch(() => failed.add(groupId))
|
||||||
|
.finally(() => fetching.delete(groupId));
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAllGroups = (): Group[] => useGroups(useShallow((s) => Object.values(s.groups)));
|
||||||
|
|
||||||
|
export const useUserGroupList = (userId: string): Group[] =>
|
||||||
|
useGroups(useShallow((s) => (s.byUser[userId] ?? []).map((id) => s.groups[id]).filter(Boolean)));
|
||||||
|
|
||||||
|
export const useRepresentedGroup = (userId: string): Group | undefined =>
|
||||||
|
useGroups((s) => {
|
||||||
|
const id = s.representedByUser[userId];
|
||||||
|
return id ? s.groups[id] : undefined;
|
||||||
|
});
|
||||||
+5
-1
@@ -10,7 +10,7 @@ import type { FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
|
|||||||
import type { Avatar } from "./types/avatar";
|
import type { Avatar } from "./types/avatar";
|
||||||
import type { RepoStats, StoredEntity } from "./types/repository";
|
import type { RepoStats, StoredEntity } from "./types/repository";
|
||||||
import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from "./types/settings";
|
import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from "./types/settings";
|
||||||
import type { Group } from "./types/group";
|
import type { Group, GroupSnapshot } from "./types/group";
|
||||||
import type { EnhancementId, EnhancementsSnapshot } from "./types/enhancements";
|
import type { EnhancementId, EnhancementsSnapshot } from "./types/enhancements";
|
||||||
import type { GallerySnapshot, Photo, ThumbCacheStats } from "./types/gallery";
|
import type { GallerySnapshot, Photo, ThumbCacheStats } from "./types/gallery";
|
||||||
import type { AppConfig } from "./types/appConfig";
|
import type { AppConfig } from "./types/appConfig";
|
||||||
@@ -51,6 +51,8 @@ export interface IpcRequests {
|
|||||||
|
|
||||||
"group:byUser": (userId: string) => IpcResult<Group[]>;
|
"group:byUser": (userId: string) => IpcResult<Group[]>;
|
||||||
"group:represented": (userId: string) => IpcResult<Group | null>;
|
"group:represented": (userId: string) => IpcResult<Group | null>;
|
||||||
|
"group:get": (groupId: string) => IpcResult<Group>;
|
||||||
|
"group:snapshot": () => IpcResult<GroupSnapshot>;
|
||||||
|
|
||||||
"social:snapshot": () => IpcResult<SocialSnapshot>;
|
"social:snapshot": () => IpcResult<SocialSnapshot>;
|
||||||
|
|
||||||
@@ -119,6 +121,8 @@ export interface IpcEvents {
|
|||||||
"social:upsert": UserProfile;
|
"social:upsert": UserProfile;
|
||||||
"world:seed": WorldSnapshot;
|
"world:seed": WorldSnapshot;
|
||||||
"world:upsert": World;
|
"world:upsert": World;
|
||||||
|
"group:seed": GroupSnapshot;
|
||||||
|
"group:upsert": Group;
|
||||||
"world:favoriteFolders": { userId: string; folders: FavoriteWorldFolder[]; done: boolean };
|
"world:favoriteFolders": { userId: string; folders: FavoriteWorldFolder[]; done: boolean };
|
||||||
"game:changed": GameStatus;
|
"game:changed": GameStatus;
|
||||||
"gallery:added": Photo;
|
"gallery:added": Photo;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface Group {
|
export interface Group {
|
||||||
id: string;
|
id: string;
|
||||||
|
detailed?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
shortCode?: string;
|
shortCode?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -9,4 +10,19 @@ export interface Group {
|
|||||||
memberCount?: number;
|
memberCount?: number;
|
||||||
privacy?: string;
|
privacy?: string;
|
||||||
isRepresenting?: boolean;
|
isRepresenting?: boolean;
|
||||||
|
|
||||||
|
onlineMemberCount?: number;
|
||||||
|
joinState?: string;
|
||||||
|
isVerified?: boolean;
|
||||||
|
rules?: string;
|
||||||
|
languages?: string[];
|
||||||
|
links?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupSnapshot {
|
||||||
|
groups: Group[];
|
||||||
|
byUser: Record<string, string[]>;
|
||||||
|
representedByUser: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user