Files
buzz/desktop/src/features/projects/ui/ProjectsIssuesList.tsx
thomaspblock fb73561e64 feat(desktop): Projects follow-ups — access restrictions, fast loading, activity feed polish (#5073)
## Summary

Follow-up batch on the Projects overview (continues merged #1677):

- **Repository access restrictions** — repositories the viewer can't
reach are surfaced with a reason instead of failing silently.
Channel-ACL denials (which arrive as the same 404 as a missing repo, for
anti-enumeration) are re-classified using the repository's channel
binding and the viewer's memberships (`useRepositoryAccess.ts`,
`projectRepoAvailability.ts`).
- **Projects loads in seconds instead of minutes** — enumeration no
longer crawls every kind:5 deletion event on the relay. It fetches
project/repo announcements first, then queries deletions scoped to those
coordinates via chunked `#a` filters (3 queries instead of hundreds on
staging).
- **Activity feed layout polish** — bare event-type glyph beside the
headline (no badge circle), timeline spine runs through the avatars
connecting consecutive cards, linkable actor/project names are bold in
theme foreground, rounded hover state, alignment fixes.
- **Create button pinned** — the "+" create menu is pinned to the pane's
top-right corner (equal 16px insets) and no longer scrolls away with the
page header.
- **List controls as a table header** — the scope selector (left) and
sort + layout toggle (right) render as the first row of the list
container on the Projects/Repositories/PRs/Issues tabs; in card view the
identical bar stands alone with the cards below
(`ProjectsListHeaderBar.tsx`).
- **Repository rows show the git location** — subtitle is
`github.com/org/repo` for external repos or `owner/repo` (resolved
profile name) for Buzz-hosted ones, instead of repeating the project
name (`repositoryDisplayPath`).
- **Uniform work-item row heights** — issue rows previously ran the
author chip in inline flow, letting the 20px avatar grow the line box
~3px taller than PR rows; both lists now share the same flex subtitle.

📸 Screenshots: [feed layout / pinned
button](https://github.com/block/buzz/pull/5073#issuecomment-5214114069)
· [list header / repo subtitles / row
heights](https://github.com/block/buzz/pull/5073#issuecomment-5217480712).

Note: two empty `chore: retrigger CI` commits exist on the branch from
working around the Aug 6 GitHub Actions incident; happy to drop them
with a signoff rebase before undrafting if preferred. Latest `main` is
merged in (`a0cc35220`).

## Test plan

- [x] Desktop unit tests (4,493 pass after merging main), Biome, tsc
- [x] New unit tests for scoped deletion enumeration and repo
availability re-classification
- [x] New unit tests for `repositoryDisplayPath` (external, Buzz-hosted,
unresolvable)
- [x] Screenshot verification of feed layout, connector spine, and
pinned button (top + scrolled states) — posted to the PR
- [x] Screenshot verification of the list header row (list + card), repo
subtitles, and matching PR/issue row heights — posted to the PR
- [ ] Manual pass against staging (projects list load time,
restricted-repo states)

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
2026-08-07 19:23:35 +02:00

343 lines
9.6 KiB
TypeScript

import { Eye, MessageSquare } from "lucide-react";
import type {
Project,
ProjectIssue,
ProjectIssueListItem,
Repository,
} from "@/features/projects/hooks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { ProjectAuthorIdentity } from "./ProjectAuthorIdentity";
import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
import {
PROJECT_LIST_CONTAINER_CLASS,
PROJECT_LIST_ROW_CLASS,
PROJECT_LIST_ROW_CONTENT_CLASS,
PROJECT_LIST_ROW_DATE_CLASS,
PROJECT_LIST_ROW_STATUS_CLASS,
PROJECT_LIST_ROW_SUBTEXT_CLASS,
PROJECT_LIST_ROW_TITLE_CLASS,
PROJECT_LIST_ROW_TRAILING_CLASS,
} from "./projectListRowStyles";
type ProjectsIssuesListProps = {
/** Render without container chrome — a parent table container provides border and rounding. */
embedded?: boolean;
error: unknown;
failedSections: ProjectWorkItemSection[];
isLoading: boolean;
isRetrying: boolean;
onOpen: (
project: Project,
repository: Repository,
issue: ProjectIssue,
) => void;
onRetry: () => void;
profiles?: UserProfileLookup;
issues: ProjectIssueListItem[];
viewMode: "grid" | "list";
};
function nextStepLabel(status: ProjectIssue["status"]) {
if (status === "Done" || status === "Closed") return "View issue";
if (status === "In Review") return "Review issue";
if (status === "Triage") return "Triage issue";
return "Open issue";
}
function IssueHeader({
authorTestId,
includeDate = true,
issue,
profiles,
project,
}: {
authorTestId?: string;
includeDate?: boolean;
issue: ProjectIssue;
profiles?: UserProfileLookup;
project: Project;
}) {
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
return (
<div className="-mt-0.5 min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{issue.title}</p>
</div>
{/* Flex (not inline flow) so the 20px author avatar cannot grow the
line box — keeps row heights identical to the PR list. */}
<div
className={`flex min-w-0 items-center gap-x-1.5 overflow-hidden whitespace-nowrap ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}
>
<span>{project.name}</span>
{includeDate ? (
<>
<span>·</span>
<span>created {relativeTime(issue.createdAt)}</span>
</>
) : null}
<span>·</span>
<span className="inline-flex items-center gap-1">
<span>by</span>
<ProjectAuthorIdentity
label={authorLabel}
profiles={profiles}
pubkey={issue.author}
testId={authorTestId}
/>
</span>
{includeDate ? (
<>
<span>·</span>
<span>{issue.status}</span>
</>
) : (
<>
<span className="md:hidden">·</span>
<span className="md:hidden">{issue.status}</span>
</>
)}
</div>
</div>
);
}
function IssueGridCard({
issue,
onOpen,
profiles,
project,
}: {
issue: ProjectIssue;
onOpen: (project: Project, issue: ProjectIssue) => void;
profiles?: UserProfileLookup;
project: Project;
}) {
return (
<Card
className="group relative flex min-h-40 flex-col overflow-hidden border-border/60 bg-transparent p-4 shadow-none transition-colors duration-150 hover:bg-muted/20"
data-projects-grid-card
>
<button
className="absolute inset-0"
onClick={() => onOpen(project, issue)}
type="button"
>
<span className="sr-only">View {issue.title}</span>
</button>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex min-w-0 items-start gap-3">
<ProjectEventTypeIcon className="h-5 w-5" kind="issue" />
<IssueHeader issue={issue} profiles={profiles} project={project} />
<Button
className="relative z-10 h-7 shrink-0 px-2.5"
onClick={(event) => {
event.stopPropagation();
onOpen(project, issue);
}}
size="xs"
type="button"
variant="outline"
>
{nextStepLabel(issue.status)}
</Button>
</div>
{issue.content ? (
<p className="line-clamp-2 text-sm text-foreground/90">
{issue.content}
</p>
) : null}
<div className="mt-auto border border-border/60 bg-muted/30 px-2.5 py-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-foreground/80">
<span className="font-mono text-foreground">
#{issue.id.slice(0, 8)}
</span>
{issue.comments.length > 0 ? (
<span className="flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{issue.comments.length}
</span>
) : null}
</div>
</div>
</div>
</Card>
);
}
function IssueListRow({
issue,
onOpen,
profiles,
project,
}: {
issue: ProjectIssue;
onOpen: (project: Project, issue: ProjectIssue) => void;
profiles?: UserProfileLookup;
project: Project;
}) {
return (
<div
className={PROJECT_LIST_ROW_CLASS}
data-testid={`projects-issue-row-${issue.id}`}
>
<button
className="absolute inset-0"
onClick={() => onOpen(project, issue)}
type="button"
>
<span className="sr-only">View {issue.title}</span>
</button>
<div className={PROJECT_LIST_ROW_CONTENT_CLASS}>
<ProjectEventTypeIcon className="h-5 w-5" kind="issue" />
<IssueHeader
authorTestId="projects-issue-author"
includeDate={false}
issue={issue}
profiles={profiles}
project={project}
/>
<div className={PROJECT_LIST_ROW_TRAILING_CLASS}>
<span className={PROJECT_LIST_ROW_STATUS_CLASS}>{issue.status}</span>
<div className="hidden w-14 shrink-0 justify-end md:flex">
{issue.comments.length > 0 ? (
<span className="flex items-center gap-1 text-2xs leading-3 text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{issue.comments.length}
</span>
) : null}
</div>
<span
className={PROJECT_LIST_ROW_DATE_CLASS}
data-testid="projects-row-date"
title={new Date(issue.createdAt * 1_000).toLocaleString()}
>
{relativeTime(issue.createdAt)}
</span>
<ProjectListRowMenu label={`More options for ${issue.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, issue)}>
<Eye className="h-4 w-4" />
{nextStepLabel(issue.status)}
</DropdownMenuItem>
</ProjectListRowMenu>
</div>
</div>
</div>
);
}
export function ProjectsIssuesList({
embedded,
error,
failedSections,
isLoading,
isRetrying,
issues,
onOpen,
onRetry,
profiles,
viewMode,
}: ProjectsIssuesListProps) {
if (isLoading) {
return (
<div
className={cn(
"px-4 py-12 text-center text-sm text-muted-foreground",
!embedded && "border border-border/60",
)}
>
Loading issues...
</div>
);
}
const loadNotice = (
<ProjectsWorkItemsLoadNotice
error={error}
failedSections={failedSections}
isRetrying={isRetrying}
onRetry={onRetry}
subject="issues"
/>
);
if (error && issues.length === 0) {
return loadNotice;
}
if (issues.length === 0) {
return (
<div className="space-y-3">
{loadNotice}
<div
className={cn(
"px-4 py-12 text-center text-sm text-muted-foreground",
!embedded && "border border-dashed border-border/60",
)}
>
No issues yet.
</div>
</div>
);
}
if (viewMode === "grid") {
return (
<div className="space-y-3">
{loadNotice}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{issues.map(({ project, issue, repository }) => (
<IssueGridCard
issue={issue}
key={`${repository.id}:${issue.id}`}
onOpen={(selectedProject, selectedIssue) =>
onOpen(selectedProject, repository, selectedIssue)
}
profiles={profiles}
project={project}
/>
))}
</div>
</div>
);
}
return (
<div className="space-y-3">
{loadNotice}
<div
className={
embedded ? "divide-y divide-border/60" : PROJECT_LIST_CONTAINER_CLASS
}
data-testid="projects-list-container"
>
{issues.map(({ project, issue, repository }) => (
<IssueListRow
issue={issue}
key={`${repository.id}:${issue.id}`}
onOpen={(selectedProject, selectedIssue) =>
onOpen(selectedProject, repository, selectedIssue)
}
profiles={profiles}
project={project}
/>
))}
</div>
</div>
);
}