diff --git a/desktop/src-tauri/src/commands/project_git_diff.rs b/desktop/src-tauri/src/commands/project_git_diff.rs index 0bd07cbffd..400b48eaa5 100644 --- a/desktop/src-tauri/src/commands/project_git_diff.rs +++ b/desktop/src-tauri/src/commands/project_git_diff.rs @@ -25,6 +25,7 @@ pub struct ProjectRepoDiffInfo { pub files: Vec, pub additions: usize, pub deletions: usize, + pub commit_body: Option, } fn clean_target_ref(value: Option) -> Option { @@ -340,7 +341,25 @@ fn diff_from_repo( repo_dir: &std::path::Path, auth: &GitAuthConfig, range: &str, + target_commit: Option<&str>, ) -> Result { + 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}"))? diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 311df0f97f..1fb3c35cc4 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -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"; diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 2d32e073da..c6f1bfa6c1 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -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"; diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index d988db9dd5..3e16cb332d 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -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. */ diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index 56dd018275..f2fb268167 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -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` diff --git a/desktop/src/features/messages/lib/timelineImagePreload.ts b/desktop/src/features/messages/lib/timelineImagePreload.ts index 77320e765a..41d5b57324 100644 --- a/desktop/src/features/messages/lib/timelineImagePreload.ts +++ b/desktop/src/features/messages/lib/timelineImagePreload.ts @@ -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 diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index a57bf93e40..86038347b7 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -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"; diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index b720e90265..b31dc3c1ff 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -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[], diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index 2140050db5..0655245866 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -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, diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index b6fa6a901f..2d0fb5fb45 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -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({ diff --git a/desktop/src/features/projects/projectPullRequests.d.mts b/desktop/src/features/projects/projectPullRequests.d.mts index f4d6e0a27d..af865d2433 100644 --- a/desktop/src/features/projects/projectPullRequests.d.mts +++ b/desktop/src/features/projects/projectPullRequests.d.mts @@ -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; diff --git a/desktop/src/features/projects/projectPullRequests.mjs b/desktop/src/features/projects/projectPullRequests.mjs index a5e9b927b7..3eebaa74f0 100644 --- a/desktop/src/features/projects/projectPullRequests.mjs +++ b/desktop/src/features/projects/projectPullRequests.mjs @@ -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, diff --git a/desktop/src/features/projects/projectPullRequests.test.mjs b/desktop/src/features/projects/projectPullRequests.test.mjs index b9f2cf98c7..9374604818 100644 --- a/desktop/src/features/projects/projectPullRequests.test.mjs +++ b/desktop/src/features/projects/projectPullRequests.test.mjs @@ -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, diff --git a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx index e20ebe4ecc..c6122dcfe4 100644 --- a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx @@ -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({ + {diff?.commitBody ? ( + + ) : null} {issue.content ? ( - + ) : null} @@ -238,11 +234,7 @@ function IssueDetail({ role={relativeTime(item.createdAt)} /> - + ))} diff --git a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx index e3170b3605..281aefe71b 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx @@ -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)} - ))} diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index bc07969d74..4c14309f24 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -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({
{pullRequest.content ? (
-
) : null} @@ -670,9 +669,11 @@ function PullRequestDetail({ ) : null}
{update.content ? ( -

- {update.content} -

+ ) : null} ))} @@ -829,10 +830,10 @@ function PullRequestDetail({ {activityContent ? ( - ) : null} {item.anchor ? ( diff --git a/desktop/src/features/projects/ui/ProjectRichContent.tsx b/desktop/src/features/projects/ui/ProjectRichContent.tsx new file mode 100644 index 0000000000..d6e61b4d56 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectRichContent.tsx @@ -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 ( + + ); +} diff --git a/desktop/src/shared/api/projectGit.ts b/desktop/src/shared/api/projectGit.ts index db5f31c458..7293db1938 100644 --- a/desktop/src/shared/api/projectGit.ts +++ b/desktop/src/shared/api/projectGit.ts @@ -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, diff --git a/desktop/src/shared/api/projectGitTypes.ts b/desktop/src/shared/api/projectGitTypes.ts index 44567e99e4..46854eb33e 100644 --- a/desktop/src/shared/api/projectGitTypes.ts +++ b/desktop/src/shared/api/projectGitTypes.ts @@ -43,6 +43,7 @@ export type ProjectRepoDiff = { files: ProjectRepoDiffFile[]; additions: number; deletions: number; + commitBody: string | null; }; export type ProjectLocalRepoSnapshot = { diff --git a/desktop/src/features/messages/lib/parseImeta.ts b/desktop/src/shared/ui/markdown/parseImeta.ts similarity index 77% rename from desktop/src/features/messages/lib/parseImeta.ts rename to desktop/src/shared/ui/markdown/parseImeta.ts index 72d28fccc2..9be060f7ae 100644 --- a/desktop/src/features/messages/lib/parseImeta.ts +++ b/desktop/src/shared/ui/markdown/parseImeta.ts @@ -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 { - const map = new Map(); +export function parseImetaTags( + tags: string[][], +): Map { + const map = new Map(); for (const tag of tags) { if (tag[0] !== "imeta") continue; - const entry: Partial = {}; + const entry: Partial = {}; 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 { break; } } - if (entry.url) map.set(entry.url, entry as ImetaEntry); + if (entry.url) map.set(entry.url, entry as ParsedImetaEntry); } return map; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4c085a0533..493908c872 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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", diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 52c886a0d1..32cd807552 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -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({