Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8cc27ba
Add Send to channel for thread messages
klopez4212 Aug 8, 2026
cccf293
Merge remote-tracking branch 'origin/main' into kennylopez-send-to-ch…
klopez4212 Aug 8, 2026
dacdf90
Restore shared thread chip styling
klopez4212 Aug 8, 2026
4fb733e
fix(desktop): wait for thread messages before sharing
klopez4212 Aug 8, 2026
8dff13d
Preserve shared message semantics
klopez4212 Aug 8, 2026
394165b
Refine shared thread provenance
klopez4212 Aug 8, 2026
530f235
Render thread provenance as a link
klopez4212 Aug 8, 2026
bbd4f85
Start shared posts as new message groups
klopez4212 Aug 8, 2026
2cc6ee2
Render thread links in the composer
klopez4212 Aug 8, 2026
98cc2b8
Align thread links with Inbox chips
klopez4212 Aug 8, 2026
19baa51
Underline thread source links on hover
klopez4212 Aug 8, 2026
0fbf9bb
Match thread source links to Inbox metadata
klopez4212 Aug 8, 2026
3115a60
Use read-state thread source styling
klopez4212 Aug 8, 2026
47f8eed
Refine thread source link styling
klopez4212 Aug 8, 2026
e37d554
Increase thread source emphasis
klopez4212 Aug 8, 2026
3514b71
Limit thread link underline to text
klopez4212 Aug 8, 2026
9387e0b
Make thread link hover webview reliable
klopez4212 Aug 8, 2026
eab77c9
Keep native thread underline visible
klopez4212 Aug 8, 2026
0030d90
Harden send-to-channel edge cases
klopez4212 Aug 8, 2026
caec1b5
Preserve edited mention identities
klopez4212 Aug 10, 2026
ea757a4
Merge origin/main into kennylopez-send-to-channel
klopez4212 Aug 10, 2026
a9ae085
Preserve mentions in partial edits
Aug 10, 2026
fea3292
Stabilize thread sharing edit metadata
Aug 10, 2026
c7d5de9
fix(desktop): preserve edit mention identities
Aug 10, 2026
d1268ab
fix(desktop): preserve mentions in partial edits
Aug 10, 2026
cbad00c
Fix thread sharing review edge cases
wesbillman Aug 10, 2026
7811d5c
Merge origin/main into kennylopez-send-to-channel
wesbillman Aug 10, 2026
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
19 changes: 16 additions & 3 deletions desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ pub async fn send_channel_message(
emoji_tags: Option<Vec<Vec<String>>>,
mention_tags: Option<Vec<Vec<String>>>,
link_preview_tags: Option<Vec<Vec<String>>>,
sent_from_thread_tag: Option<Vec<String>>,
mention_pubkeys: Option<Vec<String>>,
kind: Option<u32>,
state: State<'_, AppState>,
Expand All @@ -500,6 +501,9 @@ pub async fn send_channel_message(
let link_previews = link_preview_tags.unwrap_or_default();
let relay_base = crate::relay::relay_api_base_url_with_override(&state);
let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE);
if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE {
return Err("sent-from-thread provenance requires a stream message".into());
}

let mut resolved_root: Option<String> = None;

Expand Down Expand Up @@ -544,6 +548,7 @@ pub async fn send_channel_message(
&emoji,
&mention_refs_only,
&link_previews,
sent_from_thread_tag.as_deref(),
&relay_base,
)?
}
Expand Down Expand Up @@ -712,6 +717,7 @@ fn build_managed_agent_channel_message(
&[],
&[],
&[],
None,
&crate::relay::relay_api_base_url(),
client_tags,
)
Expand Down Expand Up @@ -890,6 +896,10 @@ pub struct EditMessageInput {
// tag, so a typo-fix edit never re-wakes existing mentions.
#[serde(default)]
mention_pubkeys: Vec<String>,
// Full stable mention identity set selected in the edited composer. `None`
// means a partial edit that must preserve the existing snapshot; `Some`,
// including an empty set, authoritatively replaces it.
mention_tags: Option<Vec<Vec<String>>>,
#[serde(default)]
suppress_link_previews: bool,
}
Expand All @@ -914,9 +924,12 @@ pub async fn edit_message(
channel_uuid,
target_eid,
trimmed,
&input.media_tags,
&input.emoji_tags,
&mention_refs,
events::MessageEditTags {
media: &input.media_tags,
custom_emoji: &input.emoji_tags,
mentions: &mention_refs,
mention_refs: input.mention_tags.as_deref(),
},
input.suppress_link_previews,
)?;
submit_event(builder, &state).await?;
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/egress_guard_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() {
&[],
&[],
&[],
None,
&crate::relay::relay_api_base_url(),
)
.unwrap();
Expand All @@ -181,6 +182,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() {
&[],
&[],
&[],
None,
&crate::relay::relay_api_base_url(),
)
.unwrap();
Expand Down
168 changes: 84 additions & 84 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST};
use nostr::{EventBuilder, EventId, Kind, Tag};
use uuid::Uuid;

