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>
This commit is contained in:
thomaspblock
2026-07-27 12:22:05 +02:00
committed by GitHub
parent 070fb6a161
commit afb272bb7b
23 changed files with 200 additions and 43 deletions
@@ -25,6 +25,7 @@ pub struct ProjectRepoDiffInfo {
pub files: Vec<ProjectRepoDiffFileInfo>,
pub additions: usize,
pub deletions: usize,
pub commit_body: Option<String>,
}
fn clean_target_ref(value: Option<String>) -> Option<String> {
@@ -340,7 +341,25 @@ fn diff_from_repo(
repo_dir: &std::path::Path,
auth: &GitAuthConfig,
range: &str,
target_commit: Option<&str>,
) -> Result<ProjectRepoDiffInfo, String> {
let commit_body = target_commit
.map(|commit| {
run_git(
&[
"show",
"--no-patch",
"--format=%b",
"--end-of-options",
commit,
],
Some(repo_dir),
auth,
)
.map(|body| body.trim_end().to_string())
})
.transpose()?
.filter(|body| !body.is_empty());
let numstat = run_git(&["diff", "--numstat", range], Some(repo_dir), auth)?;
let files = parse_numstat(&numstat)
.into_iter()
@@ -375,6 +394,7 @@ fn diff_from_repo(
Ok(ProjectRepoDiffInfo {
additions: files.iter().map(|file| file.additions).sum(),
deletions: files.iter().map(|file| file.deletions).sum(),
commit_body,
files,
})
}
@@ -430,7 +450,12 @@ pub async fn get_project_repo_diff(
diff_base_ref(&repo_dir, &auth, base_branch.as_deref()),
),
};
diff_from_repo(&repo_dir, &auth, &range)
let commit_body_ref = if target_ref.is_none() && base_branch.is_none() {
target_commit.as_deref()
} else {
None
};
diff_from_repo(&repo_dir, &auth, &range, commit_body_ref)
})
.await
.map_err(|error| format!("repo diff task failed: {error}"))?
@@ -468,7 +493,12 @@ pub async fn get_project_local_repo_diff(
base_commit.as_deref(),
target_commit.as_deref(),
);
diff_from_repo(&repo_dir, &auth, &range).map(Some)
let commit_body_ref = if base_commit.is_none() && base_branch.is_none() {
target_commit.as_deref()
} else {
None
};
diff_from_repo(&repo_dir, &auth, &range, commit_body_ref).map(Some)
})
.await
.map_err(|error| format!("local repo diff task failed: {error}"))?
@@ -9,9 +9,9 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { ForumPost } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
import { formatRelativeTime } from "../lib/time";
import { DeleteActionMenu } from "./DeleteActionMenu";
@@ -11,9 +11,9 @@ import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
import { Markdown } from "@/shared/ui/markdown";
import { Skeleton } from "@/shared/ui/skeleton";
@@ -26,7 +26,7 @@
*/
import type { BlobDescriptor } from "@/shared/api/tauri";
import { parseImetaTags } from "./parseImeta";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
export type ImetaMedia = BlobDescriptor & {
/** Composer-only label used for attachment links; not emitted in imeta. */
@@ -3,7 +3,7 @@ import type * as React from "react";
import { dimensionsFromDim } from "@/shared/ui/markdown/utils";
import type { TimelineItem } from "./timelineItems";
import type { TimelineMessage } from "../types";
import { parseImetaTags } from "./parseImeta";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
/**
* Estimate a timeline row's rendered height so its `content-visibility`
@@ -1,6 +1,6 @@
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
import type { TimelineMessage } from "../types";
import { parseImetaTags } from "./parseImeta";
/**
* Return non-message-media image URLs worth warming before a virtualized row
@@ -32,7 +32,7 @@ import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage";
import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedBy";
@@ -11,6 +11,7 @@ export type ProjectIssueStatus =
export type ProjectIssueComment = {
id: string;
content: string;
tags: string[][];
author: string;
createdAt: number;
};
@@ -19,6 +20,7 @@ export type ProjectIssue = {
id: string;
title: string;
content: string;
tags: string[][];
author: string;
createdAt: number;
repoAddress: string | null;
@@ -41,6 +43,7 @@ export const PROJECT_ISSUE_STATUS: {
export function getTag(event: RelayEvent, name: string): string | undefined;
export function getAllTags(event: RelayEvent, name: string): string[];
export function getImetaTags(event: RelayEvent): string[][];
export function eventToProjectIssue(
issue: RelayEvent,
statusEvents?: RelayEvent[],
@@ -22,6 +22,10 @@ export function getAllTags(event, name) {
.map((tag) => tag[1]);
}
export function getImetaTags(event) {
return event.tags.filter((tag) => tag[0] === "imeta");
}
function repoOwnerFromAddress(repoAddress) {
const owner = (repoAddress ?? "").split(":")[1] ?? "";
return /^[a-fA-F0-9]{64}$/.test(owner) ? owner.toLowerCase() : null;
@@ -80,6 +84,7 @@ function commentsForIssue(issueId, commentEvents) {
.map((event) => ({
id: event.id,
content: event.content,
tags: getImetaTags(event),
author: event.pubkey,
createdAt: event.created_at,
}));
@@ -101,6 +106,7 @@ export function eventToProjectIssue(
id: issue.id,
title,
content: issue.content,
tags: getImetaTags(issue),
author: issue.pubkey,
createdAt: issue.created_at,
repoAddress: getTag(issue, "a") ?? null,
@@ -99,6 +99,32 @@ test("tag helpers drop malformed value-less tags", () => {
assert.equal(issue.title, "Something is broken");
});
test("preserves root and comment tags for rich content rendering", () => {
const root = issueEvent({
tags: [
["a", REPO_ADDRESS],
["subject", "Something is broken"],
["imeta", "url https://relay.example/media/root.png", "m image/png"],
],
});
const comment = {
id: "comment-rich-content",
kind: 1,
pubkey: ATTACKER,
created_at: 200,
content: "![Screenshot](https://relay.example/media/comment.png)",
tags: [
["e", root.id, "", "root"],
["imeta", "url https://relay.example/media/comment.png", "m image/png"],
],
};
const issue = eventToProjectIssue(root, [], [comment]);
assert.deepEqual(issue.tags, [root.tags[2]]);
assert.deepEqual(issue.comments[0].tags, [comment.tags[1]]);
});
test("builds repository-scoped issue creation tags", () => {
assert.deepEqual(
buildGitIssueTags({
@@ -3,6 +3,7 @@ import type { RelayEvent } from "@/shared/api/types";
export type ProjectPullRequestUpdate = {
id: string;
content: string;
tags: string[][];
author: string;
createdAt: number;
commit: string | null;
@@ -12,6 +13,7 @@ export type ProjectPullRequestUpdate = {
export type ProjectPullRequestComment = {
id: string;
content: string;
tags: string[][];
author: string;
createdAt: number;
commit: string | null;
@@ -70,6 +72,7 @@ export type ProjectPullRequest = {
id: string;
title: string;
content: string;
tags: string[][];
author: string;
createdAt: number;
repoAddress: string | null;
@@ -1,4 +1,9 @@
import { allowedActorsForRoot, getAllTags, getTag } from "./projectIssues.mjs";
import {
allowedActorsForRoot,
getAllTags,
getImetaTags,
getTag,
} from "./projectIssues.mjs";
// Updates and status changes rewrite the PR's tip commit, clone URLs, and
// lifecycle state, so they are only honored when signed by the PR author or
@@ -135,6 +140,7 @@ function eventToPullRequestUpdate(event) {
return {
id: event.id,
content: event.content,
tags: getImetaTags(event),
author: event.pubkey,
createdAt: event.created_at,
commit: getTag(event, "c") ?? null,
@@ -190,6 +196,7 @@ function eventToPullRequestComment(event) {
return {
id: event.id,
content: event.content,
tags: getImetaTags(event),
author: event.pubkey,
createdAt: event.created_at,
commit: getTag(event, "c") ?? null,
@@ -352,6 +359,7 @@ export function eventToProjectPullRequest(
id: pullRequest.id,
title,
content: pullRequest.content,
tags: getImetaTags(pullRequest),
author: pullRequest.pubkey,
createdAt: pullRequest.created_at,
repoAddress: getTag(pullRequest, "a") ?? null,
@@ -96,6 +96,44 @@ test("accepts updates signed by the PR author", () => {
assert.equal(pullRequest.updateCount, 1);
});
test("preserves root, update, and comment tags for rich content rendering", () => {
const root = pullRequestEvent({
tags: [
["a", REPO_ADDRESS],
["subject", "Add feature"],
["c", "1111111111111111111111111111111111111111"],
["imeta", "url https://relay.example/media/root.png", "m image/png"],
],
});
const update = updateEvent({
pubkey: AUTHOR,
createdAt: 200,
commit: "2222222222222222222222222222222222222222",
});
update.tags.push([
"imeta",
"url https://relay.example/media/update.mp4",
"m video/mp4",
]);
const comment = {
id: "comment-rich-content",
kind: 1,
pubkey: ATTACKER,
created_at: 250,
content: "[Demo](https://relay.example/media/comment.png)",
tags: [
["e", root.id, "", "root"],
["imeta", "url https://relay.example/media/comment.png", "m image/png"],
],
};
const pullRequest = eventToProjectPullRequest(root, [update], [comment]);
assert.deepEqual(pullRequest.tags, [root.tags[3]]);
assert.deepEqual(pullRequest.updates[0].tags, [update.tags[3]]);
assert.deepEqual(pullRequest.comments[0].tags, [comment.tags[1]]);
});
test("accepts updates signed by the repo owner", () => {
const update = updateEvent({
pubkey: OWNER,
@@ -12,6 +12,7 @@ import type { ProjectRepoCommit, ProjectRepoDiff } from "@/shared/api/types";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
import { ProjectDiffFilesPanel } from "./ProjectPullRequestFilesChangedPanel";
import { ProjectRichContent } from "./ProjectRichContent";
function commitDateLabel(timestamp: number) {
return new Date(timestamp * 1_000).toLocaleString(undefined, {
@@ -98,6 +99,9 @@ export function ProjectCommitDetailPanel({
</div>
</div>
</div>
{diff?.commitBody ? (
<ProjectRichContent content={diff.commitBody} />
) : null}
</header>
<ProjectDiffFilesPanel
@@ -16,7 +16,6 @@ import {
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import type { ChannelMember } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
import {
ProjectFeedRow,
ProjectFeedRowCluster,
@@ -24,6 +23,7 @@ import {
} 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";
@@ -214,11 +214,7 @@ function IssueDetail({
</h3>
</div>
{issue.content ? (
<Markdown
className="text-sm"
content={issue.content}
interactive={false}
/>
<ProjectRichContent content={issue.content} tags={issue.tags} />
) : null}
</header>
@@ -238,11 +234,7 @@ function IssueDetail({
role={relativeTime(item.createdAt)}
/>
</div>
<Markdown
className="text-sm"
content={item.content}
interactive={false}
/>
<ProjectRichContent content={item.content} tags={item.tags} />
</article>
))}
</div>
@@ -8,7 +8,7 @@ import type {
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
import { ProjectRichContent } from "./ProjectRichContent";
function commentAuthor(
pubkey: string,
@@ -68,10 +68,9 @@ export function ProjectPullRequestInlineCommentThread({
{relativeTime(comment.createdAt)}
</span>
</div>
<Markdown
className="text-sm"
<ProjectRichContent
content={comment.content}
interactive={false}
tags={comment.tags}
/>
</article>
))}
@@ -36,7 +36,6 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { ChannelMember } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
import {
ProjectFeedRow,
ProjectFeedRowCluster,
@@ -49,6 +48,7 @@ import {
ProfileAuthorName,
ProfileIdentityButton,
} from "./ProjectProfileIdentity";
import { ProjectRichContent } from "./ProjectRichContent";
import { PullRequestReviewersRow } from "./PullRequestReviewersRow";
import { PullRequestReviewCard } from "./PullRequestReviewCard";
@@ -639,10 +639,9 @@ function PullRequestDetail({
<div>
{pullRequest.content ? (
<header className="p-4">
<Markdown
className="text-sm"
<ProjectRichContent
content={pullRequest.content}
interactive={false}
tags={pullRequest.tags}
/>
</header>
) : null}
@@ -670,9 +669,11 @@ function PullRequestDetail({
) : null}
</div>
{update.content ? (
<p className="text-sm text-muted-foreground">
{update.content}
</p>
<ProjectRichContent
className="text-sm text-muted-foreground"
content={update.content}
tags={update.tags}
/>
) : null}
</article>
))}
@@ -829,10 +830,10 @@ function PullRequestDetail({
</span>
</div>
{activityContent ? (
<Markdown
<ProjectRichContent
className="mt-1 text-sm text-foreground/90"
content={activityContent}
interactive={false}
tags={item.tags}
/>
) : null}
{item.anchor ? (
@@ -0,0 +1,27 @@
import * as React from "react";
import { Markdown } from "@/shared/ui/markdown";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
/**
* Renders project event content with the same link and media support as
* messages while retaining NIP-92 attachment metadata from the source event.
*/
export function ProjectRichContent({
className = "text-sm",
content,
tags,
}: {
className?: string;
content: string;
tags?: string[][];
}) {
const imetaByUrl = React.useMemo(
() => (tags ? parseImetaTags(tags) : undefined),
[tags],
);
return (
<Markdown className={className} content={content} imetaByUrl={imetaByUrl} />
);
}
+3
View File
@@ -116,6 +116,7 @@ type RawProjectRepoDiff = {
files: RawProjectRepoDiffFile[];
additions: number;
deletions: number;
commit_body: string | null;
};
function fromRawProjectRepoSnapshot(
@@ -192,6 +193,7 @@ export async function getProjectRepoDiff(input: {
return {
additions: diff.additions,
deletions: diff.deletions,
commitBody: diff.commit_body,
files: diff.files.map((file) => ({
path: file.path,
additions: file.additions,
@@ -227,6 +229,7 @@ export async function getProjectLocalRepoDiff(input: {
return {
additions: diff.additions,
deletions: diff.deletions,
commitBody: diff.commit_body,
files: diff.files.map((file) => ({
path: file.path,
additions: file.additions,
@@ -43,6 +43,7 @@ export type ProjectRepoDiff = {
files: ProjectRepoDiffFile[];
additions: number;
deletions: number;
commitBody: string | null;
};
export type ProjectLocalRepoSnapshot = {
@@ -1,22 +1,21 @@
export type ImetaEntry = {
import type { ImetaEntry } from "./types";
export type ParsedImetaEntry = ImetaEntry & {
url: string;
m: string;
x: string;
size: number;
dim?: string;
blurhash?: string;
alt?: string;
thumb?: string;
duration?: number;
image?: string;
filename?: string;
};
export function parseImetaTags(tags: string[][]): Map<string, ImetaEntry> {
const map = new Map<string, ImetaEntry>();
export function parseImetaTags(
tags: string[][],
): Map<string, ParsedImetaEntry> {
const map = new Map<string, ParsedImetaEntry>();
for (const tag of tags) {
if (tag[0] !== "imeta") continue;
const entry: Partial<ImetaEntry> = {};
const entry: Partial<ParsedImetaEntry> = {};
for (const part of tag.slice(1)) {
const spaceIdx = part.indexOf(" ");
if (spaceIdx === -1) continue;
@@ -58,7 +57,7 @@ export function parseImetaTags(tags: string[][]): Map<string, ImetaEntry> {
break;
}
}
if (entry.url) map.set(entry.url, entry as ImetaEntry);
if (entry.url) map.set(entry.url, entry as ParsedImetaEntry);
}
return map;
}
+7
View File
@@ -9660,6 +9660,13 @@ export function maybeInstallE2eTauriMocks() {
return {
additions: 27,
deletions: 4,
commit_body: [
"See the [project guide](https://example.com/project-guide).",
"",
"![Architecture](/buzz.svg)",
"",
"![Demo](https://example.com/project-demo.mp4)",
].join("\n"),
files: [
{
path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx",
@@ -187,6 +187,16 @@ test("commit detail opens from the commits feed with a diff", async ({
await expect(
page.getByRole("button", { name: "Copy commit hash" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "project guide" }),
).toHaveAttribute("href", "https://example.com/project-guide");
await expect(
page.getByRole("button", { name: "Architecture" }),
).toBeVisible();
await expect(page.locator("video")).toHaveAttribute(
"src",
"https://example.com/project-demo.mp4",
);
// Diff from the mocked get_project_repo_diff renders changed files.
await expect(page.getByText("2 changed files")).toBeVisible({