Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,18 @@ const overrides = new Map([
["src/app/AppShell.tsx", 815], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager)
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers
["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification
["src/features/channels/ui/ChannelPane.tsx", 525], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget
["src/features/channels/ui/ChannelScreen.tsx", 555], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState)
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/ui/MessageComposer.tsx", 760], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619)
["src/features/messages/ui/MessageComposer.tsx", 800], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ
["src-tauri/src/commands/messages.rs", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs)
["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests
Expand Down
9 changes: 6 additions & 3 deletions desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
8 changes: 6 additions & 2 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
4 changes: 3 additions & 1 deletion desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -67,6 +68,7 @@ type ChannelPaneProps = {
author: string;
body: string;
id: string;
imetaMedia?: ImetaMedia[];
} | null;
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
Expand All @@ -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>;
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -483,6 +484,9 @@ export function ChannelScreen({
author: editTargetMessage.author,
body: editTargetMessage.body,
id: editTargetMessage.id,
imetaMedia: imetaMediaFromTags(
editTargetMessage.tags,
),
}
: null
}
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/channels/useChannelPaneHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
27 changes: 21 additions & 6 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -458,26 +461,38 @@ 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;
}

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 };
}),
);
},
});
Expand Down
17 changes: 17 additions & 0 deletions desktop/src/features/messages/lib/applyEditTagOverlay.d.mts
Original file line number Diff line number Diff line change
@@ -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[];
26 changes: 26 additions & 0 deletions desktop/src/features/messages/lib/applyEditTagOverlay.mjs
Original file line number Diff line number Diff line change
@@ -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];
}
105 changes: 105 additions & 0 deletions desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
17 changes: 14 additions & 3 deletions desktop/src/features/messages/lib/formatTimelineMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 (
Expand All @@ -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,
});
}
Expand Down Expand Up @@ -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;
Expand Down
Loading