diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index b787e7f9a..4daadd365 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -468,7 +468,12 @@ const overrides = new Map([ // +35: persistent audience scope/hook wiring and chip component handoff. The // chip markup lives separately; remaining lines connect existing composer // send state to the audience store. Queued with the existing split. - ["src/features/messages/ui/MessageComposer.tsx", 1091], + // +23: edit-to-add-mention notify (8ace8eed) — onEditSave/edit-branch + // mentionPubkeys threading + two snapshot refs (extractMentionPubkeys, + // ownerPubkey) feeding the newly-added-mentions diff. Diff logic itself + // lives in threading.ts (diffAddedMentionPubkeys); this is the minimal + // composer-side wiring. Queued to split with the rest of this list. + ["src/features/messages/ui/MessageComposer.tsx", 1114], // global-agent-config: model-tuning section (BuzzAgentModelTuningFields via // EditAgentAdvancedFields) + providerValid gate + effectiveProvider derivation // + globalProvider threading into getPersonaProviderOptions. All load-bearing diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index d2c436742..283ea7ace 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -957,6 +957,10 @@ pub async fn edit_message( content: String, media_tags: Vec>, emoji_tags: Option>>, + // Pubkeys of mentions *newly added* by this edit (the composer diffs the + // edited body against the original). Only these get a `p` tag, so a typo-fix + // edit that leaves the mention set unchanged never re-wakes anyone. + mention_pubkeys: Option>, state: State<'_, AppState>, ) -> Result<(), String> { let channel_uuid = uuid::Uuid::parse_str(&channel_id) @@ -969,8 +973,16 @@ pub async fn edit_message( return Err("edit must have content or attachments".into()); } let emoji = emoji_tags.unwrap_or_default(); - let builder = - events::build_message_edit(channel_uuid, target_eid, trimmed, &media_tags, &emoji)?; + let mentions = mention_pubkeys.unwrap_or_default(); + let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect(); + let builder = events::build_message_edit( + channel_uuid, + target_eid, + trimmed, + &media_tags, + &emoji, + &mention_refs, + )?; submit_event(builder, &state).await?; Ok(()) } diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 650efffbd..5dee57829 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -393,18 +393,27 @@ pub fn build_forum_comment( /// event so the rendered message reflects exactly the edited state. NIP-30 /// custom-emoji tags ride along the same way so an edited body's `:shortcode:`s /// stay resolvable (the send path attaches these too). +/// +/// `mentions` carries the pubkeys of mentions that are *newly added* by this +/// edit (the caller diffs the edited body against the original). Only those get +/// a `p` tag so the newly-mentioned party is notified/woken, while a typo-fix +/// edit that leaves the mention set unchanged emits no `p` tags and never +/// re-wakes anyone. This mirrors the send path's `mention_tags` (dedup + +/// lowercase); the receiver overlays these onto the original event's audience. pub fn build_message_edit( channel_id: Uuid, target_event_id: EventId, content: &str, media_tags: &[Vec], custom_emoji_tags: &[Vec], + mentions: &[&str], ) -> Result { check_content(content)?; let mut tags = vec![ tag(vec!["h", &channel_id.to_string()])?, tag(vec!["e", &target_event_id.to_hex()])?, ]; + tags.extend(mention_tags(mentions)?); imeta_tags(media_tags, &mut tags)?; emoji_tags(custom_emoji_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags)) @@ -908,4 +917,70 @@ mod tests { assert_eq!(tags.len(), 3, "self unarchive must not carry auth tag"); assert_eq!(event.pubkey.to_hex(), TARGET_HEX); } + + // ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── + // + // The composer diffs the edited body's mentions against the original and + // hands `build_message_edit` only the *newly added* pubkeys. These tests + // pin the builder's contract given that contract: emit a `p` per added + // mention (deduped, lowercased), and none when the added set is empty + // (typo-fix edit) — so an unchanged mention set re-wakes nobody. + + const CH_ID: &str = "11111111-1111-4111-8111-111111111111"; + const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + + fn edit_tags(mentions: &[&str]) -> Vec> { + let channel = Uuid::parse_str(CH_ID).unwrap(); + let target = + EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") + .unwrap(); + let builder = build_message_edit(channel, target, "hi @alice", &[], &[], mentions).unwrap(); + let secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000003", + ) + .unwrap(); + let event = builder.sign_with_keys(&Keys::new(secret)).unwrap(); + event.tags.iter().map(|t| t.as_slice().to_vec()).collect() + } + + #[test] + fn edit_with_added_mention_emits_p_tag() { + let tags = edit_tags(&[ALICE_HEX]); + assert_eq!(tags[0][0], "h"); + assert_eq!(tags[1][0], "e"); + // The `p` tag rides right after the `e` tag (insertion order). + assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]); + } + + #[test] + fn edit_with_no_added_mentions_emits_no_p_tag() { + // Typo-fix edit: mention set unchanged, so the composer passes `&[]`. + // The edit event must carry no `p` tag and re-wake nobody. + let tags = edit_tags(&[]); + assert!( + !tags + .iter() + .any(|t| t.first().map(String::as_str) == Some("p")), + "unchanged-mention edit must not emit any `p` tag, got {tags:?}" + ); + } + + #[test] + fn edit_mentions_are_deduped_and_lowercased() { + let alice_upper = ALICE_HEX.to_ascii_uppercase(); + let tags = edit_tags(&[ALICE_HEX, &alice_upper, BOB_HEX]); + let p_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(String::as_str) == Some("p")) + .collect(); + // ALICE appears twice (mixed case) but collapses to one lowercase tag. + assert_eq!( + p_tags.len(), + 2, + "duplicate mention must collapse, got {p_tags:?}" + ); + assert_eq!(p_tags[0], &vec!["p".to_string(), ALICE_HEX.to_string()]); + assert_eq!(p_tags[1], &vec!["p".to_string(), BOB_HEX.to_string()]); + } } diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 3004aa4e9..e6a63c8f0 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -76,7 +76,11 @@ export type ChannelPaneProps = { onCloseThread: () => void; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; - onEditSave?: (content: string, mediaTags?: string[][]) => Promise; + onEditSave?: ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + ) => Promise; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; onExpandThreadReplies: (message: TimelineMessage) => void; diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index e3ec2f5cd..8c5f54fff 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -144,13 +144,22 @@ export function useChannelPaneHandlers({ ); const handleEditSave = React.useCallback( - async (content: string, mediaTags?: string[][]) => { + async ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + ) => { const eventId = editTargetIdRef.current; if (!eventId) { return; } - await editMutateRef.current({ eventId, content, mediaTags }); + await editMutateRef.current({ + eventId, + content, + mediaTags, + mentionPubkeys, + }); setEditTargetId(null); }, [setEditTargetId], diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 321c21414..3d34d07d1 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -685,9 +685,12 @@ export function useEditMessageMutation(channel: Channel | null) { eventId: string; content: string; mediaTags?: string[][]; + // Pubkeys of mentions *newly added* by this edit, diffed at the composer. + // Only these receive a `p` tag so a typo-fix edit re-wakes nobody. + mentionPubkeys?: string[]; } >({ - mutationFn: async ({ eventId, content, mediaTags }) => { + mutationFn: async ({ eventId, content, mediaTags, mentionPubkeys }) => { if (!channel) { throw new Error("No channel selected."); } @@ -698,7 +701,14 @@ export function useEditMessageMutation(channel: Channel | null) { // guard rejects any non-imeta prefix), mirroring the send path. const { mediaTags: imetaTags, emojiTags } = splitOutgoingTags(mediaTags); - await editMessage(channel.id, eventId, content, imetaTags, emojiTags); + await editMessage( + channel.id, + eventId, + content, + imetaTags, + emojiTags, + mentionPubkeys, + ); }, onSuccess: (_data, { eventId, content, mediaTags }) => { if (!channel) { diff --git a/desktop/src/features/messages/lib/diffAddedMentionPubkeys.test.mjs b/desktop/src/features/messages/lib/diffAddedMentionPubkeys.test.mjs new file mode 100644 index 000000000..f3acb3421 --- /dev/null +++ b/desktop/src/features/messages/lib/diffAddedMentionPubkeys.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { diffAddedMentionPubkeys } from "./threading.ts"; + +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); +const SELF = "c".repeat(64); + +test("returns mentions the edit newly adds", () => { + // Original mentioned Alice; edit adds Bob. + assert.deepEqual(diffAddedMentionPubkeys([ALICE], [ALICE, BOB], SELF), [BOB]); +}); + +test("typo-fix edit with unchanged mentions re-wakes nobody", () => { + assert.deepEqual(diffAddedMentionPubkeys([ALICE], [ALICE], SELF), []); +}); + +test("adding the first mention to a previously unmentioned body", () => { + assert.deepEqual(diffAddedMentionPubkeys([], [ALICE], SELF), [ALICE]); +}); + +test("removing a mention adds nothing", () => { + assert.deepEqual(diffAddedMentionPubkeys([ALICE, BOB], [ALICE], SELF), []); +}); + +test("case-only difference is not treated as newly added", () => { + // Original stored uppercase, edit resolves lowercase (or vice versa). + assert.deepEqual( + diffAddedMentionPubkeys([ALICE.toUpperCase()], [ALICE], SELF), + [], + ); +}); + +test("self-mention added in the edit is scrubbed, never notified", () => { + assert.deepEqual(diffAddedMentionPubkeys([ALICE], [ALICE, SELF], SELF), []); +}); + +test("duplicate added mention collapses to one", () => { + assert.deepEqual(diffAddedMentionPubkeys([], [BOB, BOB, BOB], SELF), [BOB]); +}); + +test("re-adding a removed mention counts as newly added", () => { + // Original had no Bob (he was removed in a prior state); this edit adds him. + assert.deepEqual(diffAddedMentionPubkeys([ALICE], [ALICE, BOB], SELF), [BOB]); +}); diff --git a/desktop/src/features/messages/lib/threading.ts b/desktop/src/features/messages/lib/threading.ts index 4a18d0264..95694f498 100644 --- a/desktop/src/features/messages/lib/threading.ts +++ b/desktop/src/features/messages/lib/threading.ts @@ -74,6 +74,30 @@ export function normalizeMentionPubkeys( return result; } +/** + * Mentions an edit *newly adds*, relative to the original message body. + * + * The composer resolves both bodies to pubkey lists with the same + * channel-roster resolver the send path uses, then hands them here. We + * normalize the edited body's set (lowercase / dedup / drop self) and keep + * only pubkeys that were not already present in the original body — compared + * case-insensitively so a case-only difference is never treated as "new". + * + * A typo-fix edit that leaves the mention set unchanged yields `[]`, so the + * edit event carries no `p` tags and re-wakes nobody. Only genuinely new + * mentions get notified. + */ +export function diffAddedMentionPubkeys( + originalPubkeys: string[], + editedPubkeys: string[], + selfPubkey: string, +): string[] { + const original = new Set(originalPubkeys.map((pk) => pk.toLowerCase())); + return normalizeMentionPubkeys(editedPubkeys, selfPubkey).filter( + (pubkey) => !original.has(pubkey), + ); +} + export function buildReplyTags( channelId: string, authorPubkey: string, diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 4ea9c095e..bd190394c 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -26,6 +26,7 @@ import { useMediaUpload, } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; +import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -113,7 +114,11 @@ type MessageComposerProps = { * return `false` to let the arrow key fall through normally. */ onEditLastOwnMessage?: () => boolean; - onEditSave?: (content: string, mediaTags?: string[][]) => Promise; + onEditSave?: ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + ) => Promise; /** Captures send context synchronously before awaits can change navigation. */ onCaptureSendContext?: () => { parentEventId: string | null; @@ -273,6 +278,8 @@ function MessageComposerImpl({ const onEditSaveRef = React.useRef(onEditSave); const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); const editTargetRef = React.useRef(editTarget); + const extractMentionPubkeysRef = React.useRef(mentions.extractMentionPubkeys); + const ownerPubkeyRef = React.useRef(ownerPubkey); disabledRef.current = disabled; isSendingRef.current = isSending; isUploadingRef.current = media.isUploading; @@ -280,6 +287,8 @@ function MessageComposerImpl({ onEditSaveRef.current = onEditSave; onEditLastOwnMessageRef.current = onEditLastOwnMessage; editTargetRef.current = editTarget; + extractMentionPubkeysRef.current = mentions.extractMentionPubkeys; + ownerPubkeyRef.current = ownerPubkey; const isAutocompleteOpenRef = React.useRef(false); isAutocompleteOpenRef.current = @@ -621,6 +630,16 @@ function MessageComposerImpl({ buildCustomEmojiTags(finalContent, customEmoji), ) ?? []; + // Notify only mentions this edit *newly adds* (see + // diffAddedMentionPubkeys): a typo-fix edit that leaves the mention set + // unchanged emits no `p` tags and re-wakes nobody. Computed before the + // composer state is cleared below. + const addedMentionPubkeys = diffAddedMentionPubkeys( + extractMentionPubkeysRef.current(editTargetRef.current.body), + extractMentionPubkeysRef.current(finalContent), + ownerPubkeyRef.current ?? "", + ); + const savedContent = trimmed; const savedImeta = [...currentPendingImeta]; const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls); @@ -634,7 +653,11 @@ function MessageComposerImpl({ setIsEmojiPickerOpen(false); try { - await onEditSaveRef.current(finalContent, outgoingTags); + await onEditSaveRef.current( + finalContent, + outgoingTags, + addedMentionPubkeys, + ); } catch { setComposerContent(savedContent); richText.setContent(savedContent); diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index f0e7b398b..863203600 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -65,7 +65,11 @@ type MessageThreadPanelProps = { onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; onEditLastOwnMessage?: () => boolean; - onEditSave?: (content: string, mediaTags?: string[][]) => Promise; + onEditSave?: ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + ) => Promise; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; onExpandReplies: (message: TimelineMessage) => void; diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 4c6be8eab..ef7f9fc24 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -567,6 +567,7 @@ export async function editMessage( content: string, mediaTags?: string[][], emojiTags?: string[][], + mentionPubkeys?: string[], ): Promise { await invokeTauri("edit_message", { channelId, @@ -574,6 +575,7 @@ export async function editMessage( content, mediaTags: mediaTags ?? [], emojiTags: emojiTags ?? [], + mentionPubkeys: mentionPubkeys ?? null, }); }