Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 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
6 changes: 6 additions & 0 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
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
80 changes: 13 additions & 67 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
140 changes: 140 additions & 0 deletions desktop/src-tauri/src/events/message_tags.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use nostr::{EventId, Tag};

use super::check_pubkey;

const MAX_THREAD_ROOT_EXCERPT_CHARS: usize = 64;
const SENT_FROM_THREAD_TAG: &str = "buzz:sent-from-thread";

pub(super) 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::parse(vec!["mention", &pubkey.to_ascii_lowercase()])
.map_err(|error| format!("invalid mention reference tag: {error}"))?,
);
}
Ok(())
}

pub(super) fn append_sent_from_thread_tag(
source_tag: Option<&[String]>,
tags: &mut Vec<Tag>,
) -> Result<(), String> {
let Some(source_tag) = source_tag else {
return Ok(());
};
if !matches!(source_tag.len(), 2 | 3)
|| source_tag.first().map(String::as_str) != Some(SENT_FROM_THREAD_TAG)
{
return Err("invalid sent-from-thread tag shape".into());
}

EventId::from_hex(source_tag[1].trim())
.map_err(|_| "sent-from-thread tag has invalid root event ID")?;

if let Some(excerpt) = source_tag.get(2) {
if excerpt.trim().is_empty()
|| excerpt.chars().count() > MAX_THREAD_ROOT_EXCERPT_CHARS
|| excerpt.chars().any(char::is_control)
{
return Err("sent-from-thread tag has invalid root excerpt".into());
}
}

let parts: Vec<&str> = source_tag.iter().map(String::as_str).collect();
tags.push(Tag::parse(parts).map_err(|e| format!("invalid sent-from-thread tag: {e}"))?);
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).
pub(super) fn imeta_tags(media_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), String> {
for media_tag in media_tags {
if media_tag.first().map(String::as_str) != Some("imeta") {
return Err(format!(
"media tags must use 'imeta' prefix (got {:?})",
media_tag.first()
));
}
let parts: Vec<&str> = media_tag.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]`.
pub(super) fn emoji_tags(emoji_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), String> {
for emoji_tag in emoji_tags {
if emoji_tag.first().map(String::as_str) != Some("emoji") {
return Err(format!(
"emoji tags must use 'emoji' prefix (got {:?})",
emoji_tag.first()
));
}
let parts: Vec<&str> = emoji_tag.iter().map(String::as_str).collect();
tags.push(Tag::parse(parts).map_err(|e| format!("invalid emoji tag: {e}"))?);
}
Ok(())
}

pub(super) 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(())
}

#[cfg(test)]
mod tests {
use super::*;

const ROOT_HEX: &str = "d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1";

#[test]
fn message_accepts_only_valid_sent_from_thread_provenance() {
let source_tag = vec![
SENT_FROM_THREAD_TAG.to_string(),
ROOT_HEX.to_string(),
"Root message excerpt".to_string(),
];
let mut tags = Vec::new();
append_sent_from_thread_tag(Some(&source_tag), &mut tags).unwrap();
assert_eq!(tags[0].as_slice(), source_tag);

let forged_channel_tag = vec!["h".to_string(), "channel-id".to_string()];
assert!(append_sent_from_thread_tag(Some(&forged_channel_tag), &mut Vec::new()).is_err());

let invalid_root_tag = vec![
SENT_FROM_THREAD_TAG.to_string(),
"not-an-event-id".to_string(),
];
assert!(append_sent_from_thread_tag(Some(&invalid_root_tag), &mut Vec::new()).is_err());
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/huddle/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ pub(crate) fn spawn_transcription_task(
&[],
&[],
&[],
None,
&crate::relay::relay_api_base_url(),
) {
Ok(b) => b,
Expand Down
6 changes: 4 additions & 2 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onResetThreadPanelWidth,
onSelectThreadReplyTarget,
onSendMessage,
onSendToChannel,
onSendVideoReviewComment,
onSendThreadReply,
onThreadScrollTargetResolved,
Expand Down Expand Up @@ -295,9 +296,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onEdit(target);
return true;
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);

const timeoutState = useTimeoutState();

// A moderation DM (1:1 with the relay identity) is read-only for the member;
// only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` →
// ordinary DM, composer enabled.
Expand Down Expand Up @@ -874,6 +873,9 @@ export const ChannelPane = React.memo(function ChannelPane({
onExpandReplies={onExpandThreadReplies}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={onSendThreadReply}
onSendToChannel={
isComposerDisabled ? undefined : onSendToChannel
}
onScrollTargetResolved={() => resolveScrollTarget()}
onScrollTargetSettled={resolveScrollTarget}
onToggleReaction={onToggleReaction}
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ export type ChannelPaneProps = {
mediaTags?: string[][],
channelId?: string | null,
) => Promise<void>;
onSendToChannel: (
message: TimelineMessage,
threadRoot: TimelineMessage,
channelId: string,
) => Promise<void>;
onSendVideoReviewComment?: (
message: TimelineMessage,
content: string,
Expand Down
Loading