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

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,

View File

@@ -43,6 +43,7 @@ export type ProjectRepoDiff = {
files: ProjectRepoDiffFile[];
additions: number;
deletions: number;
commitBody: string | null;
};
export type ProjectLocalRepoSnapshot = {

View File

@@ -0,0 +1,63 @@
import type { ImetaEntry } from "./types";
export type ParsedImetaEntry = ImetaEntry & {
url: string;
m: string;
x: string;
size: number;
blurhash?: string;
alt?: string;
};
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<ParsedImetaEntry> = {};
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 ParsedImetaEntry);
}
return map;
}