mod message_tags;

use message_tags::{
append_client_tags, append_sent_from_thread_tag, emoji_tags, imeta_tags, mention_reference_tags,
};
// ── Constants ────────────────────────────────────────────────────────────────

/// Maximum content size — matches buzz-sdk (64 KiB).
Expand Down Expand Up @@ -74,56 +80,6 @@ fn mention_tags(mentions: &[&str]) -> Result<Vec<Tag>, String> {
Ok(tags)
}

fn mention_reference_tags(mentions: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), String> {
for mention in mentions {
if mention.first().map(String::as_str) != Some("mention") {
return Err(format!(
"mention reference tags must use 'mention' prefix (got {:?})",
mention.first()
));
}
let Some(pubkey) = mention.get(1) else {
return Err("mention reference tag missing pubkey".into());
};
check_pubkey(pubkey)?;
tags.push(tag(vec!["mention", &pubkey.to_ascii_lowercase()])?);
}
Ok(())
}

/// Validate and append imeta tags. Rejects any tag whose first element is not "imeta"
/// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags).
fn imeta_tags(media_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), String> {
for mt in media_tags {
if mt.first().map(String::as_str) != Some("imeta") {
return Err(format!(
"media tags must use 'imeta' prefix (got {:?})",
mt.first()
));
}
let parts: Vec<&str> = mt.iter().map(String::as_str).collect();
tags.push(Tag::parse(parts).map_err(|e| format!("invalid imeta tag: {e}"))?);
}
Ok(())
}

/// Validate and append NIP-30 custom-emoji tags. Mirrors `imeta_tags`: rejects
/// any tag whose first element is not "emoji" so this path can't be used to
/// smuggle forged "h"/"e"/"p" tags. Each tag is `["emoji", shortcode, url]`.
fn emoji_tags(emoji_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), String> {
for et in emoji_tags {
if et.first().map(String::as_str) != Some("emoji") {
return Err(format!(
"emoji tags must use 'emoji' prefix (got {:?})",
et.first()
));
}
let parts: Vec<&str> = et.iter().map(String::as_str).collect();
tags.push(Tag::parse(parts).map_err(|e| format!("invalid emoji tag: {e}"))?);
}
Ok(())
}

