diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 35fd60fa4..416c478bf 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -37,18 +37,18 @@ const overrides = new Map([ ["src/app/AppShell.tsx", 815], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], - ["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers - ["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + ["src/features/channels/ui/ChannelPane.tsx", 525], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget + ["src/features/channels/ui/ChannelScreen.tsx", 555], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget ["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state ["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState) ["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates - ["src/features/messages/ui/MessageComposer.tsx", 760], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + ["src/features/messages/ui/MessageComposer.tsx", 800], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts ["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests ["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload - ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + ["src-tauri/src/commands/messages.rs", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits) ["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests ["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) ["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 62bb01883..2bbd75319 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -389,16 +389,19 @@ pub async fn edit_message( channel_id: String, event_id: String, content: String, + media_tags: Vec>, state: State<'_, AppState>, ) -> Result<(), String> { let channel_uuid = uuid::Uuid::parse_str(&channel_id) .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?; let trimmed = content.trim(); - if trimmed.is_empty() { - return Err("edit content must not be empty".into()); + // Empty text is allowed when the edit still carries imeta attachments + // (a media-only edit). Reject only when both are empty. + if trimmed.is_empty() && media_tags.is_empty() { + return Err("edit must have content or attachments".into()); } - let builder = events::build_message_edit(channel_uuid, target_eid, trimmed)?; + let builder = events::build_message_edit(channel_uuid, target_eid, trimmed, &media_tags)?; submit_event(builder, &state).await?; Ok(()) } diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 843c7c4d5..49db9f682 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -281,17 +281,21 @@ pub fn build_forum_comment( Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } -/// Kind 40003 — edit a message. +/// Kind 40003 — edit a message. Carries the full new content AND a fresh +/// imeta tag set; the receiver overlays the imeta tags onto the original +/// event so the rendered message reflects exactly the edited state. pub fn build_message_edit( channel_id: Uuid, target_event_id: EventId, content: &str, + media_tags: &[Vec], ) -> Result { check_content(content)?; - let tags = vec![ + let mut tags = vec![ tag(vec!["h", &channel_id.to_string()])?, tag(vec!["e", &target_event_id.to_hex()])?, ]; + imeta_tags(media_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags)) } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index fd33ab152..7f672adb3 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -4,6 +4,7 @@ import { Hash, LogIn } from "lucide-react"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; @@ -67,6 +68,7 @@ type ChannelPaneProps = { author: string; body: string; id: string; + imetaMedia?: ImetaMedia[]; } | null; fetchOlder?: () => Promise; hasOlderMessages?: boolean; @@ -82,7 +84,7 @@ type ChannelPaneProps = { onCloseThread: () => void; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; - onEditSave?: (content: string) => Promise; + onEditSave?: (content: string, mediaTags?: string[][]) => Promise; onMarkUnread?: (message: TimelineMessage) => void; onExpandThreadReplies: (message: TimelineMessage) => void; onJoinChannel?: () => Promise; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index d13d39175..c0cf8733e 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -33,6 +33,7 @@ import { formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; import { buildThreadPanelData } from "@/features/messages/lib/threadPanel"; +import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import type { TimelineMessage } from "@/features/messages/types"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors"; @@ -483,6 +484,9 @@ export function ChannelScreen({ author: editTargetMessage.author, body: editTargetMessage.body, id: editTargetMessage.id, + imetaMedia: imetaMediaFromTags( + editTargetMessage.tags, + ), } : null } diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index f1adf9eef..253f70be0 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -108,13 +108,13 @@ export function useChannelPaneHandlers({ ); const handleEditSave = React.useCallback( - async (content: string) => { + async (content: string, mediaTags?: string[][]) => { const eventId = editTargetIdRef.current; if (!eventId) { return; } - await editMutateRef.current({ eventId, content }); + await editMutateRef.current({ eventId, content, mediaTags }); setEditTargetId(null); }, [setEditTargetId], diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 65ea1c1b1..788c2cf8d 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -21,6 +21,9 @@ import { sendChannelMessage, } from "@/shared/api/tauri"; import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; +// Same .mjs the renderer uses, so the cache-update projection can't drift +// from the on-render overlay. +import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; import { KIND_STREAM_MESSAGE, KIND_SYSTEM_MESSAGE, @@ -458,16 +461,17 @@ export function useEditMessageMutation(channel: Channel | null) { { eventId: string; content: string; + mediaTags?: string[][]; } >({ - mutationFn: async ({ eventId, content }) => { + mutationFn: async ({ eventId, content, mediaTags }) => { if (!channel) { throw new Error("No channel selected."); } - await editMessage(channel.id, eventId, content); + await editMessage(channel.id, eventId, content, mediaTags); }, - onSuccess: (_data, { eventId, content }) => { + onSuccess: (_data, { eventId, content, mediaTags }) => { if (!channel) { return; } @@ -475,9 +479,20 @@ export function useEditMessageMutation(channel: Channel | null) { queryClient.setQueryData( channelMessagesKey(channel.id), (current = []) => - current.map((message) => - message.id === eventId ? { ...message, content } : message, - ), + current.map((message) => { + if (message.id !== eventId) return message; + // Apply-on-success cache update: reflect the edit's new content + // and imeta tag set immediately, so the local cache matches + // what the receiver overlay (formatTimelineMessages) will + // produce when the edit event arrives back from the relay. + // (Not a true optimistic update — runs in onSuccess, not + // onMutate. Worth bearing the cost only because the edit event + // round-trip can lag perceptibly.) + const nextTags = mediaTags + ? applyEditTagOverlay(message.tags, mediaTags) + : message.tags; + return { ...message, content, tags: nextTags }; + }), ); }, }); diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts b/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts new file mode 100644 index 000000000..c0c463ce9 --- /dev/null +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts @@ -0,0 +1,17 @@ +/** + * Type declarations for the pure overlay helper in `applyEditTagOverlay.mjs`. + * Runtime lives in `.mjs` so the (TS-loader-less) `node:test` runner can + * import it directly; this file gives TypeScript callers a typed view. + */ + +export type Tag = string[]; + +/** + * Merge an event's tags with an edit's tags: imeta from the edit (full new + * attachment set), all other tag kinds from the original. Pass-through when + * `editTags` is `undefined`. + */ +export function applyEditTagOverlay( + originalTags: Tag[], + editTags: Tag[] | undefined, +): Tag[]; diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs new file mode 100644 index 000000000..7545f4d87 --- /dev/null +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs @@ -0,0 +1,26 @@ +/** + * Pure helper for applying an edit event's imeta tags onto an original + * message event. Used by both the renderer (formatTimelineMessages.ts) + * and the post-edit cache update (useEditMessageMutation in hooks.ts) so + * they stay in sync. + * + * Lives in `.mjs` (not `.ts`) so the test runner (`node --test`, no TS + * loader) can import the same source the production code uses. The + * TypeScript-facing callers get typed access via the sibling `.d.mts`. + */ + +/** + * Merge the original event's tags with an edit's tags so that: + * - `imeta` tags come exclusively from the edit (full new attachment set); + * - all other tag kinds (`h`, `e`, `p` mentions, etc.) come exclusively + * from the original — the edit can't rewrite channel membership, + * thread refs, or mention targets. + * + * When `editTags` is undefined, returns `originalTags` unchanged. + */ +export function applyEditTagOverlay(originalTags, editTags) { + if (!editTags) return originalTags; + const nonImetaOriginal = originalTags.filter((t) => t[0] !== "imeta"); + const imetaFromEdit = editTags.filter((t) => t[0] === "imeta"); + return [...nonImetaOriginal, ...imetaFromEdit]; +} diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs new file mode 100644 index 000000000..d0f857c9c --- /dev/null +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Imports the exact source the renderer (formatTimelineMessages.ts) and the +// post-edit cache-update (useEditMessageMutation) use. No inlined copy → no +// drift risk between test expectations and production behaviour. +import { applyEditTagOverlay } from "./applyEditTagOverlay.mjs"; + +const IMETA = (url) => ["imeta", `url ${url}`, "m image/png", "x x", "size 1"]; + +test("undefined editTags is a pass-through (returns original reference)", () => { + const tags = [["h", "uuid"], IMETA("https://b/a.png")]; + assert.equal(applyEditTagOverlay(tags, undefined), tags); +}); + +test("does not mutate the original tag array", () => { + const original = [["h", "uuid"], IMETA("https://b/a.png")]; + const snapshot = JSON.parse(JSON.stringify(original)); + const edit = [IMETA("https://b/c.png")]; + applyEditTagOverlay(original, edit); + assert.deepEqual(original, snapshot); +}); + +test("edit replaces imeta A,B with edit's A,C; non-imeta from original survive", () => { + const original = [ + ["h", "uuid"], + ["p", "mention1"], + IMETA("https://b/a.png"), + IMETA("https://b/b.png"), + ]; + const edit = [ + ["h", "uuid"], + ["e", "originalEventId"], + IMETA("https://b/a.png"), + IMETA("https://b/c.png"), + ]; + + const out = applyEditTagOverlay(original, edit); + + // Non-imeta tags from the original survived (h, p mention). + const nonImeta = out.filter((t) => t[0] !== "imeta"); + assert.deepEqual(nonImeta, [ + ["h", "uuid"], + ["p", "mention1"], + ]); + + // Imeta tags now match the edit's set (A,C — not B). + const imetaUrls = out.filter((t) => t[0] === "imeta").map((t) => t[1]); + assert.deepEqual(imetaUrls, ["url https://b/a.png", "url https://b/c.png"]); +}); + +test("edit with zero imeta tags strips all attachments; non-imeta original tags stay", () => { + const original = [["h", "uuid"], IMETA("https://b/a.png")]; + const edit = [ + ["h", "uuid"], + ["e", "x"], + ]; + + const out = applyEditTagOverlay(original, edit); + assert.equal(out.filter((t) => t[0] === "imeta").length, 0); + // h tag still present. + assert.ok(out.some((t) => t[0] === "h")); +}); + +test("edit adds imeta to a previously text-only message; original mentions preserved", () => { + const original = [ + ["h", "uuid"], + ["p", "mention"], + ]; + const edit = [["h", "uuid"], ["e", "x"], IMETA("https://b/a.png")]; + + const out = applyEditTagOverlay(original, edit); + const imeta = out.filter((t) => t[0] === "imeta"); + assert.equal(imeta.length, 1); + assert.equal(imeta[0][1], "url https://b/a.png"); + // p mention still preserved from original. + assert.ok( + out.some((t) => t[0] === "p" && t[1] === "mention"), + "non-imeta tags from original must be preserved", + ); +}); + +test("edit's non-imeta tags are dropped (only imeta wins)", () => { + // The edit event itself carries `h` and `e` tags — the overlay must not + // promote those into the merged set; only imeta tags from the edit win. + const original = [ + ["h", "uuid-original"], + ["p", "mention1"], + ]; + const edit = [ + ["h", "uuid-from-edit-must-be-ignored"], + ["e", "edit-target-event-id"], + IMETA("https://b/a.png"), + ]; + const out = applyEditTagOverlay(original, edit); + // The original h survives, the edit's h is ignored. + const hTags = out.filter((t) => t[0] === "h"); + assert.deepEqual(hTags, [["h", "uuid-original"]]); + // No `e` tag from the edit leaked through. + assert.equal(out.filter((t) => t[0] === "e").length, 0); + // Original p mention still there. + assert.ok(out.some((t) => t[0] === "p" && t[1] === "mention1")); + // Imeta from the edit is present. + assert.equal(out.filter((t) => t[0] === "imeta").length, 1); +}); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 2f8d180bb..be8b68d1c 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -31,6 +31,9 @@ import { } from "@/shared/constants/kinds"; import { resolveEventAuthorPubkey } from "@/shared/lib/authors"; import { formatTime } from "@/features/messages/lib/dateFormatters"; +// Pure overlay helper lives in a sibling .mjs so node:test (no TS loader) +// can exercise the exact same source the renderer uses. +import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; const HEX_RE = /^[0-9a-f]+$/i; @@ -156,11 +159,14 @@ export function formatTimelineMessages( } } - // Build a map of latest edit per original message: targetId → { content, createdAt }. + // Build a map of latest edit per original message: targetId → { content, tags, createdAt }. // When multiple edits exist for the same message, the most recent one wins. + // The edit's own tags are kept so the renderer can overlay imeta tags + // (attachments) from the edit onto the original event — non-imeta tags on + // the original (`h`, `p` mentions, etc.) stay untouched. const editsByTargetId = new Map< string, - { content: string; createdAt: number } + { content: string; tags: string[][]; createdAt: number } >(); for (const event of events) { if ( @@ -179,6 +185,7 @@ export function formatTimelineMessages( if (!existing || event.created_at > existing.createdAt) { editsByTargetId.set(targetId, { content: event.content, + tags: event.tags, createdAt: event.created_at, }); } @@ -348,7 +355,11 @@ export function formatTimelineMessages( pending: event.pending, edited: edit !== undefined, kind: event.kind, - tags: event.tags, + // When edited, swap the original event's imeta tags for the edit's + // imeta tags. All non-imeta tags on the original are preserved. + // Logic lives in `applyEditTagOverlay.mjs` so prod and tests share + // a single source. + tags: applyEditTagOverlay(event.tags, edit?.tags), reactions: (() => { const reactions = reactionsByEventId.get(event.id); return reactions ? [...reactions.values()] : undefined; diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs new file mode 100644 index 000000000..8daaa47e8 --- /dev/null +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -0,0 +1,451 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// ── Inlined pure functions from imetaMediaMarkdown.ts ───────────────── +// Inlined to avoid importing from .ts files (no TS loader in node:test). +// Same pattern as markdown.test.mjs / useMediaUpload.test.mjs. + +const MEDIA_LINE_RE = /^!\[(?:image|video)\]\(([^)\s]+)\)\s*$/; + +function stripImetaMediaLines(body, imetaMedia) { + if (imetaMedia.length === 0) return body; + const urls = new Set(imetaMedia.map((m) => m.url)); + const lines = body.split("\n"); + let end = lines.length; + while (end > 0) { + const line = lines[end - 1]; + if (line.trim() === "") { + end -= 1; + continue; + } + const match = line.match(MEDIA_LINE_RE); + if (match && urls.has(match[1])) { + end -= 1; + continue; + } + break; + } + return lines.slice(0, end).join("\n").replace(/\s+$/, ""); +} + +function formatImetaMediaLine({ url, type }) { + const isVideo = type.startsWith("video/"); + return isVideo ? `\n![video](${url})` : `\n![image](${url})`; +} + +function buildImetaTags(imetaMedia) { + return imetaMedia.map((d) => [ + "imeta", + `url ${d.url}`, + `m ${d.type}`, + ...(d.sha256 ? [`x ${d.sha256}`] : []), + ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), + ...(d.dim ? [`dim ${d.dim}`] : []), + ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), + ...(d.thumb ? [`thumb ${d.thumb}`] : []), + ...(d.duration != null ? [`duration ${d.duration}`] : []), + ...(d.image ? [`image ${d.image}`] : []), + ]); +} + +function buildOutgoingMessage(body, pendingImeta) { + let content = body; + for (const d of pendingImeta) content += formatImetaMediaLine(d); + const mediaTags = + pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined; + return { content, mediaTags }; +} + +// Mirror of `parseImetaTags` + `imetaMediaFromTags` so the projection's +// type/x/size/dim/blurhash/thumb/duration/image fields can be tested without +// a TS loader. +function parseImetaTagsInline(tags) { + const map = new Map(); + for (const tag of tags) { + if (tag[0] !== "imeta") continue; + const entry = {}; + for (const part of tag.slice(1)) { + const i = part.indexOf(" "); + if (i === -1) continue; + const key = part.slice(0, i); + const val = part.slice(i + 1); + if (key === "url") entry.url = val; + else if (key === "m") entry.m = val; + else if (key === "x") entry.x = val; + else if (key === "size") entry.size = parseInt(val, 10); + else if (key === "dim") entry.dim = val; + else if (key === "blurhash") entry.blurhash = val; + else if (key === "thumb") entry.thumb = val; + else if (key === "duration") entry.duration = parseFloat(val); + else if (key === "image") entry.image = val; + } + if (entry.url) map.set(entry.url, entry); + } + return map; +} + +function imetaMediaFromTags(tags) { + if (!tags || tags.length === 0) return []; + const entries = parseImetaTagsInline(tags); + const out = []; + for (const e of entries.values()) { + if (!e.url) continue; + out.push({ + url: e.url, + type: e.m ?? "image/jpeg", + sha256: e.x ?? "", + size: e.size ?? 0, + uploaded: 0, + ...(e.dim ? { dim: e.dim } : {}), + ...(e.blurhash ? { blurhash: e.blurhash } : {}), + ...(e.thumb ? { thumb: e.thumb } : {}), + ...(e.duration != null ? { duration: e.duration } : {}), + ...(e.image ? { image: e.image } : {}), + }); + } + return out; +} + +// ── stripImetaMediaLines ────────────────────────────────────────────── + +test("strip: removes trailing image line whose URL is in imetaMedia", () => { + const body = "Look at this\n![image](https://blossom/abc.png)"; + const stripped = stripImetaMediaLines(body, [ + { url: "https://blossom/abc.png", type: "image/png" }, + ]); + assert.equal(stripped, "Look at this"); +}); + +test("strip: removes trailing video line", () => { + const body = "Demo:\n![video](https://blossom/clip.mp4)"; + const stripped = stripImetaMediaLines(body, [ + { url: "https://blossom/clip.mp4", type: "video/mp4" }, + ]); + assert.equal(stripped, "Demo:"); +}); + +test("strip: removes multiple trailing media lines in order", () => { + const body = "two pics\n![image](https://b/a.png)\n![image](https://b/b.png)"; + const stripped = stripImetaMediaLines(body, [ + { url: "https://b/a.png", type: "image/png" }, + { url: "https://b/b.png", type: "image/png" }, + ]); + assert.equal(stripped, "two pics"); +}); + +test("strip: leaves body alone when no imeta entries", () => { + const body = "hello\n![image](https://b/a.png)"; + assert.equal(stripImetaMediaLines(body, []), body); +}); + +test("strip: leaves media line whose URL isn't in imetaMedia", () => { + const body = "hello\n![image](https://b/other.png)"; + const stripped = stripImetaMediaLines(body, [ + { url: "https://b/known.png", type: "image/png" }, + ]); + assert.equal(stripped, body); +}); + +test("strip: stops at first non-media line (interleaved text preserved)", () => { + const body = + "before\n![image](https://b/a.png)\nmiddle\n![image](https://b/b.png)"; + const stripped = stripImetaMediaLines(body, [ + { url: "https://b/a.png", type: "image/png" }, + { url: "https://b/b.png", type: "image/png" }, + ]); + assert.equal(stripped, "before\n![image](https://b/a.png)\nmiddle"); +}); + +test("strip: tolerates blank lines between text and trailing media", () => { + const body = "hi\n\n![image](https://b/a.png)"; + const stripped = stripImetaMediaLines(body, [ + { url: "https://b/a.png", type: "image/png" }, + ]); + assert.equal(stripped, "hi"); +}); + +// ── formatImetaMediaLine (send-path body markdown) ──────────────────── + +test("formatImetaMediaLine: image mime → ![image] line", () => { + assert.equal( + formatImetaMediaLine({ url: "https://b/a.png", type: "image/png" }), + "\n![image](https://b/a.png)", + ); +}); + +test("formatImetaMediaLine: video mime → ![video] line (regardless of URL suffix)", () => { + assert.equal( + formatImetaMediaLine({ url: "https://cdn/blob/xyz", type: "video/mp4" }), + "\n![video](https://cdn/blob/xyz)", + ); +}); + +// ── imetaMediaFromTags (full BlobDescriptor projection) ─────────────── + +test("imetaMediaFromTags: empty / undefined", () => { + assert.deepEqual(imetaMediaFromTags(undefined), []); + assert.deepEqual(imetaMediaFromTags([]), []); +}); + +test("imetaMediaFromTags: full descriptor round-trip with all fields", () => { + const tags = [ + [ + "imeta", + "url https://b/photo.png", + "m image/png", + "x deadbeef", + "size 12345", + "dim 1920x1080", + "blurhash LKO2:N%2Tw=^$f", + "thumb https://b/photo-thumb.png", + "image https://b/photo.png", + ], + ]; + const out = imetaMediaFromTags(tags); + assert.deepEqual(out, [ + { + url: "https://b/photo.png", + type: "image/png", + sha256: "deadbeef", + size: 12345, + uploaded: 0, + dim: "1920x1080", + blurhash: "LKO2:N%2Tw=^$f", + thumb: "https://b/photo-thumb.png", + image: "https://b/photo.png", + }, + ]); +}); + +test("imetaMediaFromTags: video preserves duration", () => { + const tags = [ + [ + "imeta", + "url https://b/clip.mp4", + "m video/mp4", + "x cafef00d", + "size 999000", + "duration 12.5", + ], + ]; + const out = imetaMediaFromTags(tags); + assert.equal(out.length, 1); + assert.equal(out[0].duration, 12.5); + assert.equal(out[0].type, "video/mp4"); +}); + +test("imetaMediaFromTags: legacy entry without `m` falls back to image/jpeg", () => { + const tags = [["imeta", "url https://b/legacy.jpg", "x abc", "size 100"]]; + const out = imetaMediaFromTags(tags); + assert.equal(out.length, 1); + assert.equal(out[0].type, "image/jpeg"); + assert.equal(out[0].sha256, "abc"); +}); + +test("imetaMediaFromTags: skips entries without a url", () => { + const tags = [["imeta", "m image/png", "x abc"]]; + assert.deepEqual(imetaMediaFromTags(tags), []); +}); + +test("imetaMediaFromTags: ignores non-imeta tags", () => { + const tags = [ + ["e", "abc"], + ["p", "def"], + ["h", "uuid"], + ]; + assert.deepEqual(imetaMediaFromTags(tags), []); +}); + +test("imetaMediaFromTags: preserves order across multiple entries", () => { + const tags = [ + ["imeta", "url https://b/a.png", "m image/png", "x 1", "size 10"], + ["imeta", "url https://b/b.png", "m image/png", "x 2", "size 20"], + ["imeta", "url https://b/c.mp4", "m video/mp4", "x 3", "size 30"], + ]; + const out = imetaMediaFromTags(tags); + assert.deepEqual( + out.map((d) => d.url), + ["https://b/a.png", "https://b/b.png", "https://b/c.mp4"], + ); +}); + +// ── buildImetaTags (send + edit symmetry) ───────────────────────────── + +test("buildImetaTags: round-trips through imetaMediaFromTags losslessly (full fields)", () => { + const original = [ + { + url: "https://b/photo.png", + type: "image/png", + sha256: "deadbeef", + size: 12345, + uploaded: 0, + dim: "1920x1080", + blurhash: "LKO2:N%2Tw=^$f", + thumb: "https://b/photo-thumb.png", + image: "https://b/photo.png", + }, + ]; + const tags = buildImetaTags(original); + const projected = imetaMediaFromTags(tags); + assert.deepEqual(projected, original); +}); + +test("buildImetaTags: omits absent optional fields", () => { + const tags = buildImetaTags([ + { + url: "https://b/a.png", + type: "image/png", + sha256: "x", + size: 1, + uploaded: 0, + }, + ]); + assert.deepEqual(tags, [ + ["imeta", "url https://b/a.png", "m image/png", "x x", "size 1"], + ]); +}); + +// ── Edit flow: open-edit → user modifies attachments → save ─────────── + +test("edit flow: imeta tags rebuilt from current pending after user removes one", () => { + // Original event has two attachments. + const originalTags = [ + ["imeta", "url https://b/a.png", "m image/png", "x 1", "size 10"], + ["imeta", "url https://b/b.png", "m image/png", "x 2", "size 20"], + ]; + + // Composer projects them into pendingImeta on edit-load. + const pending = imetaMediaFromTags(originalTags); + assert.equal(pending.length, 2); + + // User removes the first one. + const after = pending.filter((d) => d.url !== "https://b/a.png"); + + // Composer builds the edit's mediaTags from the remaining pending list. + const editMediaTags = buildImetaTags(after); + assert.equal(editMediaTags.length, 1); + assert.equal(editMediaTags[0][1], "url https://b/b.png"); +}); + +// ── buildOutgoingMessage (shared body+tags builder for send + edit) ─── + +test("buildOutgoingMessage: empty pendingImeta returns body untouched and undefined mediaTags", () => { + const out = buildOutgoingMessage("hello", []); + assert.equal(out.content, "hello"); + assert.equal(out.mediaTags, undefined); +}); + +test("buildOutgoingMessage: appends media markdown line per attachment, in order", () => { + const out = buildOutgoingMessage("hi", [ + { + url: "https://b/a.png", + type: "image/png", + sha256: "x", + size: 1, + uploaded: 0, + }, + { + url: "https://b/v.mp4", + type: "video/mp4", + sha256: "y", + size: 2, + uploaded: 0, + }, + ]); + assert.equal( + out.content, + "hi\n![image](https://b/a.png)\n![video](https://b/v.mp4)", + ); +}); + +test("buildOutgoingMessage: mediaTags mirror buildImetaTags output for non-empty pending", () => { + const pending = [ + { + url: "https://b/a.png", + type: "image/png", + sha256: "abc", + size: 99, + uploaded: 0, + }, + ]; + const out = buildOutgoingMessage("", pending); + assert.deepEqual(out.mediaTags, buildImetaTags(pending)); +}); + +// ── Sparse / legacy hygiene: omit empty x and zero size ─────────────── + +test("imetaMediaFromTags: entry without x leaves sha256 empty", () => { + const tags = [["imeta", "url https://b/a.png", "m image/png", "size 1"]]; + const out = imetaMediaFromTags(tags); + assert.equal(out.length, 1); + assert.equal(out[0].sha256, ""); +}); + +test("imetaMediaFromTags: entry without size leaves size 0", () => { + const tags = [["imeta", "url https://b/a.png", "m image/png", "x deadbeef"]]; + const out = imetaMediaFromTags(tags); + assert.equal(out.length, 1); + assert.equal(out[0].size, 0); +}); + +test("buildImetaTags: omits x line when sha256 is empty", () => { + const tags = buildImetaTags([ + { + url: "https://b/a.png", + type: "image/png", + sha256: "", + size: 1, + uploaded: 0, + }, + ]); + assert.equal(tags.length, 1); + // No element starts with "x " or "x\t" — no empty x line emitted. + assert.ok( + !tags[0].some((part) => /^x[\s\t]/.test(part)), + `expected no x line, got ${JSON.stringify(tags[0])}`, + ); +}); + +test("buildImetaTags: omits size line when size is 0", () => { + const tags = buildImetaTags([ + { + url: "https://b/a.png", + type: "image/png", + sha256: "deadbeef", + size: 0, + uploaded: 0, + }, + ]); + assert.equal(tags.length, 1); + assert.ok( + !tags[0].some((part) => /^size[\s\t]/.test(part)), + `expected no size line, got ${JSON.stringify(tags[0])}`, + ); +}); + +test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size", () => { + // Legacy / cross-client entry: only url + m. No x, no size. + const legacyTags = [["imeta", "url https://b/legacy.png", "m image/png"]]; + const projected = imetaMediaFromTags(legacyTags); + assert.equal(projected.length, 1); + assert.equal(projected[0].sha256, ""); + assert.equal(projected[0].size, 0); + + const rebuilt = buildImetaTags(projected); + assert.equal(rebuilt.length, 1); + // Neither "x " nor "size 0" leaked into the rebuilt tag. + assert.ok( + !rebuilt[0].some((part) => /^x[\s\t]/.test(part)), + `expected no x line, got ${JSON.stringify(rebuilt[0])}`, + ); + assert.ok( + !rebuilt[0].some((part) => /^size[\s\t]/.test(part)), + `expected no size line, got ${JSON.stringify(rebuilt[0])}`, + ); + // url and m survived. + assert.deepEqual(rebuilt[0], [ + "imeta", + "url https://b/legacy.png", + "m image/png", + ]); +}); diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts new file mode 100644 index 000000000..7fdd258be --- /dev/null +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -0,0 +1,166 @@ +/** + * Helpers for round-tripping NIP-92 imeta attachments through the message + * editor. + * + * Background: edit events (kind 40003) carry only the new `content`; imeta + * tags live on the original event. The renderer overlays the edit body onto + * the original event but `markdown.tsx` only renders /