mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): editable attachments + data-loss fix on message edit (#755)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: summer <summer@sprout>
This commit is contained in:
@@ -37,18 +37,18 @@ const overrides = new Map([
|
||||
["src/app/AppShell.tsx", 835], // 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) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard
|
||||
["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
|
||||
|
||||
@@ -389,16 +389,19 @@ pub async fn edit_message(
|
||||
channel_id: String,
|
||||
event_id: String,
|
||||
content: String,
|
||||
media_tags: Vec<Vec<String>>,
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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<String>],
|
||||
) -> Result<EventBuilder, String> {
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
hasOlderMessages?: boolean;
|
||||
@@ -82,7 +84,7 @@ type ChannelPaneProps = {
|
||||
onCloseThread: () => void;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onEditSave?: (content: string) => Promise<void>;
|
||||
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onExpandThreadReplies: (message: TimelineMessage) => void;
|
||||
onJoinChannel?: () => Promise<void>;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<RelayEvent[]>(
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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` : `\n`;
|
||||
}
|
||||
|
||||
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";
|
||||
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";
|
||||
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\n";
|
||||
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";
|
||||
assert.equal(stripImetaMediaLines(body, []), body);
|
||||
});
|
||||
|
||||
test("strip: leaves media line whose URL isn't in imetaMedia", () => {
|
||||
const body = "hello\n";
|
||||
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\nmiddle\n";
|
||||
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\nmiddle");
|
||||
});
|
||||
|
||||
test("strip: tolerates blank lines between text and trailing media", () => {
|
||||
const body = "hi\n\n";
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatImetaMediaLine: video mime → ![video] line (regardless of URL suffix)", () => {
|
||||
assert.equal(
|
||||
formatImetaMediaLine({ url: "https://cdn/blob/xyz", type: "video/mp4" }),
|
||||
"\n",
|
||||
);
|
||||
});
|
||||
|
||||
// ── 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\n",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
]);
|
||||
});
|
||||
@@ -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 <img>/<video> for URLs
|
||||
* literally present in the body.
|
||||
*
|
||||
* The composer's edit mode now manages attachments as first-class state
|
||||
* (mirrors the send path):
|
||||
*
|
||||
* - on edit-load, seed the composer's `pendingImeta` with the original
|
||||
* event's imeta entries (full BlobDescriptor shape, so the send-path
|
||||
* mediaTags builder works unchanged); strip any matching trailing
|
||||
* `` lines from the body so the user only sees text;
|
||||
* - on submit, pass `mediaTags` (built from the current `pendingImeta`)
|
||||
* alongside the edited content so the edit event carries a full new
|
||||
* imeta tag set;
|
||||
* - the receiver overlays the edit's imeta tags onto the rendered message
|
||||
* (`formatTimelineMessages`).
|
||||
*
|
||||
* `ImetaMedia` is exactly the `BlobDescriptor` shape so it plugs into
|
||||
* `setPendingImeta` directly. `uploaded` isn't carried in imeta tags, so
|
||||
* `imetaMediaFromTags` zero-fills it (no consumer reads the value today).
|
||||
*/
|
||||
|
||||
import type { BlobDescriptor } from "@/shared/api/tauri";
|
||||
import { parseImetaTags } from "./parseImeta";
|
||||
|
||||
export type ImetaMedia = BlobDescriptor;
|
||||
|
||||
/**
|
||||
* Project a Nostr event's imeta tags into the `BlobDescriptor[]` shape the
|
||||
* composer's media state uses. Preserves tag order.
|
||||
*
|
||||
* Falls back to `image/jpeg` when an entry is missing `m` (legacy events).
|
||||
* The `uploaded` field isn't transmitted in imeta tags — set to 0 since no
|
||||
* consumer reads it.
|
||||
*
|
||||
* Projection ceiling: NIP-92 also defines `alt`, `fallback`, and `service`
|
||||
* fields that `BlobDescriptor` doesn't carry. We drop them on edit-load,
|
||||
* which means an edit will silently strip those fields from the saved tag
|
||||
* set. In practice this only fires on cross-client edits today (our send
|
||||
* path doesn't emit them), so the data loss is bounded. If/when those
|
||||
* fields become first-class in the composer, widen `BlobDescriptor`
|
||||
* (or split `ImetaMedia` from it) and pass them through here.
|
||||
*/
|
||||
export function imetaMediaFromTags(
|
||||
tags: ReadonlyArray<ReadonlyArray<string>> | undefined,
|
||||
): ImetaMedia[] {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
const entries = parseImetaTags(tags as string[][]);
|
||||
const out: ImetaMedia[] = [];
|
||||
for (const entry of entries.values()) {
|
||||
if (!entry.url) continue;
|
||||
out.push({
|
||||
url: entry.url,
|
||||
type: entry.m ?? "image/jpeg",
|
||||
sha256: entry.x ?? "",
|
||||
size: entry.size ?? 0,
|
||||
uploaded: 0,
|
||||
...(entry.dim ? { dim: entry.dim } : {}),
|
||||
...(entry.blurhash ? { blurhash: entry.blurhash } : {}),
|
||||
...(entry.thumb ? { thumb: entry.thumb } : {}),
|
||||
...(entry.duration != null ? { duration: entry.duration } : {}),
|
||||
...(entry.image ? { image: entry.image } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the imeta tag set for an outbound event from a list of attachments.
|
||||
* Shared by the send path (initial post) and the edit path (full new tag set
|
||||
* on the edit event), so the two stay perfectly symmetric.
|
||||
*
|
||||
* `url` and `m` are always emitted (NIP-92's only de-facto required fields;
|
||||
* `m` carries a fallback in `imetaMediaFromTags`). All other fields are
|
||||
* conditional — including `x` and `size` — because legacy and cross-client
|
||||
* imeta entries can land without a sha256 or size, and our relay validator
|
||||
* rejects literal `"x "` / `"size 0"` empties. NIP-92 itself treats every
|
||||
* field except `url` as optional, so dropping them is spec-clean.
|
||||
*/
|
||||
export function buildImetaTags(
|
||||
imetaMedia: ReadonlyArray<ImetaMedia>,
|
||||
): string[][] {
|
||||
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}`] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
const MEDIA_LINE_RE = /^!\[(?:image|video)\]\(([^)\s]+)\)\s*$/;
|
||||
|
||||
/**
|
||||
* Remove trailing `` lines whose URL matches an entry in
|
||||
* `imetaMedia`. Stops at the first non-matching/non-blank line so attachments
|
||||
* that have been moved or interleaved with text are left alone (the composer
|
||||
* only ever produces trailing lines, but defending against shape drift is
|
||||
* cheap).
|
||||
*/
|
||||
export function stripImetaMediaLines(
|
||||
body: string,
|
||||
imetaMedia: ReadonlyArray<ImetaMedia>,
|
||||
): string {
|
||||
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+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single imeta entry as a leading-newline markdown line. Mime-driven
|
||||
* so the alt label is correct regardless of URL suffix.
|
||||
*/
|
||||
export function formatImetaMediaLine({ url, type }: ImetaMedia): string {
|
||||
const isVideo = type.startsWith("video/");
|
||||
return isVideo ? `\n` : `\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the body + tags pair for an outgoing message (initial send or
|
||||
* edit). Appends `` markdown lines for each attachment
|
||||
* to the body so the renderer (which keys on URLs literally present in
|
||||
* the content) draws them, and returns the matching imeta tag set.
|
||||
*
|
||||
* Returns `mediaTags: undefined` when there are no attachments. Callers
|
||||
* that need an explicit "wipe attachments" signal (the edit path, where
|
||||
* `[]` instructs the receiver overlay to drop existing imeta) should
|
||||
* coerce with `?? []`.
|
||||
*/
|
||||
export function buildOutgoingMessage(
|
||||
body: string,
|
||||
pendingImeta: ReadonlyArray<ImetaMedia>,
|
||||
): { content: string; mediaTags: string[][] | undefined } {
|
||||
let content = body;
|
||||
for (const d of pendingImeta) content += formatImetaMediaLine(d);
|
||||
const mediaTags =
|
||||
pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined;
|
||||
return { content, mediaTags };
|
||||
}
|
||||
@@ -70,6 +70,15 @@ function MoreActionsMenu({
|
||||
open: boolean;
|
||||
}) {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
|
||||
// Set true the moment the user picks "Edit message". The
|
||||
// `onCloseAutoFocus` handler on `DropdownMenuContent` reads it to
|
||||
// suppress Radix's default focus-restoration (which would yank focus
|
||||
// back to the trigger and steal it from the composer's editor — the
|
||||
// composer schedules its own focus on RAF, but Radix's restoration
|
||||
// runs in a setTimeout that fires after our RAF and wins the race).
|
||||
// Reset to false inside the handler so Escape / non-Edit closes still
|
||||
// get default trigger-restoration (a11y intact for keyboard users).
|
||||
const editJustSelectedRef = React.useRef(false);
|
||||
|
||||
const hasCopyActions = !message.pending;
|
||||
|
||||
@@ -93,11 +102,22 @@ function MoreActionsMenu({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>More actions</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" side="top" sideOffset={6}>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (editJustSelectedRef.current) {
|
||||
event.preventDefault();
|
||||
editJustSelectedRef.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{onEdit ? (
|
||||
<DropdownMenuItem
|
||||
data-testid={`edit-message-${message.id}`}
|
||||
onClick={() => {
|
||||
editJustSelectedRef.current = true;
|
||||
onEdit(message);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,11 @@ import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"
|
||||
import { useDrafts } from "@/features/messages/lib/useDrafts";
|
||||
import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete";
|
||||
import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete";
|
||||
import {
|
||||
buildOutgoingMessage,
|
||||
type ImetaMedia,
|
||||
stripImetaMediaLines,
|
||||
} from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
|
||||
import {
|
||||
ALLOWED_MEDIA_TYPES,
|
||||
@@ -46,11 +51,19 @@ type MessageComposerProps = {
|
||||
author: string;
|
||||
body: string;
|
||||
id: string;
|
||||
/**
|
||||
* NIP-92 imeta attachments on the original event, in tag order. Loaded
|
||||
* into the composer's pending-imeta state on edit-open so the user sees
|
||||
* them as removable thumbnails (just like the send path) and can add
|
||||
* more. The submit path emits a fresh full imeta tag set on the edit
|
||||
* event; the receiver overlays it.
|
||||
*/
|
||||
imetaMedia?: ImetaMedia[];
|
||||
} | null;
|
||||
isSending?: boolean;
|
||||
onCancelEdit?: () => void;
|
||||
onCancelReply?: () => void;
|
||||
onEditSave?: (content: string) => Promise<void>;
|
||||
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
|
||||
onSend: (
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
@@ -106,7 +119,14 @@ export function MessageComposer({
|
||||
const previousDraftKeyRef = React.useRef<string | null>(null);
|
||||
const effectiveDraftKeyRef = React.useRef(effectiveDraftKey);
|
||||
effectiveDraftKeyRef.current = effectiveDraftKey;
|
||||
const preEditContentRef = React.useRef<string | null>(null);
|
||||
// Snapshot of composer state at the moment we enter edit mode (text body
|
||||
// + draft attachments) so the user's pre-edit work isn't lost when the
|
||||
// composer is hijacked for editing. Restored on edit-cancel/exit. `null`
|
||||
// while not in edit mode.
|
||||
const preEditSnapshotRef = React.useRef<{
|
||||
content: string;
|
||||
pendingImeta: ImetaMedia[];
|
||||
} | null>(null);
|
||||
const mentions = useMentions(channelId, undefined, profiles);
|
||||
const channelLinks = useChannelLinks();
|
||||
const emojiAutocomplete = useEmojiAutocomplete();
|
||||
@@ -220,17 +240,45 @@ export function MessageComposer({
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger
|
||||
React.useEffect(() => {
|
||||
if (editTarget) {
|
||||
preEditContentRef.current = contentRef.current;
|
||||
setContent(editTarget.body);
|
||||
contentRef.current = editTarget.body;
|
||||
richText.setContent(editTarget.body);
|
||||
richText.focus();
|
||||
} else if (preEditContentRef.current !== null) {
|
||||
const restored = preEditContentRef.current;
|
||||
preEditContentRef.current = null;
|
||||
setContent(restored);
|
||||
contentRef.current = restored;
|
||||
restored ? richText.setContent(restored) : richText.clearContent();
|
||||
// Snapshot the current draft (text + attachments) so the user's
|
||||
// in-flight work survives the edit-mode hijack and is restored on
|
||||
// edit-cancel/exit.
|
||||
preEditSnapshotRef.current = {
|
||||
content: contentRef.current,
|
||||
pendingImeta: [...media.pendingImetaRef.current],
|
||||
};
|
||||
// Strip the trailing `` lines that correspond to
|
||||
// imeta attachments — the user manages those via the attachments row,
|
||||
// not via raw markdown in the editor.
|
||||
const editableBody = stripImetaMediaLines(
|
||||
editTarget.body,
|
||||
editTarget.imetaMedia ?? [],
|
||||
);
|
||||
setContent(editableBody);
|
||||
contentRef.current = editableBody;
|
||||
richText.setContent(editableBody);
|
||||
// Seed the composer's pending-imeta state with the original event's
|
||||
// attachments so they show up in `ComposerAttachments` and the user
|
||||
// can remove existing ones / add new ones before saving.
|
||||
media.setPendingImeta(editTarget.imetaMedia ?? []);
|
||||
// Defer focus to the next frame so it runs after any focus-
|
||||
// restoration the trigger UI (e.g. the message-row context menu)
|
||||
// fires on close. Without this, Radix-style focus-restoration races
|
||||
// our call and leaves DOM focus on the message row — global keybinds
|
||||
// like Delete then fire there instead of in the editor. `focusEnd`
|
||||
// also lands the caret at end of the loaded content.
|
||||
const rafId = requestAnimationFrame(() => richText.focusEnd());
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
} else if (preEditSnapshotRef.current !== null) {
|
||||
const { content: restoredContent, pendingImeta: restoredImeta } =
|
||||
preEditSnapshotRef.current;
|
||||
preEditSnapshotRef.current = null;
|
||||
setContent(restoredContent);
|
||||
contentRef.current = restoredContent;
|
||||
restoredContent
|
||||
? richText.setContent(restoredContent)
|
||||
: richText.clearContent();
|
||||
media.setPendingImeta(restoredImeta);
|
||||
}
|
||||
}, [editTarget?.id]);
|
||||
|
||||
@@ -343,23 +391,40 @@ export function MessageComposer({
|
||||
|
||||
// Edit mode
|
||||
if (editTargetRef.current && onEditSaveRef.current) {
|
||||
if (!trimmed || isSendingRef.current) return;
|
||||
if (isSendingRef.current || isUploadingRef.current) return;
|
||||
const currentPendingImeta = media.pendingImetaRef.current;
|
||||
const hasMedia = currentPendingImeta.length > 0;
|
||||
// Empty text + zero attachments is a no-op (don't let edit become an
|
||||
// effective deletion).
|
||||
if (!trimmed && !hasMedia) return;
|
||||
|
||||
// Build the edit's body + imeta tag set. Coerce `mediaTags ?? []`
|
||||
// because edit semantics use `[]` as the explicit "wipe all
|
||||
// attachments" signal — the receiver overlay drops imeta when the
|
||||
// edit carries an empty (but defined) set.
|
||||
const { content: finalContent, mediaTags } = buildOutgoingMessage(
|
||||
trimmed,
|
||||
currentPendingImeta,
|
||||
);
|
||||
|
||||
const savedContent = trimmed;
|
||||
const savedImeta = [...currentPendingImeta];
|
||||
setContent("");
|
||||
contentRef.current = "";
|
||||
richText.clearContent();
|
||||
media.setPendingImeta([]);
|
||||
mentions.clearMentions();
|
||||
channelLinks.clearChannels();
|
||||
emojiAutocomplete.clearEmojis();
|
||||
setIsEmojiPickerOpen(false);
|
||||
|
||||
try {
|
||||
await onEditSaveRef.current(trimmed);
|
||||
await onEditSaveRef.current(finalContent, mediaTags ?? []);
|
||||
} catch {
|
||||
setContent(savedContent);
|
||||
contentRef.current = savedContent;
|
||||
richText.setContent(savedContent);
|
||||
media.setPendingImeta(savedImeta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -378,28 +443,13 @@ export function MessageComposer({
|
||||
|
||||
const pubkeys = mentions.extractMentionPubkeys(trimmed);
|
||||
|
||||
const mediaTags =
|
||||
currentPendingImeta.length > 0
|
||||
? currentPendingImeta.map((d) => [
|
||||
"imeta",
|
||||
`url ${d.url}`,
|
||||
`m ${d.type}`,
|
||||
`x ${d.sha256}`,
|
||||
`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}`] : []),
|
||||
])
|
||||
: undefined;
|
||||
|
||||
// Append all attachments as markdown images at the end of the message.
|
||||
let finalContent = trimmed;
|
||||
for (const d of currentPendingImeta) {
|
||||
const isVideo = d.type.startsWith("video/");
|
||||
finalContent += isVideo ? `\n` : `\n`;
|
||||
}
|
||||
// Send semantics use `undefined` for "no attachments" (no imeta tags
|
||||
// emitted on the publish), which is what `buildOutgoingMessage`
|
||||
// returns by default.
|
||||
const { content: finalContent, mediaTags } = buildOutgoingMessage(
|
||||
trimmed,
|
||||
currentPendingImeta,
|
||||
);
|
||||
|
||||
const savedContent = trimmed;
|
||||
const savedImeta = [...currentPendingImeta];
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { ArrowDown, X } from "lucide-react";
|
||||
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
@@ -28,14 +29,19 @@ type MessageThreadPanelProps = {
|
||||
channelName: string;
|
||||
currentPubkey?: string;
|
||||
disabled?: boolean;
|
||||
editTarget?: { author: string; body: string; id: string } | null;
|
||||
editTarget?: {
|
||||
author: string;
|
||||
body: string;
|
||||
id: string;
|
||||
imetaMedia?: ImetaMedia[];
|
||||
} | null;
|
||||
isSending: boolean;
|
||||
onCancelEdit?: () => void;
|
||||
onCancelReply: () => void;
|
||||
onClose: () => void;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onEditSave?: (content: string) => Promise<void>;
|
||||
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onExpandReplies: (message: TimelineMessage) => void;
|
||||
onResetWidth: () => void;
|
||||
|
||||
@@ -785,8 +785,14 @@ export async function editMessage(
|
||||
channelId: string,
|
||||
eventId: string,
|
||||
content: string,
|
||||
mediaTags?: string[][],
|
||||
): Promise<void> {
|
||||
await invokeTauri("edit_message", { channelId, eventId, content });
|
||||
await invokeTauri("edit_message", {
|
||||
channelId,
|
||||
eventId,
|
||||
content,
|
||||
mediaTags: mediaTags ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMessage(eventId: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user