/// Validate a hex pubkey is exactly 64 hex characters.
fn check_pubkey(pubkey: &str) -> Result<(), String> {
if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
Expand Down Expand Up @@ -302,6 +258,7 @@ pub fn build_message(
custom_emoji_tags: &[Vec<String>],
mention_ref_tags: &[Vec<String>],
link_preview_tags: &[Vec<String>],
sent_from_thread_tag: Option<&[String]>,
relay_base: &str,
) -> Result<EventBuilder, String> {
build_message_with_client_tags(
Expand All @@ -313,6 +270,7 @@ pub fn build_message(
custom_emoji_tags,
mention_ref_tags,
link_preview_tags,
sent_from_thread_tag,
relay_base,
&[],
)
Expand All @@ -333,9 +291,13 @@ pub fn build_message_with_client_tags(
custom_emoji_tags: &[Vec<String>],
mention_ref_tags: &[Vec<String>],
link_preview_tags: &[Vec<String>],
sent_from_thread_tag: Option<&[String]>,
relay_base: &str,
client_tags: &[Vec<String>],
) -> Result<EventBuilder, String> {
if sent_from_thread_tag.is_some() && thread_ref.is_some() {
return Err("sent-from-thread provenance requires a top-level message".into());
}
check_content(content)?;
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
if let Some(tr) = thread_ref {
Expand All @@ -346,27 +308,11 @@ pub fn build_message_with_client_tags(
emoji_tags(custom_emoji_tags, &mut tags)?;
mention_reference_tags(mention_ref_tags, &mut tags)?;
crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?;
append_sent_from_thread_tag(sent_from_thread_tag, &mut tags)?;
append_client_tags(client_tags, &mut tags)?;
Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags))
}

fn append_client_tags(client_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), String> {
for client_tag in client_tags {
if client_tag.first().map(String::as_str) != Some("client") {
return Err(format!(
"client tags must use 'client' prefix (got {:?})",
client_tag.first()
));
}
if client_tag.len() < 2 {
return Err("client tag missing marker".into());
}
let parts: Vec<&str> = client_tag.iter().map(String::as_str).collect();
tags.push(Tag::parse(parts).map_err(|e| format!("invalid client tag: {e}"))?);
}
Ok(())
}

/// Kind 45001 — forum post.
pub fn build_forum_post(
channel_id: Uuid,
Expand Down Expand Up @@ -401,25 +347,34 @@ pub fn build_forum_comment(
Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags))
}

pub struct MessageEditTags<'a> {
pub media: &'a [Vec<String>],
pub custom_emoji: &'a [Vec<String>],
pub mentions: &'a [&'a str],
pub mention_refs: Option<&'a [Vec<String>]>,
}

/// Kind 40003 — edit a message with full content, media, emoji, mentions,
/// and optional monotonic link-preview suppression.
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],
edit_tags: MessageEditTags<'_>,
suppress_link_previews: bool,
) -> 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)?;
tags.extend(mention_tags(edit_tags.mentions)?);
imeta_tags(edit_tags.media, &mut tags)?;
emoji_tags(edit_tags.custom_emoji, &mut tags)?;
if let Some(mention_refs) = edit_tags.mention_refs {
mention_reference_tags(mention_refs, &mut tags)?;
tags.push(tag(vec!["buzz:mention-snapshot"])?);
}
if suppress_link_previews {
tags.push(tag(vec!["link-preview", "none"])?);
}
Expand Down Expand Up @@ -930,25 +885,35 @@ mod tests {
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>> {
edit_tags_with_refs(mentions, Some(&[]))
}

fn edit_tags_with_refs(
mentions: &[&str],
mention_refs: Option<&[Vec<String>]>,
) -> 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, false).unwrap();
let builder = build_message_edit(
channel,
target,
"hi @alice",
MessageEditTags {
media: &[],
custom_emoji: &[],
mentions,
mention_refs,
},
false,
)
.unwrap();
let secret = nostr::SecretKey::from_hex(
"0000000000000000000000000000000000000000000000000000000000000003",
)
Expand All @@ -962,7 +927,6 @@ mod tests {
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()]);
}

Expand All @@ -979,6 +943,42 @@ mod tests {
);
}

#[test]
fn edit_emits_full_mention_reference_snapshot() {
let tags = edit_tags_with_refs(&[], Some(&[vec!["mention".into(), ALICE_HEX.into()]]));
assert!(
tags.iter().any(|tag| tag == &["mention", ALICE_HEX]),
"stable mention reference must be present: {tags:?}"
);
assert!(
tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]),
"snapshot marker must be present: {tags:?}"
);
}

#[test]
fn empty_edit_mention_snapshot_is_explicit() {
let tags = edit_tags_with_refs(&[], Some(&[]));
assert!(
tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]),
"empty snapshot must still clear stale references: {tags:?}"
);
assert!(!tags
.iter()
.any(|tag| tag.first().map(String::as_str) == Some("mention")));
}

#[test]
fn partial_edit_omits_mention_snapshot() {
let tags = edit_tags_with_refs(&[], None);
assert!(!tags
.iter()
.any(|tag| tag.first().map(String::as_str) == Some("mention")));
assert!(!tags
.iter()
.any(|tag| tag.first().map(String::as_str) == Some("buzz:mention-snapshot")));
}

#[test]
fn edit_mentions_are_deduped_and_lowercased() {
let alice_upper = ALICE_HEX.to_ascii_uppercase();
Expand Down
Loading
Loading