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
@@ -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. */
@@ -1,64 +0,0 @@
export type 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>();
for (const tag of tags) {
if (tag[0] !== "imeta") continue;
const entry: Partial<ImetaEntry> = {};
for (const part of tag.slice(1)) {
const spaceIdx = part.indexOf(" ");
if (spaceIdx === -1) continue;
const key = part.slice(0, spaceIdx);
const val = part.slice(spaceIdx + 1);
switch (key) {
case "url":
entry.url = val;
break;
case "m":
entry.m = val;
break;
case "x":
entry.x = val;
break;
case "size":
entry.size = parseInt(val, 10);
break;
case "dim":
entry.dim = val;
break;
case "blurhash":
entry.blurhash = val;
break;
case "alt":
entry.alt = val;
break;
case "thumb":
entry.thumb = val;
break;
case "duration":
entry.duration = parseFloat(val);
break;
case "image":
entry.image = val;
break;
case "filename":
entry.filename = val;
break;
}
}
if (entry.url) map.set(entry.url, entry as ImetaEntry);
}
return map;
}
@@ -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} />
);
}