Integrate bugbash/quinn-edit-mentions: notify newly-added mentions on message edit (8ace8eed)

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

* commit '2981f4ccf':
  Fix edit-to-add-mention not notifying newly-mentioned party (8ace8eed)
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-16 20:39:41 -04:00
co-authored by Tyler Longwell
11 changed files with 225 additions and 11 deletions
+6 -1
View File
@@ -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
+14 -2
View File
@@ -957,6 +957,10 @@ pub async fn edit_message(
content: String,
media_tags: Vec<Vec<String>>,
emoji_tags: Option<Vec<Vec<String>>>,
// 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<Vec<String>>,
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(())
}
+75
View File
@@ -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<String>],
custom_emoji_tags: &[Vec<String>],
mentions: &[&str],
) -> Result<EventBuilder, String> {
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<Vec<String>> {
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<String>> = 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()]);
}
}
@@ -76,7 +76,11 @@ export type ChannelPaneProps = {
onCloseThread: () => void;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
onEditSave?: (
content: string,
mediaTags?: string[][],
mentionPubkeys?: string[],
) => Promise<void>;
onMarkUnread?: (message: TimelineMessage) => void;
onMarkRead?: (message: TimelineMessage) => void;
onExpandThreadReplies: (message: TimelineMessage) => void;
@@ -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],
+12 -2
View File
@@ -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) {
@@ -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]);
});
@@ -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,
@@ -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<void>;
onEditSave?: (
content: string,
mediaTags?: string[][],
mentionPubkeys?: string[],
) => Promise<void>;
/** 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);
@@ -65,7 +65,11 @@ type MessageThreadPanelProps = {
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onEditLastOwnMessage?: () => boolean;
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
onEditSave?: (
content: string,
mediaTags?: string[][],
mentionPubkeys?: string[],
) => Promise<void>;
onMarkUnread?: (message: TimelineMessage) => void;
onMarkRead?: (message: TimelineMessage) => void;
onExpandReplies: (message: TimelineMessage) => void;
+2
View File
@@ -567,6 +567,7 @@ export async function editMessage(
content: string,
mediaTags?: string[][],
emojiTags?: string[][],
mentionPubkeys?: string[],
): Promise<void> {
await invokeTauri("edit_message", {
channelId,
@@ -574,6 +575,7 @@ export async function editMessage(
content,
mediaTags: mediaTags ?? [],
emojiTags: emojiTags ?? [],
mentionPubkeys: mentionPubkeys ?? null,
});
}