mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
## Summary Project issues, pull requests, reviews, and commit details no longer flatten rich content into inert or plain text. They now share the message markdown and media pipeline, preserving NIP-92 `imeta` metadata so links, images, and videos render consistently. Commit bodies are fetched only when a single-commit detail view is opened, keeping list queries lightweight while exposing full context beside the diff. ### Related issue None found. ### Testing - Pre-push suite: `desktop-check`, `desktop-test`, `desktop-tauri-test`, `rust-tests`, and `mobile-test` - Project issue and pull request regression tests cover preserving attachment metadata on roots, updates, and comments - The project commit detail smoke scenario verifies linked text, images, and video in commit bodies Signed-off-by: Thomas Petersen <thomasp@squareup.com>
381 lines
11 KiB
TypeScript
381 lines
11 KiB
TypeScript
import { CircleCheck, CircleDot, CircleX, MessageSquare } from "lucide-react";
|
|
import * as React from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { ForumComposer } from "@/features/forum/ui/ForumComposer";
|
|
import {
|
|
type Project,
|
|
type ProjectIssue,
|
|
useCreateProjectIssueCommentMutation,
|
|
useProjectIssuesQuery,
|
|
} from "@/features/projects/hooks";
|
|
import {
|
|
resolveUserLabel,
|
|
type UserProfileLookup,
|
|
} from "@/features/profile/lib/identity";
|
|
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
|
|
import type { ChannelMember } from "@/shared/api/types";
|
|
import { normalizePubkey } from "@/shared/lib/pubkey";
|
|
import {
|
|
ProjectFeedRow,
|
|
ProjectFeedRowCluster,
|
|
ProjectFeedRowMonoCell,
|
|
} from "./ProjectFeedRow";
|
|
import { OverviewRailSection } from "./ProjectOverviewPanel";
|
|
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
|
|
import { ProjectRichContent } from "./ProjectRichContent";
|
|
|
|
export function issueStatusClassName(status: ProjectIssue["status"]) {
|
|
if (status === "Done") return "text-purple-400";
|
|
if (status === "Closed") return "text-destructive";
|
|
return "text-green-500";
|
|
}
|
|
|
|
function issueStatusVisual(status: ProjectIssue["status"]) {
|
|
if (status === "Done") {
|
|
return { className: "text-purple-400", icon: CircleCheck };
|
|
}
|
|
if (status === "Closed") {
|
|
return { className: "text-destructive", icon: CircleX };
|
|
}
|
|
return { className: "text-green-500", icon: CircleDot };
|
|
}
|
|
|
|
function issueMembers(
|
|
project: Project,
|
|
issue: ProjectIssue,
|
|
profiles?: UserProfileLookup,
|
|
): ChannelMember[] {
|
|
return [
|
|
...new Set([
|
|
project.owner,
|
|
issue.author,
|
|
...project.contributors,
|
|
...issue.recipients,
|
|
]),
|
|
].map((pubkey) => {
|
|
const profile = profiles?.[normalizePubkey(pubkey)];
|
|
return {
|
|
pubkey,
|
|
role: "member" as const,
|
|
isAgent: profile?.isAgent === true,
|
|
joinedAt: new Date(0).toISOString(),
|
|
displayName:
|
|
profile?.displayName?.trim() || profile?.nip05Handle?.trim() || null,
|
|
};
|
|
});
|
|
}
|
|
|
|
function AuthorIdentity({
|
|
profiles,
|
|
pubkey,
|
|
role,
|
|
}: {
|
|
profiles?: UserProfileLookup;
|
|
pubkey: string;
|
|
role?: React.ReactNode;
|
|
}) {
|
|
const profile = profiles?.[normalizePubkey(pubkey)];
|
|
return (
|
|
<ProfileIdentityButton
|
|
align="center"
|
|
avatarSize="xs"
|
|
avatarUrl={profile?.avatarUrl ?? null}
|
|
isAgent={profile?.isAgent === true}
|
|
label={resolveUserLabel({ profiles, pubkey })}
|
|
pubkey={pubkey}
|
|
role={role}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function IssueRow({
|
|
issue,
|
|
onOpen,
|
|
profiles,
|
|
}: {
|
|
issue: ProjectIssue;
|
|
onOpen: () => void;
|
|
profiles?: UserProfileLookup;
|
|
}) {
|
|
const authorProfile = profiles?.[normalizePubkey(issue.author)];
|
|
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
|
|
const status = issueStatusVisual(issue.status);
|
|
|
|
return (
|
|
<ProjectFeedRow
|
|
meta={
|
|
<>
|
|
<ProfileIdentityButton
|
|
avatarClassName="shrink-0"
|
|
avatarSize="xs"
|
|
avatarUrl={authorProfile?.avatarUrl ?? null}
|
|
isAgent={authorProfile?.isAgent === true}
|
|
label={authorLabel}
|
|
pubkey={issue.author}
|
|
showLabel={false}
|
|
/>
|
|
<span className="truncate text-foreground/80">
|
|
<span className="font-medium">{authorLabel}</span> created this
|
|
issue {relativeTime(issue.createdAt)}
|
|
</span>
|
|
<span>·</span>
|
|
<span>{issue.status}</span>
|
|
{issue.labels.map((label) => (
|
|
<span
|
|
className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs"
|
|
key={label}
|
|
>
|
|
{label}
|
|
</span>
|
|
))}
|
|
</>
|
|
}
|
|
onOpen={onOpen}
|
|
statusIcon={
|
|
<status.icon className={`h-3.5 w-3.5 shrink-0 ${status.className}`} />
|
|
}
|
|
testId="project-issue-row"
|
|
title={issue.title}
|
|
trailing={
|
|
<>
|
|
{issue.comments.length > 0 ? (
|
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
{issue.comments.length}
|
|
</span>
|
|
) : null}
|
|
<ProjectFeedRowCluster>
|
|
<ProjectFeedRowMonoCell
|
|
label={`#${issue.id.slice(0, 8)}`}
|
|
onClick={onOpen}
|
|
title="View issue"
|
|
/>
|
|
</ProjectFeedRowCluster>
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function IssueDetail({
|
|
issue,
|
|
profiles,
|
|
project,
|
|
}: {
|
|
issue: ProjectIssue;
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
}) {
|
|
const commentMutation = useCreateProjectIssueCommentMutation(project);
|
|
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
|
|
const members = React.useMemo(
|
|
() => issueMembers(project, issue, profiles),
|
|
[issue, profiles, project],
|
|
);
|
|
const handleCommentSubmit = React.useCallback(
|
|
async (
|
|
content: string,
|
|
mentionPubkeys: string[],
|
|
mediaTags?: string[][],
|
|
) => {
|
|
try {
|
|
await commentMutation.mutateAsync({
|
|
content,
|
|
issue,
|
|
mediaTags,
|
|
mentionPubkeys,
|
|
});
|
|
toast.success("Comment posted.");
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to post comment.",
|
|
);
|
|
throw error;
|
|
}
|
|
},
|
|
[commentMutation, issue],
|
|
);
|
|
|
|
return (
|
|
<div className="grid xl:grid-cols-[minmax(0,1fr)_18rem]">
|
|
<div className="min-w-0 divide-y divide-border/50">
|
|
<header className="space-y-3 p-4">
|
|
<div className="min-w-0">
|
|
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
<CircleDot className="h-3.5 w-3.5" />
|
|
Issue from {authorLabel}
|
|
</p>
|
|
<h3 className="mt-1 line-clamp-2 text-base font-semibold text-foreground">
|
|
{issue.title}{" "}
|
|
<span className="font-normal text-muted-foreground">
|
|
#{issue.id.slice(0, 8)}
|
|
</span>
|
|
</h3>
|
|
</div>
|
|
{issue.content ? (
|
|
<ProjectRichContent content={issue.content} tags={issue.tags} />
|
|
) : null}
|
|
</header>
|
|
|
|
<section className="space-y-3 p-4">
|
|
<h4 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
Add Your Comment
|
|
</h4>
|
|
{issue.comments.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{issue.comments.map((item) => (
|
|
<article key={item.id}>
|
|
<div className="mb-2">
|
|
<AuthorIdentity
|
|
profiles={profiles}
|
|
pubkey={item.author}
|
|
role={relativeTime(item.createdAt)}
|
|
/>
|
|
</div>
|
|
<ProjectRichContent content={item.content} tags={item.tags} />
|
|
</article>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No comments yet.</p>
|
|
)}
|
|
<ForumComposer
|
|
className="border border-border/60 bg-background/45"
|
|
disabled={commentMutation.isPending}
|
|
isSending={commentMutation.isPending}
|
|
members={members}
|
|
onSubmit={handleCommentSubmit}
|
|
placeholder="Add a comment…"
|
|
profiles={profiles}
|
|
/>
|
|
</section>
|
|
</div>
|
|
|
|
<IssueMetaRail issue={issue} profiles={profiles} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Right-hand meta column for the issue detail view: status, author, labels,
|
|
* and dates — keeps the conversation column focused. */
|
|
function IssueMetaRail({
|
|
issue,
|
|
profiles,
|
|
}: {
|
|
issue: ProjectIssue;
|
|
profiles?: UserProfileLookup;
|
|
}) {
|
|
const authorProfile = profiles?.[normalizePubkey(issue.author)];
|
|
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
|
|
const status = issueStatusVisual(issue.status);
|
|
|
|
return (
|
|
<aside className="space-y-6 border-t border-border/60 p-4 xl:border-l xl:border-t-0">
|
|
<OverviewRailSection title="Status">
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2.5 py-1 text-xs font-medium ${status.className}`}
|
|
>
|
|
<status.icon className="h-3.5 w-3.5" />
|
|
{issue.status}
|
|
</span>
|
|
</OverviewRailSection>
|
|
<OverviewRailSection title="Author">
|
|
<ProfileIdentityButton
|
|
align="center"
|
|
avatarSize="xs"
|
|
avatarUrl={authorProfile?.avatarUrl ?? null}
|
|
isAgent={authorProfile?.isAgent === true}
|
|
label={authorLabel}
|
|
pubkey={issue.author}
|
|
/>
|
|
</OverviewRailSection>
|
|
{issue.labels.length > 0 ? (
|
|
<OverviewRailSection title="Labels">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{issue.labels.map((label) => (
|
|
<span
|
|
className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs text-muted-foreground"
|
|
key={label}
|
|
>
|
|
{label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</OverviewRailSection>
|
|
) : null}
|
|
<OverviewRailSection title="Activity">
|
|
<dl className="space-y-1.5 text-xs text-muted-foreground">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<dt>Created</dt>
|
|
<dd className="font-medium text-foreground">
|
|
{relativeTime(issue.createdAt)}
|
|
</dd>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<dt>Updated</dt>
|
|
<dd className="font-medium text-foreground">
|
|
{relativeTime(issue.updatedAt)}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</OverviewRailSection>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export function ProjectIssuesPanel({
|
|
onSelectedIssueIdChange,
|
|
profiles,
|
|
project,
|
|
selectedIssueId,
|
|
}: {
|
|
onSelectedIssueIdChange: (id: string | null) => void;
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
selectedIssueId: string | null;
|
|
}) {
|
|
const issuesQuery = useProjectIssuesQuery(project);
|
|
const issues = issuesQuery.data ?? [];
|
|
const selectedIssue =
|
|
issues.find((issue) => issue.id === selectedIssueId) ?? null;
|
|
|
|
if (issuesQuery.isLoading) {
|
|
return <p className="p-4 text-sm text-muted-foreground">Loading issues…</p>;
|
|
}
|
|
|
|
if (issues.length === 0) {
|
|
return (
|
|
<p className="p-4 text-sm text-muted-foreground">
|
|
{issuesQuery.error
|
|
? "Could not load issues for this repository."
|
|
: "No issues yet."}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
if (selectedIssue) {
|
|
return (
|
|
<IssueDetail
|
|
issue={selectedIssue}
|
|
profiles={profiles}
|
|
project={project}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="divide-y divide-border/50">
|
|
{issues.map((issue) => (
|
|
<IssueRow
|
|
issue={issue}
|
|
key={issue.id}
|
|
onOpen={() => onSelectedIssueIdChange(issue.id)}
|
|
profiles={profiles}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|