mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
♻️ refactor: dedupe utils, lift CardGrid/IconLabel primitives
This commit is contained in:
Vendored
+3
-5
@@ -1,6 +1,7 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { writeFileAtomic, writeFileAtomicSync } from "../lib/atomicFile";
|
import { writeFileAtomic, writeFileAtomicSync } from "../lib/atomicFile";
|
||||||
import type { CacheEntryInfo, CacheStats } from "../../shared/types/debug";
|
import type { CacheEntryInfo, CacheStats } from "../../shared/types/debug";
|
||||||
|
import { httpStatusOf } from "../vrchat/errors";
|
||||||
|
|
||||||
interface Entry<T> {
|
interface Entry<T> {
|
||||||
value: T;
|
value: T;
|
||||||
@@ -31,14 +32,11 @@ function byteSize(value: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function retryAfterMs(err: unknown): number | null {
|
function retryAfterMs(err: unknown): number | null {
|
||||||
|
if (httpStatusOf(err) !== 429) return null;
|
||||||
const e = (err ?? {}) as {
|
const e = (err ?? {}) as {
|
||||||
status?: number;
|
response?: { headers?: Record<string, string> };
|
||||||
statusCode?: number;
|
|
||||||
response?: { status?: number; headers?: Record<string, string> };
|
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
};
|
};
|
||||||
const status = e.status ?? e.statusCode ?? e.response?.status;
|
|
||||||
if (status !== 429) return null;
|
|
||||||
const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"];
|
const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"];
|
||||||
const secs = raw != null ? Number(raw) : NaN;
|
const secs = raw != null ? Number(raw) : NaN;
|
||||||
return Number.isFinite(secs) ? secs * 1000 : 8000;
|
return Number.isFinite(secs) ? secs * 1000 : 8000;
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ function normalizeStatus(status?: string): UserStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toIso(v?: string | Date | null): string | undefined {
|
export function toIso(v?: string | Date | null): string | undefined {
|
||||||
if (!v) return undefined;
|
if (!v) return undefined;
|
||||||
const s = v instanceof Date ? v.toISOString() : v;
|
const date = v instanceof Date ? v : new Date(v);
|
||||||
return s === "" ? undefined : s;
|
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toUserProfile(raw: RawUser, selfId: string): UserProfile {
|
export function toUserProfile(raw: RawUser, selfId: string): UserProfile {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
import type { TwoFactorMethod } from "../../shared/types/auth";
|
import type { TwoFactorMethod } from "../../shared/types/auth";
|
||||||
import type { UserStatus } from "../../shared/types/user";
|
import type { UserStatus } from "../../shared/types/user";
|
||||||
import { requireActiveClient } from "./client";
|
import { requireActiveClient } from "./client";
|
||||||
|
import { toIso } from "./mappers";
|
||||||
import { userCache } from "./userService";
|
import { userCache } from "./userService";
|
||||||
import { cacheKeys } from "../cache/policies";
|
import { cacheKeys } from "../cache/policies";
|
||||||
import { entityStore } from "../store/entityStore";
|
import { entityStore } from "../store/entityStore";
|
||||||
@@ -20,12 +21,6 @@ const CONTENT_FILTER_KEYS: ContentFilterKey[] = [
|
|||||||
"content_horror",
|
"content_horror",
|
||||||
];
|
];
|
||||||
|
|
||||||
function toIso(d?: Date | string | null): string | undefined {
|
|
||||||
if (!d) return undefined;
|
|
||||||
const date = typeof d === "string" ? new Date(d) : d;
|
|
||||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSettings(u: CurrentUser): AccountSettings {
|
function toSettings(u: CurrentUser): AccountSettings {
|
||||||
const filters = (u.contentFilters ?? []).filter((t): t is ContentFilterKey =>
|
const filters = (u.contentFilters ?? []).filter((t): t is ContentFilterKey =>
|
||||||
(CONTENT_FILTER_KEYS as string[]).includes(t),
|
(CONTENT_FILTER_KEYS as string[]).includes(t),
|
||||||
|
|||||||
@@ -49,10 +49,13 @@ export async function getUser(userId: string): Promise<UserProfile> {
|
|||||||
export async function getUserByName(username: string): Promise<UserProfile> {
|
export async function getUserByName(username: string): Promise<UserProfile> {
|
||||||
const vrc = requireActiveClient();
|
const vrc = requireActiveClient();
|
||||||
const self = await selfId();
|
const self = await selfId();
|
||||||
return userCache.get(cacheKeys.userByName(username), policies.user, async () => {
|
const key = cacheKeys.userByName(username);
|
||||||
|
const profile = await userCache.get(key, policies.user, async () => {
|
||||||
const { data } = await vrc.getUserByName({ path: { username }, throwOnError: true });
|
const { data } = await vrc.getUserByName({ path: { username }, throwOnError: true });
|
||||||
return toUserProfile(data, self);
|
return toUserProfile(data, self);
|
||||||
});
|
});
|
||||||
|
refreshStore(profile, key);
|
||||||
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchUsers(query: string): Promise<UserProfile[]> {
|
export async function searchUsers(query: string): Promise<UserProfile[]> {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function CardGrid({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return <div className={`grid grid-cols-2 gap-3 sm:grid-cols-3 ${className}`}>{children}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function IconLabel({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
gap = "gap-1",
|
||||||
|
title,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
gap?: string;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center ${gap} ${className}`} title={title} style={style}>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ export { Toggle } from "./Toggle";
|
|||||||
export { Section, Fact } from "./Section";
|
export { Section, Fact } from "./Section";
|
||||||
export { StatTile } from "./StatTile";
|
export { StatTile } from "./StatTile";
|
||||||
export { SkeletonGrid } from "./SkeletonGrid";
|
export { SkeletonGrid } from "./SkeletonGrid";
|
||||||
|
export { CardGrid } from "./CardGrid";
|
||||||
|
export { IconLabel } from "./IconLabel";
|
||||||
export { Skeleton } from "./Skeleton";
|
export { Skeleton } from "./Skeleton";
|
||||||
export { LinkPill } from "./LinkPill";
|
export { LinkPill } from "./LinkPill";
|
||||||
export { CodeBlock } from "./CodeBlock";
|
export { CodeBlock } from "./CodeBlock";
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import type { CacheEntryInfo, CacheStatus } from "../../../../shared/types/debug";
|
import type { CacheEntryInfo, CacheStatus } from "../../../../shared/types/debug";
|
||||||
|
|
||||||
|
export { formatBytes } from "../../lib/format";
|
||||||
|
|
||||||
export const statusTone = {
|
export const statusTone = {
|
||||||
fresh: "success",
|
fresh: "success",
|
||||||
stale: "warn",
|
stale: "warn",
|
||||||
@@ -44,12 +46,6 @@ export function cacheStatus(e: CacheEntryInfo, now: number): CacheStatus {
|
|||||||
return now < e.expiresAt ? "fresh" : now < e.hardExpiresAt ? "stale" : "expired";
|
return now < e.expiresAt ? "fresh" : now < e.hardExpiresAt ? "stale" : "expired";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatBytes(n: number): string {
|
|
||||||
if (n < 1024) return `${n} B`;
|
|
||||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDuration(s: number): string {
|
export function formatDuration(s: number): string {
|
||||||
if (s < 60) return `${s}s`;
|
if (s < 60) return `${s}s`;
|
||||||
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
export function formatBytes(bytes: number): string {
|
export { formatBytes } from "../../lib/format";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
const units = ["KB", "MB", "GB"];
|
|
||||||
let n = bytes / 1024;
|
|
||||||
let i = 0;
|
|
||||||
while (n >= 1024 && i < units.length - 1) {
|
|
||||||
n /= 1024;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatBucket(bucket: string, locale?: string): string {
|
export function formatBucket(bucket: string, locale?: string): string {
|
||||||
const m = /^(\d{4})-(\d{2})$/.exec(bucket);
|
const m = /^(\d{4})-(\d{2})$/.exec(bucket);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChevronRight, Globe, Hash, MapPin, Users } from "lucide-react";
|
import { ChevronRight, Globe, Hash, MapPin, Users } from "lucide-react";
|
||||||
import { parseLocation, type Location } from "../../../../shared/types/user";
|
import { parseLocation, type Location } from "../../../../shared/types/user";
|
||||||
import { HoverImage } from "../../components/ui";
|
import { HoverImage, IconLabel } from "../../components/ui";
|
||||||
import { useWorld } from "../../store/worlds";
|
import { useWorld } from "../../store/worlds";
|
||||||
import { useNav } from "../navigation/NavContext";
|
import { useNav } from "../navigation/NavContext";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
@@ -54,36 +54,34 @@ export function LocationSection({ location }: { location?: Location }) {
|
|||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{t("profile:location.by", { author: world.authorName })}
|
{t("profile:location.by", { author: world.authorName })}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<IconLabel icon={<Hash size={11} />} title={t("profile:location.instance")}>
|
||||||
className="inline-flex items-center gap-1"
|
|
||||||
title={t("profile:location.instance")}
|
|
||||||
>
|
|
||||||
<Hash size={11} />
|
|
||||||
{parsed.instanceId}
|
{parsed.instanceId}
|
||||||
</span>
|
</IconLabel>
|
||||||
{region ? (
|
{region ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<IconLabel
|
||||||
{flag ? (
|
icon={
|
||||||
<span className="text-[13px] leading-none">{flag}</span>
|
flag ? (
|
||||||
) : (
|
<span className="text-[13px] leading-none">{flag}</span>
|
||||||
<Globe size={11} />
|
) : (
|
||||||
)}{" "}
|
<Globe size={11} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
{region}
|
{region}
|
||||||
</span>
|
</IconLabel>
|
||||||
) : null}
|
) : null}
|
||||||
{inInstance ? (
|
{inInstance ? (
|
||||||
<span
|
<IconLabel icon={<Users size={11} />} style={{ color: "var(--status-active)" }}>
|
||||||
className="inline-flex items-center gap-1"
|
{" "}
|
||||||
style={{ color: "var(--status-active)" }}
|
|
||||||
>
|
|
||||||
<Users size={11} />{" "}
|
|
||||||
{t("profile:location.inInstance", { n: inInstance.userCount })}
|
{t("profile:location.inInstance", { n: inInstance.userCount })}
|
||||||
</span>
|
</IconLabel>
|
||||||
) : null}
|
) : null}
|
||||||
{world.occupants > 0 ? (
|
{world.occupants > 0 ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<IconLabel icon={<Users size={11} />}>
|
||||||
<Users size={11} /> {t("profile:location.inWorld", { n: world.occupants })}
|
{" "}
|
||||||
</span>
|
{t("profile:location.inWorld", { n: world.occupants })}
|
||||||
|
</IconLabel>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { World } from "../../../../shared/types/world";
|
import type { World } from "../../../../shared/types/world";
|
||||||
import { CollapsibleCard, Field, SkeletonGrid } from "../../components/ui";
|
import { CardGrid, CollapsibleCard, Field, SkeletonGrid } from "../../components/ui";
|
||||||
import { useT } from "../../lib/i18n";
|
import { useT } from "../../lib/i18n";
|
||||||
import { WorldCard } from "../world/WorldCard";
|
import { WorldCard } from "../world/WorldCard";
|
||||||
import { useUserWorlds } from "./useUserWorlds";
|
import { useUserWorlds } from "./useUserWorlds";
|
||||||
@@ -73,11 +73,11 @@ export function FavoriteWorldsSection({
|
|||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{shown.map((folder) => (
|
{shown.map((folder) => (
|
||||||
<CollapsibleCard key={folder.name} title={folder.displayName} count={folder.worlds.length}>
|
<CollapsibleCard key={folder.name} title={folder.displayName} count={folder.worlds.length}>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<CardGrid>
|
||||||
{folder.worlds.map((w) => (
|
{folder.worlds.map((w) => (
|
||||||
<WorldCard key={w.id} world={w} />
|
<WorldCard key={w.id} world={w} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</CardGrid>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
))}
|
))}
|
||||||
{loading ? <SkeletonGrid count={3} /> : null}
|
{loading ? <SkeletonGrid count={3} /> : null}
|
||||||
@@ -99,11 +99,11 @@ function WorldGrid({
|
|||||||
if (worlds.length) {
|
if (worlds.length) {
|
||||||
return (
|
return (
|
||||||
<CollapsibleCard title={title} count={worlds.length}>
|
<CollapsibleCard title={title} count={worlds.length}>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<CardGrid>
|
||||||
{worlds.map((w) => (
|
{worlds.map((w) => (
|
||||||
<WorldCard key={w.id} world={w} />
|
<WorldCard key={w.id} world={w} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</CardGrid>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Search as SearchIcon } from "lucide-react";
|
|||||||
import type { UserProfile } from "../../../../shared/types/user";
|
import type { UserProfile } from "../../../../shared/types/user";
|
||||||
import type { World } from "../../../../shared/types/world";
|
import type { World } from "../../../../shared/types/world";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { Avatar, Button, ContextMenu, Field, Tabs, Tag } from "../../components/ui";
|
import { Avatar, Button, CardGrid, ContextMenu, Field, Tabs, Tag } from "../../components/ui";
|
||||||
import { useT } from "../../lib/i18n";
|
import { useT } from "../../lib/i18n";
|
||||||
import { useNav } from "../navigation/NavContext";
|
import { useNav } from "../navigation/NavContext";
|
||||||
import { useUserMenu } from "../friends/useUserMenu";
|
import { useUserMenu } from "../friends/useUserMenu";
|
||||||
@@ -106,11 +106,11 @@ export function SearchView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<CardGrid>
|
||||||
{(results as World[]).map((w) => (
|
{(results as World[]).map((w) => (
|
||||||
<WorldCard key={w.id} world={w} showAuthor onOpen={() => openWorld(w.id)} />
|
<WorldCard key={w.id} world={w} showAuthor onOpen={() => openWorld(w.id)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</CardGrid>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Circle, Star, Users } from "lucide-react";
|
import { Circle, Star, Users } from "lucide-react";
|
||||||
import type { World } from "../../../../shared/types/world";
|
import type { World } from "../../../../shared/types/world";
|
||||||
import { Card, HoverImage, Tag } from "../../components/ui";
|
import { Card, HoverImage, IconLabel, Tag } from "../../components/ui";
|
||||||
import { compactNumber } from "../../lib/format";
|
import { compactNumber } from "../../lib/format";
|
||||||
import { useT } from "../../lib/i18n";
|
import { useT } from "../../lib/i18n";
|
||||||
import { useNav } from "../navigation/NavContext";
|
import { useNav } from "../navigation/NavContext";
|
||||||
@@ -37,26 +37,29 @@ export function WorldCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] tabular-nums text-faint">
|
<div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] tabular-nums text-faint">
|
||||||
<span className="inline-flex items-center gap-1" title={t("profile:worlds.tip.favorites")}>
|
<IconLabel icon={<Star size={12} />} title={t("profile:worlds.tip.favorites")}>
|
||||||
<Star size={12} /> {compactNumber(world.favorites)}
|
{" "}
|
||||||
</span>
|
{compactNumber(world.favorites)}
|
||||||
|
</IconLabel>
|
||||||
{world.occupants > 0 ? (
|
{world.occupants > 0 ? (
|
||||||
<span
|
<IconLabel
|
||||||
className="inline-flex items-center gap-1"
|
icon={<Circle size={9} fill="currentColor" />}
|
||||||
title={t("profile:worlds.tip.online")}
|
title={t("profile:worlds.tip.online")}
|
||||||
style={{ color: "var(--status-active)" }}
|
style={{ color: "var(--status-active)" }}
|
||||||
>
|
>
|
||||||
<Circle size={9} fill="currentColor" /> {compactNumber(world.occupants)}
|
{" "}
|
||||||
</span>
|
{compactNumber(world.occupants)}
|
||||||
|
</IconLabel>
|
||||||
) : null}
|
) : null}
|
||||||
{world.visits > 0 ? (
|
{world.visits > 0 ? (
|
||||||
<span title={t("profile:worlds.tip.visits")}>
|
<span title={t("profile:worlds.tip.visits")}>
|
||||||
{t("profile:worlds.visitsCount", { formattedCount: compactNumber(world.visits) })}
|
{t("profile:worlds.visitsCount", { formattedCount: compactNumber(world.visits) })}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="inline-flex items-center gap-1" title={t("profile:worlds.tip.capacity")}>
|
<IconLabel icon={<Users size={12} />} title={t("profile:worlds.tip.capacity")}>
|
||||||
<Users size={12} /> {world.capacity}
|
{" "}
|
||||||
</span>
|
{world.capacity}
|
||||||
|
</IconLabel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["KB", "MB", "GB"];
|
||||||
|
let n = bytes / 1024;
|
||||||
|
let i = 0;
|
||||||
|
while (n >= 1024 && i < units.length - 1) {
|
||||||
|
n /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function compactNumber(n: number): string {
|
export function compactNumber(n: number): string {
|
||||||
if (n < 1000) return `${n}`;
|
if (n < 1000) return `${n}`;
|
||||||
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
||||||
|
|||||||
@@ -5,19 +5,16 @@ export type AuthStatus =
|
|||||||
| { state: "awaiting2fa"; methods: TwoFactorMethod[] }
|
| { state: "awaiting2fa"; methods: TwoFactorMethod[] }
|
||||||
| { state: "authenticated"; user: CurrentUserSummary };
|
| { state: "authenticated"; user: CurrentUserSummary };
|
||||||
|
|
||||||
export interface CurrentUserSummary {
|
|
||||||
id: string;
|
|
||||||
displayName: string;
|
|
||||||
userIcon: string;
|
|
||||||
currentAvatarThumbnailImageUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
userIcon: string;
|
userIcon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CurrentUserSummary extends Account {
|
||||||
|
currentAvatarThumbnailImageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccountsState {
|
export interface AccountsState {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user