mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
commit
db63573fa4
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user