Files
buzz/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx
thomaspblock afb272bb7b fix(desktop): render rich project work item content (#3100)
## 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>
2026-07-27 12:22:05 +02:00

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>
);
}