mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
fix(desktop): polish Projects navigation and detail presentation
Make sidebar and repository navigation behave predictably while clarifying review metadata, activity hierarchy, and file-detail presentation. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -273,7 +273,7 @@ const NAME_LIST_MAX = 3;
|
||||
function DiscussionMessagePreview({ content }: { content: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
className="inbox-preview-markdown mt-0.5 text-inherit leading-4"
|
||||
className="inbox-preview-markdown mt-0.5 text-inherit leading-6"
|
||||
content={discussionSnippet(content)}
|
||||
interactive={false}
|
||||
/>
|
||||
|
||||
@@ -15,12 +15,16 @@ export function CopyTextButton({
|
||||
text: string;
|
||||
}) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const handleCopy = React.useCallback(() => {
|
||||
void writeTextToClipboard(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2_000);
|
||||
});
|
||||
}, [text]);
|
||||
const handleCopy = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
void writeTextToClipboard(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2_000);
|
||||
});
|
||||
},
|
||||
[text],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -30,6 +34,7 @@ export function CopyTextButton({
|
||||
className,
|
||||
)}
|
||||
onClick={handleCopy}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
{copied ? (
|
||||
|
||||
@@ -311,13 +311,13 @@ export function ProjectEntityListRow({
|
||||
</span>
|
||||
{count != null ? (
|
||||
<span
|
||||
className="grid w-12 shrink-0 grid-cols-[0.875rem_1fr] items-center gap-1 text-xs text-muted-foreground/65"
|
||||
className="flex w-12 shrink-0 items-center gap-1 text-xs text-muted-foreground/65"
|
||||
data-projects-text-priority="secondary"
|
||||
data-testid={countTestId}
|
||||
title={countTitle}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
<span className="text-right tabular-nums">
|
||||
<span className="tabular-nums">
|
||||
{count}
|
||||
{countSuffix}
|
||||
</span>
|
||||
|
||||
@@ -531,6 +531,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
<ProjectWorkItemContextDetails
|
||||
issue={selectedIssue}
|
||||
pullRequest={selectedPullRequest}
|
||||
repository={repository}
|
||||
/>
|
||||
{!selectedIssue &&
|
||||
!selectedPullRequest &&
|
||||
|
||||
@@ -617,6 +617,7 @@ export function RepositoryFilesPanel({
|
||||
profiles,
|
||||
fallbackAuthorPubkey,
|
||||
onContextChange,
|
||||
onOpenCommit,
|
||||
sourceControls,
|
||||
unavailableMessage,
|
||||
}: {
|
||||
@@ -631,6 +632,7 @@ export function RepositoryFilesPanel({
|
||||
kind: "file" | "folder";
|
||||
path: string;
|
||||
}) => void;
|
||||
onOpenCommit?: (commitHash: string) => void;
|
||||
/** Branch picker + remote/local toggle rendered in the panel header. */
|
||||
sourceControls?: RepoSourceHeaderControls;
|
||||
unavailableMessage?: string;
|
||||
@@ -850,9 +852,36 @@ export function RepositoryFilesPanel({
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto px-2 pb-2">
|
||||
<table className="w-full border-separate border-spacing-y-0.5 caption-bottom text-sm">
|
||||
<table className="w-full border-collapse caption-bottom text-sm">
|
||||
<thead>
|
||||
<tr className="border-border/50 border-b bg-muted/20">
|
||||
<tr
|
||||
aria-label={
|
||||
latestCommit
|
||||
? `Open commit ${latestCommit.shortHash}`
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"bg-muted/20",
|
||||
latestCommit &&
|
||||
onOpenCommit &&
|
||||
"cursor-pointer transition-colors hover:bg-muted/35 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
)}
|
||||
data-testid="project-repository-latest-commit"
|
||||
onClick={
|
||||
latestCommit && onOpenCommit
|
||||
? () => onOpenCommit(latestCommit.hash)
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
latestCommit && onOpenCommit
|
||||
? (event) =>
|
||||
handleRepositoryEntryKeyDown(event, () =>
|
||||
onOpenCommit(latestCommit.hash),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
tabIndex={latestCommit && onOpenCommit ? 0 : undefined}
|
||||
>
|
||||
<th className="px-4 py-3 text-left font-normal" colSpan={3}>
|
||||
{latestCommit ? (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 text-sm">
|
||||
@@ -904,9 +933,8 @@ export function RepositoryFilesPanel({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleEntries.map((entry, index) => {
|
||||
{visibleEntries.map((entry) => {
|
||||
const latestCommit = entry.latestCommit;
|
||||
const rowIsLast = index === visibleEntries.length - 1;
|
||||
const openEntry = () =>
|
||||
openRepositoryEntry(entry, openPath, setSelectedFile);
|
||||
|
||||
@@ -914,6 +942,7 @@ export function RepositoryFilesPanel({
|
||||
<tr
|
||||
aria-label={`Open ${entry.type} ${entry.name}`}
|
||||
className="group/repository-entry cursor-pointer text-xs focus-visible:outline-hidden"
|
||||
data-testid="project-repository-entry-row"
|
||||
key={`${entry.type}:${entry.path}`}
|
||||
onClick={openEntry}
|
||||
onKeyDown={(event) =>
|
||||
@@ -921,12 +950,7 @@ export function RepositoryFilesPanel({
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td
|
||||
className={cn(
|
||||
"min-w-52 rounded-l-md px-3 py-2 align-middle transition-colors group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:bg-muted/35",
|
||||
!rowIsLast && "border-border/50 border-b",
|
||||
)}
|
||||
>
|
||||
<td className="min-w-52 px-3 py-2 align-middle transition-colors group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:bg-muted/35">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<RepositoryEntryIcon entry={entry} />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
@@ -934,23 +958,13 @@ export function RepositoryFilesPanel({
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"max-w-96 p-2 align-middle transition-colors group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:bg-muted/35",
|
||||
!rowIsLast && "border-border/50 border-b",
|
||||
)}
|
||||
>
|
||||
<td className="max-w-96 p-2 align-middle transition-colors group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:bg-muted/35">
|
||||
<RepositoryCommitCell
|
||||
commit={latestCommit}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"w-36 whitespace-nowrap rounded-r-md p-2 text-right align-middle text-muted-foreground transition-colors group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:bg-muted/35",
|
||||
!rowIsLast && "border-border/50 border-b",
|
||||
)}
|
||||
>
|
||||
<td className="w-36 whitespace-nowrap p-2 text-right align-middle text-muted-foreground transition-colors group-hover/repository-entry:bg-muted/35 group-focus-visible/repository-entry:bg-muted/35">
|
||||
{latestCommit ? (
|
||||
<time
|
||||
dateTime={new Date(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
type LucideIcon,
|
||||
MessageCircle,
|
||||
@@ -11,6 +12,7 @@ import type * as React from "react";
|
||||
import type {
|
||||
ProjectIssue,
|
||||
ProjectPullRequest,
|
||||
Repository,
|
||||
} from "@/features/projects/hooks";
|
||||
|
||||
function ContextDetailRow({
|
||||
@@ -36,9 +38,11 @@ function ContextDetailRow({
|
||||
export function ProjectWorkItemContextDetails({
|
||||
issue,
|
||||
pullRequest,
|
||||
repository,
|
||||
}: {
|
||||
issue?: ProjectIssue | null;
|
||||
pullRequest?: ProjectPullRequest | null;
|
||||
repository: Repository;
|
||||
}) {
|
||||
if (issue) {
|
||||
return (
|
||||
@@ -63,8 +67,25 @@ export function ProjectWorkItemContextDetails({
|
||||
}
|
||||
|
||||
if (pullRequest) {
|
||||
const sourceBranch = pullRequest.branchName || "Unknown branch";
|
||||
const targetBranch =
|
||||
pullRequest.targetBranch || repository.defaultBranch || "Default branch";
|
||||
const branchLabel = `${sourceBranch} → ${targetBranch}`;
|
||||
return (
|
||||
<>
|
||||
<ContextDetailRow
|
||||
icon={GitBranch}
|
||||
label="Branch"
|
||||
value={
|
||||
<span
|
||||
className="block max-w-40 truncate"
|
||||
data-testid="project-context-branch"
|
||||
title={branchLabel}
|
||||
>
|
||||
{branchLabel}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<ContextDetailRow
|
||||
icon={GitPullRequest}
|
||||
label="Status"
|
||||
|
||||
@@ -566,6 +566,7 @@ export function WorkspaceTabs({
|
||||
files={files}
|
||||
isLoading={displayedSnapshotLoading}
|
||||
onContextChange={onFilesContextChange}
|
||||
onOpenCommit={onSelectedCommitHashChange}
|
||||
profiles={profiles}
|
||||
snapshot={displayedSnapshot}
|
||||
unavailableMessage={
|
||||
|
||||
@@ -26,6 +26,8 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ProjectsCreateMenu } from "./ProjectsCreateMenu";
|
||||
import { ProjectsSelectionCountMenu } from "./ProjectsSelectionCountMenu";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons";
|
||||
import {
|
||||
type OverviewContextStatIcon,
|
||||
type ProjectsOverviewSection,
|
||||
@@ -130,11 +132,34 @@ export function ProjectsOverviewPanel({
|
||||
}
|
||||
|
||||
export function ProjectsActivityIntro() {
|
||||
const { activeCommunity } = useCommunities();
|
||||
const communityIconQuery = useActiveCommunityIcon(activeCommunity?.relayUrl);
|
||||
const communityIcon = communityIconQuery.data ?? null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="pb-8 pt-5 text-center"
|
||||
className="pb-8 pt-16 text-center"
|
||||
data-testid="projects-activity-intro"
|
||||
>
|
||||
<div
|
||||
aria-label={`${activeCommunity?.name ?? "Current"} relay`}
|
||||
className="mx-auto mb-3 flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl text-4xl"
|
||||
data-testid="projects-activity-relay-icon"
|
||||
role="img"
|
||||
>
|
||||
{communityIcon ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
src={communityIcon}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true" className="-translate-y-px leading-none">
|
||||
🐝
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2
|
||||
className="text-xl font-semibold tracking-tight text-foreground"
|
||||
data-testid="projects-page-header"
|
||||
|
||||
@@ -291,7 +291,7 @@ export function PullRequestReviewersRow({
|
||||
<ProjectDetailMetaRow icon={Users} label="Reviewers">
|
||||
<div className="grid min-w-0 gap-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{showSummary ? (
|
||||
{showSummary && displayedDecisionActors.length === 0 ? (
|
||||
<span className="font-medium" data-testid={summaryTestId}>
|
||||
{reviewSummary}
|
||||
</span>
|
||||
@@ -317,32 +317,27 @@ export function PullRequestReviewersRow({
|
||||
? History
|
||||
: null;
|
||||
const decisionLabel = hasApproved
|
||||
? "Approved"
|
||||
? `Approved by ${label}`
|
||||
: hasRequestedChanges
|
||||
? "Changes requested"
|
||||
? `Changes requested by ${label}`
|
||||
: needsRereview
|
||||
? "Re-review needed"
|
||||
: "Pending";
|
||||
? `Re-review needed from ${label}`
|
||||
: `Awaiting review from ${label}`;
|
||||
return (
|
||||
<span
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1 text-sm",
|
||||
hasApproved && "text-green-600 dark:text-green-400",
|
||||
hasRequestedChanges && "text-amber-600 dark:text-amber-400",
|
||||
!hasApproved && !hasRequestedChanges && "text-muted-foreground",
|
||||
)}
|
||||
data-testid="project-reviewer-decision"
|
||||
key={pubkey}
|
||||
>
|
||||
<span className="truncate text-sm text-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-0.5 text-sm",
|
||||
hasApproved && "text-green-600 dark:text-green-400",
|
||||
hasRequestedChanges && "text-amber-600 dark:text-amber-400",
|
||||
!hasApproved &&
|
||||
!hasRequestedChanges &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DecisionIcon ? <DecisionIcon className="h-3.5 w-3.5" /> : null}
|
||||
{decisionLabel}
|
||||
</span>
|
||||
{DecisionIcon ? (
|
||||
<DecisionIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
) : null}
|
||||
<span className="truncate">{decisionLabel}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -585,6 +585,30 @@ test("multi-repository projects switch the active repository", async ({
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("latest files commit opens its detail without a divider", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enableProjectsFeature(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
await page.getByTestId("projects-section-projects").click();
|
||||
const projectEntry = page
|
||||
.locator(
|
||||
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
||||
)
|
||||
.first();
|
||||
await expect(projectEntry).toBeVisible({ timeout: 10_000 });
|
||||
await projectEntry.click();
|
||||
await page.getByRole("tab", { name: "Files" }).click();
|
||||
|
||||
const latestCommit = page.getByTestId("project-repository-latest-commit");
|
||||
await expect(latestCommit).toBeVisible();
|
||||
await expect(latestCommit).toHaveCSS("border-bottom-width", "0px");
|
||||
await latestCommit.click();
|
||||
await expect(page.getByTestId("project-commit-detail")).toBeVisible();
|
||||
});
|
||||
|
||||
test("commit detail opens from the commits feed with a diff", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -204,6 +204,7 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("project-context-chat-agent")).toBeVisible();
|
||||
await expect(page.getByTestId("project-context-discuss")).toBeVisible();
|
||||
await expect(page.getByTestId("project-context-branch")).toContainText("→");
|
||||
await expect(page.getByTestId("project-context-review-summary")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
@@ -264,6 +265,12 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
|
||||
await expect(page.getByText("Requested a review from bob")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByTestId("project-review-summary")).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("project-reviewer-decision")
|
||||
.filter({ hasText: "Awaiting review from bob" }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
@@ -287,9 +294,12 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
|
||||
.getByTestId("project-pull-request-timeline-row")
|
||||
.filter({ hasText: "requested changes" }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("project-review-summary")).toHaveText(
|
||||
"Changes requested",
|
||||
);
|
||||
await expect(page.getByTestId("project-review-summary")).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("project-reviewer-decision")
|
||||
.filter({ hasText: "Changes requested" }),
|
||||
).toHaveCount(1);
|
||||
const changeRequestEvent = await page.evaluate(() =>
|
||||
window.__BUZZ_E2E_SIGNED_EVENTS__
|
||||
?.filter(
|
||||
@@ -368,10 +378,12 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Approve", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("project-review-summary")).toHaveText(
|
||||
"Awaiting review",
|
||||
);
|
||||
await expect(page.getByText("Approved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("project-review-summary")).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("project-reviewer-decision")
|
||||
.filter({ hasText: "Approved by" }),
|
||||
).toHaveCount(1);
|
||||
const approvalEvent = await page.evaluate(() =>
|
||||
window.__BUZZ_E2E_SIGNED_EVENTS__
|
||||
?.filter(
|
||||
|
||||
@@ -40,7 +40,19 @@ test("projects activity overview screenshot", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
await expect(page.getByTestId("projects-page-tabs")).toBeVisible();
|
||||
await expect(page.getByTestId("projects-page-header")).toBeVisible();
|
||||
const activityHeader = page.getByTestId("projects-page-header");
|
||||
const relayIcon = page.getByTestId("projects-activity-relay-icon");
|
||||
await expect(activityHeader).toBeVisible();
|
||||
await expect(relayIcon).toBeVisible();
|
||||
const [activityHeaderBox, relayIconBox] = await Promise.all([
|
||||
activityHeader.boundingBox(),
|
||||
relayIcon.boundingBox(),
|
||||
]);
|
||||
expect(activityHeaderBox).not.toBeNull();
|
||||
expect(relayIconBox).not.toBeNull();
|
||||
expect((relayIconBox?.y ?? 0) + (relayIconBox?.height ?? 0)).toBeLessThan(
|
||||
activityHeaderBox?.y ?? 0,
|
||||
);
|
||||
await expect(page.getByTestId("projects-activity-search")).toBeVisible();
|
||||
await expect(page.getByTestId("projects-activity-intro")).toContainText(
|
||||
"Projects Activity",
|
||||
@@ -785,6 +797,13 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
await expect(
|
||||
workspacePanel.getByTestId("project-repository-entry-icon").first(),
|
||||
).toHaveCSS("border-radius", "8px");
|
||||
const repositoryEntryCell = workspacePanel
|
||||
.getByTestId("project-repository-entry-row")
|
||||
.first()
|
||||
.locator("td")
|
||||
.first();
|
||||
await expect(repositoryEntryCell).toHaveCSS("border-radius", "0px");
|
||||
await expect(repositoryEntryCell).toHaveCSS("border-bottom-width", "0px");
|
||||
await chatPanelTab.click();
|
||||
await expect(agentContext).toContainText("Files");
|
||||
await expect(
|
||||
|
||||
@@ -531,6 +531,47 @@ test("aligns the sidebar search with the channel title outside the Buzz theme",
|
||||
expect(Math.abs(searchCenter - channelTitleCenter)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("keeps only search pinned while primary navigation scrolls", async ({
|
||||
page,
|
||||
}) => {
|
||||
await loadTheme(page, "github-light");
|
||||
|
||||
const search = page.getByTestId("open-search");
|
||||
const primaryMenu = page.getByTestId("sidebar-primary-menu");
|
||||
const sidebarScroller = page.locator(".buzz-sidebar-scrollbar");
|
||||
const [initialSearchBox, initialMenuBox] = await Promise.all([
|
||||
search.boundingBox(),
|
||||
primaryMenu.boundingBox(),
|
||||
]);
|
||||
expect(initialSearchBox).not.toBeNull();
|
||||
expect(initialMenuBox).not.toBeNull();
|
||||
|
||||
const scrollTop = await sidebarScroller.evaluate((element) => {
|
||||
element.scrollTop = Math.min(
|
||||
120,
|
||||
Math.max(0, element.scrollHeight - element.clientHeight),
|
||||
);
|
||||
return element.scrollTop;
|
||||
});
|
||||
expect(scrollTop).toBeGreaterThan(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebarScroller.evaluate((element) => Math.round(element.scrollTop)),
|
||||
)
|
||||
.toBe(Math.round(scrollTop));
|
||||
|
||||
const [scrolledSearchBox, scrolledMenuBox] = await Promise.all([
|
||||
search.boundingBox(),
|
||||
primaryMenu.boundingBox(),
|
||||
]);
|
||||
expect(scrolledSearchBox).not.toBeNull();
|
||||
expect(scrolledMenuBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs((scrolledSearchBox?.y ?? 0) - (initialSearchBox?.y ?? 0)),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(scrolledMenuBox?.y ?? 0).toBeLessThan(initialMenuBox?.y ?? 0);
|
||||
});
|
||||
|
||||
test("scales the sidebar backward while its chrome closes", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user