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);
});
}
+30 -8
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) => {
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;
+4 -1
View File
@@ -15,6 +15,7 @@ import {
import type { LucideIcon } from "lucide-react";
import { ProfileView } from "../features/profile/ProfileView";
import { WorldView } from "../features/world/WorldView";
import { GroupView } from "../features/group/GroupView";
import { AccountSettingsView } from "../features/account/AccountSettingsView";
import { SearchView } from "../features/search/SearchView";
import { SettingsView } from "../features/settings/SettingsView";
@@ -98,7 +99,7 @@ function Shell() {
const openProfile = (id: "me" | string) => nav.openUser(id);
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;
@@ -163,6 +164,8 @@ function Shell() {
<div key={stageKey} className="stage__inner animate-rise">
{nav.current.kind === "world" ? (
<WorldView worldId={nav.current.id} />
) : nav.current.kind === "group" ? (
<GroupView groupId={nav.current.id} />
) : nav.current.kind === "account" ? (
<AccountSettingsView />
) : nav.current.kind === "settings" ? (
+16 -2
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useSocial } from "../../store/social";
import { useWorlds } from "../../store/worlds";
import { useGroups } from "../../store/groups";
import { useCopied, useDebug } from "./useDebug";
import { Count, TabButton } from "./ui";
import { CacheTab } from "./tabs/CacheTab";
@@ -8,10 +9,11 @@ import { ReposTab } from "./tabs/ReposTab";
import { ThumbnailsTab } from "./tabs/ThumbnailsTab";
import { SocialTab } from "./tabs/SocialTab";
import { WorldStorePanel } from "./tabs/WorldsTab";
import { GroupStorePanel } from "./tabs/GroupsTab";
import { WsTab } from "./tabs/WebSocketTab";
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() {
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 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 [exported, exportSnapshot] = useCopied();
@@ -30,7 +33,13 @@ export function DebugPanel() {
platform: window.api?.platform,
cacheStats: stats,
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,
ws,
},
@@ -58,6 +67,9 @@ export function DebugPanel() {
<TabButton active={tab === "worlds"} onClick={() => setTab("worlds")}>
Worlds <Count n={worldCount} />
</TabButton>
<TabButton active={tab === "groups"} onClick={() => setTab("groups")}>
Groups <Count n={groupCount} />
</TabButton>
<TabButton active={tab === "ws"} onClick={() => setTab("ws")}>
WebSocket <Count n={ws.length} />
</TabButton>
@@ -83,6 +95,8 @@ export function DebugPanel() {
<SocialTab />
) : tab === "worlds" ? (
<WorldStorePanel />
) : tab === "groups" ? (
<GroupStorePanel />
) : tab === "ws" ? (
<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 =
| { kind: "user"; id: "me" | string }
| { kind: "world"; id: string }
| { kind: "group"; id: string }
| { kind: "account" }
| { kind: "settings" }
| { kind: "enhancements" }
@@ -14,6 +15,7 @@ interface Nav {
canBack: boolean;
openUser: (id: "me" | string) => void;
openWorld: (id: string) => void;
openGroup: (id: string) => void;
openAccount: () => void;
openSettings: () => void;
openEnhancements: () => void;
@@ -42,6 +44,7 @@ export function NavProvider({ children }: { children: React.ReactNode }) {
canBack: stack.length > 1,
openUser: (id) => push({ kind: "user", id }),
openWorld: (id) => push({ kind: "world", id }),
openGroup: (id) => push({ kind: "group", id }),
openAccount: () => root({ kind: "account" }),
openSettings: () => root({ kind: "settings" }),
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 === "user" && b.kind === "user") 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;
}
@@ -1,6 +1,7 @@
import { Star, Users } from "lucide-react";
import type { Group } from "../../../../shared/types/group";
import { Avatar, CollapsibleCard, SkeletonGrid } from "../../components/ui";
import { useNav } from "../navigation/NavContext";
import { useUserGroups } from "./useUserGroups";
export function GroupsSection({ userId }: { userId: string }) {
@@ -40,9 +41,11 @@ export function GroupsSection({ userId }: { userId: string }) {
}
function FeaturedGroup({ group }: { group: Group }) {
const { openGroup } = useNav();
return (
<div
className="relative overflow-hidden rounded-lg border border-accent/40 bg-surface p-3.5"
<button
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}
>
{group.bannerUrl ? (
@@ -67,13 +70,17 @@ function FeaturedGroup({ group }: { group: Group }) {
<GroupMeta group={group} />
</div>
</div>
</div>
</button>
);
}
function GroupRow({ group }: { group: Group }) {
const { openGroup } = useNav();
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" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13.5px] font-semibold" title={group.name}>
@@ -81,7 +88,7 @@ function GroupRow({ group }: { group: Group }) {
</div>
<GroupMeta group={group} />
</div>
</div>
</button>
);
}
@@ -1,6 +1,7 @@
import type { Group } from "../../../../shared/types/group";
import { api } from "../../lib/api";
import { useAsync } from "../../lib/useAsync";
import { useUserGroupList, useRepresentedGroup } from "../../store/groups";
type Status = "loading" | "ready" | "error";
@@ -10,22 +11,17 @@ export function useUserGroups(userId: string): {
represented: Group | null;
message?: string;
} {
const groups = useUserGroupList(userId);
const represented = useRepresentedGroup(userId) ?? null;
const state = useAsync(
() =>
Promise.all([api.group.byUser(userId), api.group.represented(userId)]).then(
([groups, represented]) => ({ groups, represented }),
),
() => Promise.all([api.group.byUser(userId), api.group.represented(userId)]),
[userId],
"Failed to load groups.",
);
if (state.status === "ready") {
return { status: "ready", groups: state.data.groups, represented: state.data.represented };
}
return {
status: state.status,
groups: [],
represented: null,
message: state.status === "error" ? state.message : undefined,
};
if (groups.length || represented) return { status: "ready", groups, represented };
if (state.status === "error")
return { status: "error", groups, represented, message: state.message };
return { status: state.status === "ready" ? "ready" : "loading", groups, represented };
}
+2
View File
@@ -62,6 +62,8 @@ export const api = {
group: {
byUser: (userId: string) => call("group:byUser", userId),
represented: (userId: string) => call("group:represented", userId),
get: (groupId: string) => call("group:get", groupId),
snapshot: () => call("group:snapshot"),
},
social: {
snapshot: () => call("social:snapshot"),
+58
View File
@@ -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
View File
@@ -10,7 +10,7 @@ import type { FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
import type { Avatar } from "./types/avatar";
import type { RepoStats, StoredEntity } from "./types/repository";
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 { GallerySnapshot, Photo, ThumbCacheStats } from "./types/gallery";
import type { AppConfig } from "./types/appConfig";
@@ -51,6 +51,8 @@ export interface IpcRequests {
"group:byUser": (userId: string) => IpcResult<Group[]>;
"group:represented": (userId: string) => IpcResult<Group | null>;
"group:get": (groupId: string) => IpcResult<Group>;
"group:snapshot": () => IpcResult<GroupSnapshot>;
"social:snapshot": () => IpcResult<SocialSnapshot>;
@@ -119,6 +121,8 @@ export interface IpcEvents {
"social:upsert": UserProfile;
"world:seed": WorldSnapshot;
"world:upsert": World;
"group:seed": GroupSnapshot;
"group:upsert": Group;
"world:favoriteFolders": { userId: string; folders: FavoriteWorldFolder[]; done: boolean };
"game:changed": GameStatus;
"gallery:added": Photo;
+16
View File
@@ -1,5 +1,6 @@
export interface Group {
id: string;
detailed?: boolean;
name: string;
shortCode?: string;
description?: string;
@@ -9,4 +10,19 @@ export interface Group {
memberCount?: number;
privacy?: string;
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>;
}