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:
Wes
2026-05-26 19:59:06 -07:00
committed by GitHub
co-authored by summer
parent 7df4681fee
commit f1e25b21da
17 changed files with 948 additions and 62 deletions
+4 -4
View File
@@ -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
+6 -3
View File
@@ -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(())
}
+6 -2
View File
@@ -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
View File
@@ -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![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",
]);
});
@@ -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
* `![image|video](url)` 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 `![image|video](url)` 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![video](${url})` : `\n![image](${url})`;
}
/**
* Build the body + tags pair for an outgoing message (initial send or
* edit). Appends `![image|video](url)` 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 `![image|video](url)` 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![video](${d.url})` : `\n![image](${d.url})`;
}
// 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;
+7 -1
View File
@@ -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> {