diff --git a/src/main/cache/policies.ts b/src/main/cache/policies.ts index 266c95c..6c40ca8 100644 --- a/src/main/cache/policies.ts +++ b/src/main/cache/policies.ts @@ -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; 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}`, }; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index df8d798..b73108f 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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()), diff --git a/src/main/store/groupStore.ts b/src/main/store/groupStore.ts new file mode 100644 index 0000000..9e16f52 --- /dev/null +++ b/src/main/store/groupStore.ts @@ -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(); + private byUser = new Map>(); + private representedByUser = new Map(); + 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(); diff --git a/src/main/store/repository/fieldPolicy.ts b/src/main/store/repository/fieldPolicy.ts index 6757757..4742201 100644 --- a/src/main/store/repository/fieldPolicy.ts +++ b/src/main/store/repository/fieldPolicy.ts @@ -82,3 +82,11 @@ export const avatarFieldPolicy = table( + { + memberCount: "stat", + onlineMemberCount: "live", + }, + { identity: 30 * DAY, stat: 6 * HOUR, live: 2 * MIN }, +); diff --git a/src/main/store/repository/manager.ts b/src/main/store/repository/manager.ts index 860ad68..51a189f 100644 --- a/src/main/store/repository/manager.ts +++ b/src/main/store/repository/manager.ts @@ -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; users: Repository; avatars: Repository; + groups: Repository; } function dbDir(): string { @@ -53,6 +60,11 @@ class RepositoryManager { policy: avatarFieldPolicy, backend: new JsonlBackend(fileFor(accountId, "avatars")), }), + groups: new Repository({ + name: "groups", + policy: groupFieldPolicy, + backend: new JsonlBackend(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; } diff --git a/src/main/store/social.ts b/src/main/store/social.ts index 672c40c..7618162 100644 --- a/src/main/store/social.ts +++ b/src/main/store/social.ts @@ -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 { 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); + }); } diff --git a/src/main/vrchat/groupService.ts b/src/main/vrchat/groupService.ts index 5b3d3c9..1a40181 100644 --- a/src/main/vrchat/groupService.ts +++ b/src/main/vrchat/groupService.ts @@ -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 { - 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 { + 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 { - 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 { + 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 { + 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; } diff --git a/src/main/vrchat/mappers.ts b/src/main/vrchat/mappers.ts index 3e97003..5220e72 100644 --- a/src/main/vrchat/mappers.ts +++ b/src/main/vrchat/mappers.ts @@ -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; diff --git a/src/renderer/src/components/AppShell.tsx b/src/renderer/src/components/AppShell.tsx index 27848ec..af2ef6f 100644 --- a/src/renderer/src/components/AppShell.tsx +++ b/src/renderer/src/components/AppShell.tsx @@ -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() {
{nav.current.kind === "world" ? ( + ) : nav.current.kind === "group" ? ( + ) : nav.current.kind === "account" ? ( ) : nav.current.kind === "settings" ? ( diff --git a/src/renderer/src/features/debug/DebugPanel.tsx b/src/renderer/src/features/debug/DebugPanel.tsx index 9668e33..15b881f 100644 --- a/src/renderer/src/features/debug/DebugPanel.tsx +++ b/src/renderer/src/features/debug/DebugPanel.tsx @@ -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() { setTab("worlds")}> Worlds + setTab("groups")}> + Groups + setTab("ws")}> WebSocket @@ -83,6 +95,8 @@ export function DebugPanel() { ) : tab === "worlds" ? ( + ) : tab === "groups" ? ( + ) : tab === "ws" ? ( ) : ( diff --git a/src/renderer/src/features/debug/tabs/GroupsTab.tsx b/src/renderer/src/features/debug/tabs/GroupsTab.tsx new file mode 100644 index 0000000..5b51678 --- /dev/null +++ b/src/renderer/src/features/debug/tabs/GroupsTab.tsx @@ -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 ( + +
+ +
+
+ {shown.length === 0 ? ( + + {groups.length === 0 + ? "No groups yet — open a profile to seed it." + : "No groups match."} + + ) : ( + shown.map((g) => ) + )} +
+
+ ); +} + +function GroupStoreRow({ group }: { group: Group }) { + const [open, setOpen] = useState(false); + const [copied, copy] = useCopied(); + return ( +
+ + {open ? ( +
+
+ +
+              {JSON.stringify(group, null, 2)}
+            
+
+
+ ) : null} +
+ ); +} diff --git a/src/renderer/src/features/group/GroupView.tsx b/src/renderer/src/features/group/GroupView.tsx new file mode 100644 index 0000000..18f387c --- /dev/null +++ b/src/renderer/src/features/group/GroupView.tsx @@ -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 {load.message}; + } + if (!cached?.detailed) return ; + return ; +} + +function GroupCard({ group }: { group: Group }) { + const { openUser } = useNav(); + const authorTags = (group.tags ?? []).filter((t) => t.startsWith("group_tag_")); + + return ( +
+
+ +
+ +
+

+ {group.name} + {group.isVerified ? : null} +

+ {group.ownerId ? ( +

+ owned by{" "} + +

+ ) : null} +
+ {group.shortCode ? {group.shortCode} : null} + {group.privacy && group.privacy !== "default" ? ( + {group.privacy} + ) : null} +
+
+
+ +
+
+ } + label="Members" + value={typeof group.memberCount === "number" ? compactNumber(group.memberCount) : "—"} + /> + } + label="Online" + value={ + typeof group.onlineMemberCount === "number" + ? compactNumber(group.onlineMemberCount) + : "—" + } + live={(group.onlineMemberCount ?? 0) > 0} + /> + } + label="Languages" + value={group.languages?.length ? group.languages.join(", ").toUpperCase() : "—"} + /> +
+ + {group.description ? ( +
+

+ {group.description} +

+
+ ) : null} + + {group.rules ? ( +
+

+ {group.rules} +

+
+ ) : null} + +
+
+
+ {group.joinState ? : null} + {group.createdAt ? ( + + ) : null} + +
+
+ + {authorTags.length ? ( +
+
+ {authorTags.map((t) => ( + {prettyTag(t, "group_tag_")} + ))} +
+
+ ) : null} +
+
+
+ ); +} + +function GroupSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+
+ ); +} diff --git a/src/renderer/src/features/navigation/NavContext.tsx b/src/renderer/src/features/navigation/NavContext.tsx index c71ac44..3bfda85 100644 --- a/src/renderer/src/features/navigation/NavContext.tsx +++ b/src/renderer/src/features/navigation/NavContext.tsx @@ -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; } diff --git a/src/renderer/src/features/profile/GroupsSection.tsx b/src/renderer/src/features/profile/GroupsSection.tsx index c15ea05..e8e9b53 100644 --- a/src/renderer/src/features/profile/GroupsSection.tsx +++ b/src/renderer/src/features/profile/GroupsSection.tsx @@ -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 ( -
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 }) {
-
+ ); } function GroupRow({ group }: { group: Group }) { + const { openGroup } = useNav(); return ( -
+
+ ); } diff --git a/src/renderer/src/features/profile/useUserGroups.ts b/src/renderer/src/features/profile/useUserGroups.ts index bb50574..80d922c 100644 --- a/src/renderer/src/features/profile/useUserGroups.ts +++ b/src/renderer/src/features/profile/useUserGroups.ts @@ -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 }; } diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index 1d197df..4106f97 100644 --- a/src/renderer/src/lib/api.ts +++ b/src/renderer/src/lib/api.ts @@ -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"), diff --git a/src/renderer/src/store/groups.ts b/src/renderer/src/store/groups.ts new file mode 100644 index 0000000..bfd4ffc --- /dev/null +++ b/src/renderer/src/store/groups.ts @@ -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; + byUser: Record; + representedByUser: Record; + seed: (s: GroupSnapshot) => void; + upsert: (g: Group) => void; +} + +export const useGroups = create((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(); +const failed = new Set(); + +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; + }); diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 9bf4dad..e5feb8c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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:represented": (userId: string) => IpcResult; + "group:get": (groupId: string) => IpcResult; + "group:snapshot": () => IpcResult; "social:snapshot": () => IpcResult; @@ -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; diff --git a/src/shared/types/group.ts b/src/shared/types/group.ts index c467214..6965634 100644 --- a/src/shared/types/group.ts +++ b/src/shared/types/group.ts @@ -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; + representedByUser: Record; }