mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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: "",
|
||||
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} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user