commit 36d87d367542e911bd1c4e0ad647b490d2b6f827
parent f6fbab7e1dc5f27528ced08256ee09cc0cbffc77
Author: triesap <tyson@radroots.org>
Date: Fri, 12 Jun 2026 03:29:37 -0700
events_codec: harden social existing codecs
- preserve post social metadata tags and strict public file metadata boundaries
- enforce strict NIP-22 comment targets without legacy fallback tags
- accept empty NIP-25 reactions while validating event and address targets
- cover Farm Ops isolation, NIP-29 boundaries, and upgraded codec paths with serde_json tests
Diffstat:
16 files changed, 1687 insertions(+), 545 deletions(-)
diff --git a/crates/events/src/comment.rs b/crates/events/src/comment.rs
@@ -1,4 +1,4 @@
-use crate::RadrootsNostrEventRef;
+use crate::social::RadrootsSocialTarget;
#[cfg(not(feature = "std"))]
use alloc::string::String;
@@ -6,7 +6,7 @@ use alloc::string::String;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
pub struct RadrootsComment {
- pub root: RadrootsNostrEventRef,
- pub parent: RadrootsNostrEventRef,
+ pub root: RadrootsSocialTarget,
+ pub parent: RadrootsSocialTarget,
pub content: String,
}
diff --git a/crates/events/src/reaction.rs b/crates/events/src/reaction.rs
@@ -1,4 +1,4 @@
-use crate::RadrootsNostrEventRef;
+use crate::social::RadrootsSocialTarget;
#[cfg(not(feature = "std"))]
use alloc::string::String;
@@ -6,6 +6,6 @@ use alloc::string::String;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
pub struct RadrootsReaction {
- pub root: RadrootsNostrEventRef,
+ pub target: RadrootsSocialTarget,
pub content: String,
}
diff --git a/crates/events_codec/src/comment/decode.rs b/crates/events_codec/src/comment/decode.rs
@@ -7,12 +7,13 @@ use alloc::{
use radroots_events::{
RadrootsNostrEvent,
comment::RadrootsComment,
- kinds::KIND_COMMENT,
+ kinds::{KIND_COMMENT, KIND_POST},
+ social::RadrootsSocialTarget,
tags::{TAG_E_PREV, TAG_E_ROOT},
};
use crate::error::EventParseError;
-use crate::event_ref::{find_event_ref_tag, parse_event_ref_tag, parse_nip10_ref_tags};
+use crate::field_helpers::{parse_address_tag, validate_lowercase_hex_64_tag};
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
const DEFAULT_KIND: u32 = KIND_COMMENT;
@@ -31,22 +32,21 @@ pub fn comment_from_tags(
if content.trim().is_empty() {
return Err(EventParseError::InvalidTag("content"));
}
+ if tags
+ .iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_ROOT))
+ {
+ return Err(EventParseError::InvalidTag(TAG_E_ROOT));
+ }
+ if tags
+ .iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_PREV))
+ {
+ return Err(EventParseError::InvalidTag(TAG_E_PREV));
+ }
- let root = if find_event_ref_tag(tags, "E").is_some() {
- parse_nip10_ref_tags(tags, "E", "P", "K", "A")?
- } else if let Some(root_tag) = find_event_ref_tag(tags, TAG_E_ROOT) {
- parse_event_ref_tag(root_tag, TAG_E_ROOT)?
- } else {
- return Err(EventParseError::MissingTag("E"));
- };
-
- let parent = if find_event_ref_tag(tags, "e").is_some() {
- parse_nip10_ref_tags(tags, "e", "p", "k", "a")?
- } else if let Some(tag) = find_event_ref_tag(tags, TAG_E_PREV) {
- parse_event_ref_tag(tag, TAG_E_PREV)?
- } else {
- root.clone()
- };
+ let root = parse_comment_target(tags, CommentTargetTags::root())?;
+ let parent = parse_comment_target(tags, CommentTargetTags::parent())?;
Ok(RadrootsComment {
root,
@@ -55,6 +55,163 @@ pub fn comment_from_tags(
})
}
+struct CommentTargetTags {
+ event: &'static str,
+ address: &'static str,
+ external: &'static str,
+ author: &'static str,
+ kind: &'static str,
+}
+
+impl CommentTargetTags {
+ fn root() -> Self {
+ Self {
+ event: "E",
+ address: "A",
+ external: "I",
+ author: "P",
+ kind: "K",
+ }
+ }
+
+ fn parent() -> Self {
+ Self {
+ event: "e",
+ address: "a",
+ external: "i",
+ author: "p",
+ kind: "k",
+ }
+ }
+}
+
+fn parse_comment_target(
+ tags: &[Vec<String>],
+ keys: CommentTargetTags,
+) -> Result<RadrootsSocialTarget, EventParseError> {
+ let event_tag = find_tag(tags, keys.event);
+ let address_tag = find_tag(tags, keys.address);
+ let external_tag = find_tag(tags, keys.external);
+ let target_count = usize::from(event_tag.is_some())
+ + usize::from(address_tag.is_some())
+ + usize::from(external_tag.is_some());
+ if target_count == 0 {
+ return Err(EventParseError::MissingTag(keys.event));
+ }
+ if target_count > 1 {
+ return Err(EventParseError::InvalidTag(keys.event));
+ }
+
+ if let Some(tag) = event_tag {
+ let id = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(keys.event))?;
+ validate_lowercase_hex_64_tag(&id, keys.event)?;
+ let kind = required_numeric_kind(tags, keys.kind)?;
+ validate_comment_target_kind(kind, keys.kind)?;
+ let author = required_author(tags, keys.author)?;
+ let relays = if tag.len() > 2 {
+ Some(tag[2..].to_vec())
+ } else {
+ None
+ };
+ return Ok(RadrootsSocialTarget::Event {
+ id,
+ author: Some(author),
+ event_kind: Some(kind),
+ relays,
+ });
+ }
+
+ if let Some(tag) = address_tag {
+ let value = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(keys.address))?;
+ let address = parse_address_tag(&value, keys.address)?;
+ let kind = required_numeric_kind(tags, keys.kind)?;
+ if kind != address.kind {
+ return Err(EventParseError::InvalidTag(keys.kind));
+ }
+ validate_comment_target_kind(kind, keys.kind)?;
+ let author = required_author(tags, keys.author)?;
+ if author != address.pubkey {
+ return Err(EventParseError::InvalidTag(keys.author));
+ }
+ let relays = if tag.len() > 2 {
+ Some(tag[2..].to_vec())
+ } else {
+ None
+ };
+ return Ok(RadrootsSocialTarget::Address {
+ address: value,
+ author: Some(author),
+ event_kind: Some(kind),
+ relays,
+ });
+ }
+
+ let Some(tag) = external_tag else {
+ return Err(EventParseError::MissingTag(keys.external));
+ };
+ let id = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(keys.external))?;
+ if id.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(keys.external));
+ }
+ let external_kind = required_kind_value(tags, keys.kind)?;
+ let hint = tag.get(2).filter(|value| !value.trim().is_empty()).cloned();
+ Ok(RadrootsSocialTarget::External {
+ id,
+ external_kind,
+ hint,
+ })
+}
+
+fn find_tag<'a>(tags: &'a [Vec<String>], key: &'static str) -> Option<&'a Vec<String>> {
+ tags.iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
+}
+
+fn required_author(tags: &[Vec<String>], key: &'static str) -> Result<String, EventParseError> {
+ let value = find_tag(tags, key)
+ .and_then(|tag| tag.get(1))
+ .cloned()
+ .ok_or(EventParseError::MissingTag(key))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(key));
+ }
+ Ok(value)
+}
+
+fn required_kind_value(tags: &[Vec<String>], key: &'static str) -> Result<String, EventParseError> {
+ let value = find_tag(tags, key)
+ .and_then(|tag| tag.get(1))
+ .cloned()
+ .ok_or(EventParseError::MissingTag(key))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(key));
+ }
+ Ok(value)
+}
+
+fn required_numeric_kind(tags: &[Vec<String>], key: &'static str) -> Result<u32, EventParseError> {
+ required_kind_value(tags, key)?
+ .parse::<u32>()
+ .map_err(|err| EventParseError::InvalidNumber(key, err))
+}
+
+fn validate_comment_target_kind(kind: u32, key: &'static str) -> Result<(), EventParseError> {
+ if kind == KIND_POST {
+ Err(EventParseError::InvalidTag(key))
+ } else {
+ Ok(())
+ }
+}
+
pub fn data_from_event(
id: String,
author: String,
diff --git a/crates/events_codec/src/comment/encode.rs b/crates/events_codec/src/comment/encode.rs
@@ -1,47 +1,28 @@
#[cfg(not(feature = "std"))]
-use alloc::{string::String, vec::Vec};
+use alloc::{
+ format,
+ string::{String, ToString},
+ vec::Vec,
+};
-use radroots_events::{RadrootsNostrEventRef, comment::RadrootsComment};
+use radroots_events::{
+ comment::RadrootsComment,
+ kinds::{KIND_COMMENT, KIND_POST},
+ social::RadrootsSocialTarget,
+};
use crate::error::EventEncodeError;
-use crate::event_ref::push_nip10_ref_tags;
+use crate::field_helpers::{
+ parse_address_tag, validate_lowercase_hex_64, validate_non_empty_field,
+};
use crate::wire::WireEventParts;
-use radroots_events::kinds::KIND_COMMENT;
const DEFAULT_KIND: u32 = KIND_COMMENT;
-fn validate_ref(
- event: &RadrootsNostrEventRef,
- id_label: &'static str,
- author_label: &'static str,
-) -> Result<(), EventEncodeError> {
- if event.id.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField(id_label));
- }
- if event.author.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField(author_label));
- }
- Ok(())
-}
-
pub fn comment_build_tags(comment: &RadrootsComment) -> Result<Vec<Vec<String>>, EventEncodeError> {
- validate_ref(&comment.root, "root.id", "root.author")?;
- validate_ref(&comment.parent, "parent.id", "parent.author")?;
-
- let root_has_addr = comment
- .root
- .d_tag
- .as_deref()
- .map_or(false, |v| !v.is_empty());
- let parent_has_addr = comment
- .parent
- .d_tag
- .as_deref()
- .map_or(false, |v| !v.is_empty());
- let mut tags =
- Vec::with_capacity(6 + usize::from(root_has_addr) + usize::from(parent_has_addr));
- push_nip10_ref_tags(&mut tags, &comment.root, "E", "P", "K", "A");
- push_nip10_ref_tags(&mut tags, &comment.parent, "e", "p", "k", "a");
+ let mut tags = Vec::with_capacity(8);
+ push_comment_target(&mut tags, &comment.root, CommentTargetTags::root())?;
+ push_comment_target(&mut tags, &comment.parent, CommentTargetTags::parent())?;
Ok(tags)
}
@@ -63,3 +44,125 @@ pub fn to_wire_parts_with_kind(
tags,
})
}
+
+struct CommentTargetTags {
+ event: &'static str,
+ address: &'static str,
+ external: &'static str,
+ author: &'static str,
+ kind: &'static str,
+ field: &'static str,
+}
+
+impl CommentTargetTags {
+ fn root() -> Self {
+ Self {
+ event: "E",
+ address: "A",
+ external: "I",
+ author: "P",
+ kind: "K",
+ field: "root",
+ }
+ }
+
+ fn parent() -> Self {
+ Self {
+ event: "e",
+ address: "a",
+ external: "i",
+ author: "p",
+ kind: "k",
+ field: "parent",
+ }
+ }
+}
+
+fn push_comment_target(
+ tags: &mut Vec<Vec<String>>,
+ target: &RadrootsSocialTarget,
+ keys: CommentTargetTags,
+) -> Result<(), EventEncodeError> {
+ match target {
+ RadrootsSocialTarget::Event {
+ id,
+ author,
+ event_kind,
+ relays,
+ } => {
+ validate_lowercase_hex_64(id, keys.field)?;
+ let author = author
+ .as_deref()
+ .ok_or(EventEncodeError::EmptyRequiredField(keys.field))?;
+ validate_non_empty_field(author, keys.field)?;
+ let kind = event_kind.ok_or(EventEncodeError::EmptyRequiredField(keys.field))?;
+ validate_comment_target_kind(kind, keys.field)?;
+ let mut event_tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len));
+ event_tag.push(keys.event.to_string());
+ event_tag.push(id.clone());
+ if let Some(relays) = relays {
+ event_tag.extend(relays.iter().cloned());
+ }
+ tags.push(event_tag);
+ tags.push(vec![keys.author.to_string(), author.to_string()]);
+ tags.push(vec![keys.kind.to_string(), kind.to_string()]);
+ }
+ RadrootsSocialTarget::Address {
+ address,
+ author,
+ event_kind,
+ relays,
+ } => {
+ let parsed = parse_address_tag(address, keys.field)
+ .map_err(|_| EventEncodeError::InvalidField(keys.field))?;
+ validate_comment_target_kind(parsed.kind, keys.field)?;
+ if let Some(kind) = event_kind {
+ if *kind != parsed.kind {
+ return Err(EventEncodeError::InvalidField(keys.field));
+ }
+ }
+ if let Some(author) = author.as_deref() {
+ if author != parsed.pubkey {
+ return Err(EventEncodeError::InvalidField(keys.field));
+ }
+ }
+ let mut address_tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len));
+ address_tag.push(keys.address.to_string());
+ address_tag.push(format!(
+ "{}:{}:{}",
+ parsed.kind, parsed.pubkey, parsed.d_tag
+ ));
+ if let Some(relays) = relays {
+ address_tag.extend(relays.iter().cloned());
+ }
+ tags.push(address_tag);
+ tags.push(vec![keys.author.to_string(), parsed.pubkey]);
+ tags.push(vec![keys.kind.to_string(), parsed.kind.to_string()]);
+ }
+ RadrootsSocialTarget::External {
+ id,
+ external_kind,
+ hint,
+ } => {
+ validate_non_empty_field(id, keys.field)?;
+ validate_non_empty_field(external_kind, keys.field)?;
+ let mut external_tag = Vec::with_capacity(3);
+ external_tag.push(keys.external.to_string());
+ external_tag.push(id.clone());
+ if let Some(hint) = hint.as_deref().filter(|value| !value.trim().is_empty()) {
+ external_tag.push(hint.to_string());
+ }
+ tags.push(external_tag);
+ tags.push(vec![keys.kind.to_string(), external_kind.clone()]);
+ }
+ }
+ Ok(())
+}
+
+fn validate_comment_target_kind(kind: u32, field: &'static str) -> Result<(), EventEncodeError> {
+ if kind == KIND_POST {
+ Err(EventEncodeError::InvalidField(field))
+ } else {
+ Ok(())
+ }
+}
diff --git a/crates/events_codec/src/farm/decode.rs b/crates/events_codec/src/farm/decode.rs
@@ -45,6 +45,7 @@ pub fn farm_from_event(
return Err(EventParseError::InvalidJson("content"));
}
let d_tag = parse_d_tag(tags)?;
+ reject_private_farm_ops_content(content)?;
let mut farm: RadrootsFarm =
serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?;
@@ -57,6 +58,34 @@ pub fn farm_from_event(
Ok(farm)
}
+fn reject_private_farm_ops_content(content: &str) -> Result<(), EventParseError> {
+ let value: serde_json::Value =
+ serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?;
+ let Some(object) = value.as_object() else {
+ return Err(EventParseError::InvalidJson("content"));
+ };
+ for key in [
+ "workspace",
+ "farm_group_id",
+ "document_id",
+ "document_kind",
+ "crdt_backend",
+ "encoded_change",
+ "semantic_kind",
+ "owner_document_kind",
+ "owner_document_id",
+ "relays",
+ "media_servers",
+ "supported_kinds",
+ "protocol_version",
+ ] {
+ if object.contains_key(key) {
+ return Err(EventParseError::InvalidJson("content"));
+ }
+ }
+ Ok(())
+}
+
pub fn data_from_event(
id: String,
author: String,
diff --git a/crates/events_codec/src/file_metadata/decode.rs b/crates/events_codec/src/file_metadata/decode.rs
@@ -20,6 +20,7 @@ use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
use crate::social_helpers::{first_tag_value, parse_dimensions_tag};
const EXPECTED_KIND: &str = "1063";
+const TAG_RADROOTS_OWNER_DOCUMENT: &str = "radroots:owner_document";
pub fn file_metadata_from_event(
kind: u32,
@@ -32,6 +33,7 @@ pub fn file_metadata_from_event(
got: kind,
});
}
+ reject_private_farm_file_tags(tags)?;
let url = required_tag_value(tags, TAG_URL)?;
let mime_type = required_tag_value(tags, TAG_MIME)?;
let sha256 = required_tag_value(tags, TAG_SHA256)?;
@@ -71,6 +73,17 @@ pub fn file_metadata_from_event(
})
}
+fn reject_private_farm_file_tags(tags: &[Vec<String>]) -> Result<(), EventParseError> {
+ if tags
+ .iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_OWNER_DOCUMENT))
+ {
+ Err(EventParseError::InvalidTag(TAG_RADROOTS_OWNER_DOCUMENT))
+ } else {
+ Ok(())
+ }
+}
+
pub fn data_from_event(
id: String,
author: String,
diff --git a/crates/events_codec/src/post/decode.rs b/crates/events_codec/src/post/decode.rs
@@ -4,10 +4,19 @@ use alloc::{
vec::Vec,
};
-use radroots_events::{RadrootsNostrEvent, kinds::KIND_POST, post::RadrootsPost};
+use radroots_events::{
+ RadrootsNostrEvent,
+ farm::RadrootsFarmRef,
+ kinds::{KIND_FARM, KIND_POST},
+ post::RadrootsPost,
+ social::{RadrootsSocialFarmAnchor, RadrootsSocialMediaMetadata, RadrootsSocialTarget},
+ tags::{TAG_A, TAG_IMETA, TAG_Q, TAG_T},
+};
use crate::error::EventParseError;
+use crate::field_helpers::{parse_address_tag, tag_values, validate_lowercase_hex_64_tag};
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
+use crate::social_helpers::{location_from_tags, parse_dimensions_tag};
const DEFAULT_KIND: u32 = KIND_POST;
@@ -32,15 +41,30 @@ pub fn post_from_content(kind: u32, content: &str) -> Result<RadrootsPost, Event
})
}
+pub fn post_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsPost, EventParseError> {
+ let mut post = post_from_content(kind, content)?;
+ post.farm = farm_anchor_from_tags(tags)?;
+ post.address_refs = address_refs_from_tags(tags)?;
+ post.location = location_from_tags(tags);
+ post.topics = non_empty_vec(tag_values(tags, TAG_T)?);
+ post.quote_refs = quote_refs_from_tags(tags)?;
+ post.media = media_from_tags(tags)?;
+ Ok(post)
+}
+
pub fn data_from_event(
id: String,
author: String,
published_at: u32,
kind: u32,
content: String,
- _tags: Vec<Vec<String>>,
+ tags: Vec<Vec<String>>,
) -> Result<RadrootsParsedData<RadrootsPost>, EventParseError> {
- let post = post_from_content(kind, &content)?;
+ let post = post_from_event(kind, &tags, &content)?;
Ok(RadrootsParsedData::new(
id,
author,
@@ -80,3 +104,169 @@ pub fn parsed_from_event(
data,
})
}
+
+fn farm_anchor_from_tags(
+ tags: &[Vec<String>],
+) -> Result<Option<RadrootsSocialFarmAnchor>, EventParseError> {
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
+ {
+ let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_A))?;
+ let address = parse_address_tag(value, TAG_A)?;
+ if address.kind == KIND_FARM {
+ let relays = if tag.len() > 2 {
+ Some(tag[2..].to_vec())
+ } else {
+ None
+ };
+ return Ok(Some(RadrootsSocialFarmAnchor {
+ farm: RadrootsFarmRef {
+ pubkey: address.pubkey,
+ d_tag: address.d_tag,
+ },
+ relays,
+ }));
+ }
+ }
+ Ok(None)
+}
+
+fn address_refs_from_tags(
+ tags: &[Vec<String>],
+) -> Result<Option<Vec<RadrootsSocialTarget>>, EventParseError> {
+ let mut refs = Vec::new();
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
+ {
+ let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_A))?;
+ let address = parse_address_tag(value, TAG_A)?;
+ if address.kind == KIND_FARM {
+ continue;
+ }
+ let relays = if tag.len() > 2 {
+ Some(tag[2..].to_vec())
+ } else {
+ None
+ };
+ refs.push(RadrootsSocialTarget::Address {
+ address: value.clone(),
+ author: Some(address.pubkey),
+ event_kind: Some(address.kind),
+ relays,
+ });
+ }
+ Ok(non_empty_vec(refs))
+}
+
+fn quote_refs_from_tags(
+ tags: &[Vec<String>],
+) -> Result<Option<Vec<RadrootsSocialTarget>>, EventParseError> {
+ let mut refs = Vec::new();
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_Q))
+ {
+ let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_Q))?;
+ let relays = if tag.len() > 2 {
+ Some(tag[2..].to_vec())
+ } else {
+ None
+ };
+ match parse_address_tag(value, TAG_Q) {
+ Ok(address) => refs.push(RadrootsSocialTarget::Address {
+ address: value.clone(),
+ author: Some(address.pubkey),
+ event_kind: Some(address.kind),
+ relays,
+ }),
+ Err(_) => {
+ validate_lowercase_hex_64_tag(value, TAG_Q)?;
+ refs.push(RadrootsSocialTarget::Event {
+ id: value.clone(),
+ author: None,
+ event_kind: None,
+ relays,
+ });
+ }
+ }
+ }
+ Ok(non_empty_vec(refs))
+}
+
+fn media_from_tags(
+ tags: &[Vec<String>],
+) -> Result<Option<Vec<RadrootsSocialMediaMetadata>>, EventParseError> {
+ let mut media = Vec::new();
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_IMETA))
+ {
+ if tag.len() < 2 {
+ return Err(EventParseError::InvalidTag(TAG_IMETA));
+ }
+ let raw = tag[1..].to_vec();
+ if raw.iter().any(|value| value.trim().is_empty()) {
+ return Err(EventParseError::InvalidTag(TAG_IMETA));
+ }
+ let mut item = RadrootsSocialMediaMetadata {
+ imeta: Some(vec![raw.clone()]),
+ ..RadrootsSocialMediaMetadata::default()
+ };
+ for entry in raw {
+ parse_imeta_entry(&mut item, &entry)?;
+ }
+ media.push(item);
+ }
+ Ok(non_empty_vec(media))
+}
+
+fn parse_imeta_entry(
+ item: &mut RadrootsSocialMediaMetadata,
+ entry: &str,
+) -> Result<(), EventParseError> {
+ let Some((key, value)) = entry.split_once(' ') else {
+ return Err(EventParseError::InvalidTag(TAG_IMETA));
+ };
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_IMETA));
+ }
+ match key {
+ "url" => item.url = Some(value.to_string()),
+ "m" => item.mime_type = Some(value.to_string()),
+ "x" => item.sha256 = Some(value.to_string()),
+ "ox" => item.original_sha256 = Some(value.to_string()),
+ "size" => {
+ item.size = Some(
+ value
+ .parse::<u64>()
+ .map_err(|err| EventParseError::InvalidNumber(TAG_IMETA, err))?,
+ );
+ }
+ "dim" => item.dimensions = Some(parse_dimensions_tag(value, TAG_IMETA)?),
+ "blurhash" => item.blurhash = Some(value.to_string()),
+ "image" => item.image = Some(value.to_string()),
+ "summary" => item.summary = Some(value.to_string()),
+ "alt" => item.alt = Some(value.to_string()),
+ "fallback" => item.fallback = Some(value.to_string()),
+ "magnet" => item.magnet = Some(value.to_string()),
+ "i" => push_repeated_value(&mut item.content_hashes, value),
+ "service" => push_repeated_value(&mut item.services, value),
+ "thumb" => {}
+ _ => {}
+ }
+ Ok(())
+}
+
+fn push_repeated_value(values: &mut Option<Vec<String>>, value: &str) {
+ values.get_or_insert_with(Vec::new).push(value.to_string());
+}
+
+fn non_empty_vec<T>(values: Vec<T>) -> Option<Vec<T>> {
+ if values.is_empty() {
+ None
+ } else {
+ Some(values)
+ }
+}
diff --git a/crates/events_codec/src/post/encode.rs b/crates/events_codec/src/post/encode.rs
@@ -1,14 +1,58 @@
#[cfg(not(feature = "std"))]
-use alloc::vec::Vec;
+use alloc::{
+ format,
+ string::{String, ToString},
+ vec,
+ vec::Vec,
+};
-use radroots_events::post::RadrootsPost;
+use radroots_events::{
+ kinds::{KIND_FARM, KIND_POST},
+ post::RadrootsPost,
+ social::{RadrootsSocialFarmAnchor, RadrootsSocialMediaMetadata, RadrootsSocialTarget},
+ tags::{TAG_A, TAG_IMETA, TAG_Q, TAG_T},
+};
use crate::error::EventEncodeError;
+use crate::field_helpers::{parse_address_tag, validate_lowercase_hex_64};
+use crate::social_helpers::{dimensions_tag, push_location_tags};
use crate::wire::WireEventParts;
-use radroots_events::kinds::KIND_POST;
const DEFAULT_KIND: u32 = KIND_POST;
+pub fn post_build_tags(post: &RadrootsPost) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ let mut tags = Vec::new();
+ if let Some(farm) = post.farm.as_ref() {
+ push_farm_anchor(&mut tags, farm)?;
+ }
+ if let Some(refs) = post.address_refs.as_ref() {
+ for target in refs {
+ push_address_ref(&mut tags, target)?;
+ }
+ }
+ if let Some(location) = post.location.as_ref() {
+ push_location_tags(&mut tags, location);
+ }
+ if let Some(topics) = post.topics.as_ref() {
+ for topic in topics {
+ if !topic.trim().is_empty() {
+ tags.push(vec![TAG_T.to_string(), topic.clone()]);
+ }
+ }
+ }
+ if let Some(quote_refs) = post.quote_refs.as_ref() {
+ for target in quote_refs {
+ push_quote_ref(&mut tags, target)?;
+ }
+ }
+ if let Some(media) = post.media.as_ref() {
+ for item in media {
+ push_media_tags(&mut tags, item)?;
+ }
+ }
+ Ok(tags)
+}
+
pub fn to_wire_parts(post: &RadrootsPost) -> Result<WireEventParts, EventEncodeError> {
to_wire_parts_with_kind(post, DEFAULT_KIND)
}
@@ -20,9 +64,188 @@ pub fn to_wire_parts_with_kind(
if post.content.trim().is_empty() {
return Err(EventEncodeError::EmptyRequiredField("content"));
}
+ let tags = post_build_tags(post)?;
Ok(WireEventParts {
kind,
content: post.content.clone(),
- tags: Vec::new(),
+ tags,
})
}
+
+fn push_farm_anchor(
+ tags: &mut Vec<Vec<String>>,
+ farm: &RadrootsSocialFarmAnchor,
+) -> Result<(), EventEncodeError> {
+ if farm.farm.pubkey.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("farm.pubkey"));
+ }
+ if farm.farm.d_tag.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("farm.d_tag"));
+ }
+ let address = format!("{}:{}:{}", KIND_FARM, farm.farm.pubkey, farm.farm.d_tag);
+ parse_address_tag(&address, "farm").map_err(|_| EventEncodeError::InvalidField("farm"))?;
+ let mut tag = Vec::with_capacity(2 + farm.relays.as_ref().map_or(0, Vec::len));
+ tag.push(TAG_A.to_string());
+ tag.push(address);
+ if let Some(relays) = farm.relays.as_ref() {
+ tag.extend(relays.iter().cloned());
+ }
+ tags.push(tag);
+ Ok(())
+}
+
+fn push_address_ref(
+ tags: &mut Vec<Vec<String>>,
+ target: &RadrootsSocialTarget,
+) -> Result<(), EventEncodeError> {
+ let RadrootsSocialTarget::Address {
+ address,
+ author,
+ event_kind,
+ relays,
+ } = target
+ else {
+ return Err(EventEncodeError::InvalidField("address_refs"));
+ };
+ let parsed = parse_address_tag(address, "address_refs")
+ .map_err(|_| EventEncodeError::InvalidField("address_refs"))?;
+ if parsed.kind == KIND_FARM {
+ return Err(EventEncodeError::InvalidField("address_refs"));
+ }
+ if let Some(kind) = event_kind {
+ if *kind != parsed.kind {
+ return Err(EventEncodeError::InvalidField("address_refs"));
+ }
+ }
+ if let Some(author) = author.as_deref() {
+ if author != parsed.pubkey {
+ return Err(EventEncodeError::InvalidField("address_refs"));
+ }
+ }
+ let mut tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len));
+ tag.push(TAG_A.to_string());
+ tag.push(format!(
+ "{}:{}:{}",
+ parsed.kind, parsed.pubkey, parsed.d_tag
+ ));
+ if let Some(relays) = relays {
+ tag.extend(relays.iter().cloned());
+ }
+ tags.push(tag);
+ Ok(())
+}
+
+fn push_quote_ref(
+ tags: &mut Vec<Vec<String>>,
+ target: &RadrootsSocialTarget,
+) -> Result<(), EventEncodeError> {
+ match target {
+ RadrootsSocialTarget::Event { id, relays, .. } => {
+ validate_lowercase_hex_64(id, "quote_refs")?;
+ let mut tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len));
+ tag.push(TAG_Q.to_string());
+ tag.push(id.clone());
+ if let Some(relays) = relays {
+ tag.extend(relays.iter().cloned());
+ }
+ tags.push(tag);
+ Ok(())
+ }
+ RadrootsSocialTarget::Address {
+ address,
+ event_kind,
+ relays,
+ ..
+ } => {
+ let parsed = parse_address_tag(address, "quote_refs")
+ .map_err(|_| EventEncodeError::InvalidField("quote_refs"))?;
+ if let Some(kind) = event_kind {
+ if *kind != parsed.kind {
+ return Err(EventEncodeError::InvalidField("quote_refs"));
+ }
+ }
+ let mut tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len));
+ tag.push(TAG_Q.to_string());
+ tag.push(format!(
+ "{}:{}:{}",
+ parsed.kind, parsed.pubkey, parsed.d_tag
+ ));
+ if let Some(relays) = relays {
+ tag.extend(relays.iter().cloned());
+ }
+ tags.push(tag);
+ Ok(())
+ }
+ RadrootsSocialTarget::External { .. } => Err(EventEncodeError::InvalidField("quote_refs")),
+ }
+}
+
+fn push_media_tags(
+ tags: &mut Vec<Vec<String>>,
+ media: &RadrootsSocialMediaMetadata,
+) -> Result<(), EventEncodeError> {
+ if let Some(raw_tags) = media.imeta.as_ref() {
+ for raw in raw_tags {
+ if raw.is_empty() || raw.iter().any(|value| value.trim().is_empty()) {
+ return Err(EventEncodeError::InvalidField("imeta"));
+ }
+ let mut tag = Vec::with_capacity(1 + raw.len());
+ tag.push(TAG_IMETA.to_string());
+ tag.extend(raw.iter().cloned());
+ tags.push(tag);
+ }
+ return Ok(());
+ }
+
+ let mut fields = Vec::new();
+ push_imeta_field(&mut fields, "url", media.url.as_deref());
+ push_imeta_field(&mut fields, "m", media.mime_type.as_deref());
+ push_imeta_field(&mut fields, "x", media.sha256.as_deref());
+ push_imeta_field(&mut fields, "ox", media.original_sha256.as_deref());
+ if let Some(size) = media.size {
+ fields.push(format!("size {size}"));
+ }
+ if let Some(dimensions) = media.dimensions.as_ref() {
+ fields.push(format!("dim {}", dimensions_tag(dimensions)));
+ }
+ push_imeta_field(&mut fields, "blurhash", media.blurhash.as_deref());
+ if let Some(thumbnails) = media.thumbnails.as_ref() {
+ for thumbnail in thumbnails {
+ if thumbnail.url.trim().is_empty() {
+ return Err(EventEncodeError::InvalidField("imeta"));
+ }
+ fields.push(format!("thumb {}", thumbnail.url));
+ if let Some(dimensions) = thumbnail.dimensions.as_ref() {
+ fields.push(format!("dim {}", dimensions_tag(dimensions)));
+ }
+ }
+ }
+ push_imeta_field(&mut fields, "image", media.image.as_deref());
+ push_imeta_field(&mut fields, "summary", media.summary.as_deref());
+ push_imeta_field(&mut fields, "alt", media.alt.as_deref());
+ push_imeta_field(&mut fields, "fallback", media.fallback.as_deref());
+ push_imeta_field(&mut fields, "magnet", media.magnet.as_deref());
+ if let Some(values) = media.content_hashes.as_ref() {
+ for value in values {
+ push_imeta_field(&mut fields, "i", Some(value.as_str()));
+ }
+ }
+ if let Some(values) = media.services.as_ref() {
+ for value in values {
+ push_imeta_field(&mut fields, "service", Some(value.as_str()));
+ }
+ }
+ if !fields.is_empty() {
+ let mut tag = Vec::with_capacity(1 + fields.len());
+ tag.push(TAG_IMETA.to_string());
+ tag.extend(fields);
+ tags.push(tag);
+ }
+ Ok(())
+}
+
+fn push_imeta_field(fields: &mut Vec<String>, key: &str, value: Option<&str>) {
+ if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
+ fields.push(format!("{key} {value}"));
+ }
+}
diff --git a/crates/events_codec/src/reaction/decode.rs b/crates/events_codec/src/reaction/decode.rs
@@ -5,11 +5,12 @@ use alloc::{
};
use radroots_events::{
- RadrootsNostrEvent, kinds::KIND_REACTION, reaction::RadrootsReaction, tags::TAG_E_ROOT,
+ RadrootsNostrEvent, kinds::KIND_REACTION, reaction::RadrootsReaction,
+ social::RadrootsSocialTarget, tags::TAG_E_ROOT,
};
use crate::error::EventParseError;
-use crate::event_ref::{find_event_ref_tag, parse_event_ref_tag, parse_nip10_ref_tags};
+use crate::field_helpers::{parse_address_tag, validate_lowercase_hex_64_tag};
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
const DEFAULT_KIND: u32 = KIND_REACTION;
@@ -25,18 +26,15 @@ pub fn reaction_from_tags(
got: kind,
});
}
- if content.trim().is_empty() {
- return Err(EventParseError::InvalidTag("content"));
+ if tags
+ .iter()
+ .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_ROOT))
+ {
+ return Err(EventParseError::InvalidTag(TAG_E_ROOT));
}
- let root = if find_event_ref_tag(tags, "e").is_some() {
- parse_nip10_ref_tags(tags, "e", "p", "k", "a")?
- } else if let Some(root_tag) = find_event_ref_tag(tags, TAG_E_ROOT) {
- parse_event_ref_tag(root_tag, TAG_E_ROOT)?
- } else {
- return Err(EventParseError::MissingTag("e"));
- };
+ let target = parse_reaction_target(tags)?;
Ok(RadrootsReaction {
- root,
+ target,
content: content.to_string(),
})
}
@@ -89,3 +87,91 @@ pub fn parsed_from_event(
data,
})
}
+
+fn parse_reaction_target(tags: &[Vec<String>]) -> Result<RadrootsSocialTarget, EventParseError> {
+ let event_tag = find_tag(tags, "e");
+ let address_tag = find_tag(tags, "a");
+ match (event_tag, address_tag) {
+ (Some(_), Some(_)) => Err(EventParseError::InvalidTag("e")),
+ (None, None) => Err(EventParseError::MissingTag("e")),
+ (Some(tag), None) => {
+ let id = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag("e"))?;
+ validate_lowercase_hex_64_tag(&id, "e")?;
+ let relays = if tag.len() > 2 {
+ Some(tag[2..].to_vec())
+ } else {
+ None
+ };
+ Ok(RadrootsSocialTarget::Event {
+ id,
+ author: optional_tag_value(tags, "p")?,
+ event_kind: optional_numeric_tag(tags, "k")?,
+ relays,
+ })
+ }
+ (None, Some(tag)) => {
+ let value = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag("a"))?;
+ let address = parse_address_tag(&value, "a")?;
+ let kind = optional_numeric_tag(tags, "k")?.unwrap_or(address.kind);
+ if kind != address.kind {
+ return Err(EventParseError::InvalidTag("k"));
+ }
+ let author = optional_tag_value(tags, "p")?.unwrap_or_else(|| address.pubkey.clone());
+ if author != address.pubkey {
+ return Err(EventParseError::InvalidTag("p"));
+ }
+ let relays = if tag.len() > 2 {
+ Some(tag[2..].to_vec())
+ } else {
+ None
+ };
+ Ok(RadrootsSocialTarget::Address {
+ address: value,
+ author: Some(author),
+ event_kind: Some(kind),
+ relays,
+ })
+ }
+ }
+}
+
+fn find_tag<'a>(tags: &'a [Vec<String>], key: &'static str) -> Option<&'a Vec<String>> {
+ tags.iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
+}
+
+fn optional_tag_value(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<String>, EventParseError> {
+ let Some(tag) = find_tag(tags, key) else {
+ return Ok(None);
+ };
+ let value = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(key))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(key));
+ }
+ Ok(Some(value))
+}
+
+fn optional_numeric_tag(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<u32>, EventParseError> {
+ optional_tag_value(tags, key)?
+ .map(|value| {
+ value
+ .parse::<u32>()
+ .map_err(|err| EventParseError::InvalidNumber(key, err))
+ })
+ .transpose()
+}
diff --git a/crates/events_codec/src/reaction/encode.rs b/crates/events_codec/src/reaction/encode.rs
@@ -1,36 +1,27 @@
#[cfg(not(feature = "std"))]
-use alloc::{string::String, vec::Vec};
+use alloc::{
+ format,
+ string::{String, ToString},
+ vec::Vec,
+};
-use radroots_events::{RadrootsNostrEventRef, reaction::RadrootsReaction};
+use radroots_events::{
+ kinds::KIND_REACTION, reaction::RadrootsReaction, social::RadrootsSocialTarget,
+};
use crate::error::EventEncodeError;
-use crate::event_ref::push_nip10_ref_tags;
+use crate::field_helpers::{
+ parse_address_tag, validate_lowercase_hex_64, validate_non_empty_field,
+};
use crate::wire::WireEventParts;
-use radroots_events::kinds::KIND_REACTION;
const DEFAULT_KIND: u32 = KIND_REACTION;
-fn validate_ref(event: &RadrootsNostrEventRef) -> Result<(), EventEncodeError> {
- if event.id.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("root.id"));
- }
- if event.author.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("root.author"));
- }
- Ok(())
-}
-
pub fn reaction_build_tags(
reaction: &RadrootsReaction,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- validate_ref(&reaction.root)?;
- let has_addr = reaction
- .root
- .d_tag
- .as_deref()
- .map_or(false, |v| !v.is_empty());
- let mut tags = Vec::with_capacity(3 + usize::from(has_addr));
- push_nip10_ref_tags(&mut tags, &reaction.root, "e", "p", "k", "a");
+ let mut tags = Vec::with_capacity(4);
+ push_reaction_target(&mut tags, &reaction.target)?;
Ok(tags)
}
@@ -42,9 +33,6 @@ pub fn to_wire_parts_with_kind(
reaction: &RadrootsReaction,
kind: u32,
) -> Result<WireEventParts, EventEncodeError> {
- if reaction.content.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("content"));
- }
let tags = reaction_build_tags(reaction)?;
Ok(WireEventParts {
kind,
@@ -52,3 +40,68 @@ pub fn to_wire_parts_with_kind(
tags,
})
}
+
+fn push_reaction_target(
+ tags: &mut Vec<Vec<String>>,
+ target: &RadrootsSocialTarget,
+) -> Result<(), EventEncodeError> {
+ match target {
+ RadrootsSocialTarget::Event {
+ id,
+ author,
+ event_kind,
+ relays,
+ } => {
+ validate_lowercase_hex_64(id, "target.id")?;
+ let mut event_tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len));
+ event_tag.push("e".to_string());
+ event_tag.push(id.clone());
+ if let Some(relays) = relays {
+ event_tag.extend(relays.iter().cloned());
+ }
+ tags.push(event_tag);
+ if let Some(author) = author.as_deref() {
+ validate_non_empty_field(author, "target.author")?;
+ tags.push(vec!["p".to_string(), author.to_string()]);
+ }
+ if let Some(kind) = event_kind {
+ tags.push(vec!["k".to_string(), kind.to_string()]);
+ }
+ }
+ RadrootsSocialTarget::Address {
+ address,
+ author,
+ event_kind,
+ relays,
+ } => {
+ let parsed = parse_address_tag(address, "target.address")
+ .map_err(|_| EventEncodeError::InvalidField("target.address"))?;
+ if let Some(kind) = event_kind {
+ if *kind != parsed.kind {
+ return Err(EventEncodeError::InvalidField("target.kind"));
+ }
+ }
+ if let Some(author) = author.as_deref() {
+ if author != parsed.pubkey {
+ return Err(EventEncodeError::InvalidField("target.author"));
+ }
+ }
+ let mut address_tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len));
+ address_tag.push("a".to_string());
+ address_tag.push(format!(
+ "{}:{}:{}",
+ parsed.kind, parsed.pubkey, parsed.d_tag
+ ));
+ if let Some(relays) = relays {
+ address_tag.extend(relays.iter().cloned());
+ }
+ tags.push(address_tag);
+ tags.push(vec!["p".to_string(), parsed.pubkey]);
+ tags.push(vec!["k".to_string(), parsed.kind.to_string()]);
+ }
+ RadrootsSocialTarget::External { .. } => {
+ return Err(EventEncodeError::InvalidField("target"));
+ }
+ }
+ Ok(())
+}
diff --git a/crates/events_codec/src/tag_builders.rs b/crates/events_codec/src/tag_builders.rs
@@ -35,6 +35,7 @@ use crate::listing::tags::listing_tags;
use crate::message::encode::message_build_tags;
use crate::message_file::encode::message_file_build_tags;
use crate::plot::encode::plot_build_tags;
+use crate::post::encode::post_build_tags;
use crate::reaction::encode::reaction_build_tags;
use crate::resource_area::encode::resource_area_build_tags;
use crate::resource_cap::encode::resource_harvest_cap_build_tags;
@@ -225,9 +226,9 @@ impl RadrootsEventTagBuilder for RadrootsProfile {
}
impl RadrootsEventTagBuilder for RadrootsPost {
- type Error = Infallible;
+ type Error = EventEncodeError;
fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
- Ok(Vec::new())
+ post_build_tags(self)
}
}
diff --git a/crates/events_codec/tests/comment.rs b/crates/events_codec/tests/comment.rs
@@ -1,65 +1,116 @@
-mod common;
-
-use radroots_events::tags::{TAG_E_PREV, TAG_E_ROOT};
use radroots_events::{
comment::RadrootsComment,
- kinds::{KIND_COMMENT, KIND_POST},
+ kinds::{KIND_ARTICLE, KIND_COMMENT, KIND_POST},
+ social::RadrootsSocialTarget,
+ tags::{TAG_E_PREV, TAG_E_ROOT},
+};
+use radroots_events_codec::comment::decode::{
+ comment_from_tags, data_from_event, parsed_from_event,
};
-
-use radroots_events_codec::comment::decode::comment_from_tags;
-use radroots_events_codec::comment::decode::{data_from_event, parsed_from_event};
use radroots_events_codec::comment::encode::{
comment_build_tags, to_wire_parts, to_wire_parts_with_kind,
};
use radroots_events_codec::error::{EventEncodeError, EventParseError};
-use radroots_events_codec::event_ref::{build_event_ref_tag, push_nip10_ref_tags};
-fn assert_event_ref_fields(
- actual: &radroots_events::RadrootsNostrEventRef,
- expected: &radroots_events::RadrootsNostrEventRef,
-) {
- assert_eq!(actual.id, expected.id);
- assert_eq!(actual.author, expected.author);
- assert_eq!(actual.kind, expected.kind);
- assert_eq!(actual.d_tag, expected.d_tag);
- assert_eq!(actual.relays, expected.relays);
+const ROOT_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+const PARENT_ID: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
+const AUTHOR: &str = "author_pubkey";
+const PARENT_AUTHOR: &str = "parent_pubkey";
+const D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+
+fn event_target(id: &str, author: &str, kind: u32) -> RadrootsSocialTarget {
+ RadrootsSocialTarget::Event {
+ id: id.to_string(),
+ author: Some(author.to_string()),
+ event_kind: Some(kind),
+ relays: Some(vec!["wss://relay.example.test".to_string()]),
+ }
+}
+
+fn address_target(author: &str, kind: u32, d_tag: &str) -> RadrootsSocialTarget {
+ RadrootsSocialTarget::Address {
+ address: format!("{kind}:{author}:{d_tag}"),
+ author: Some(author.to_string()),
+ event_kind: Some(kind),
+ relays: Some(vec!["wss://relay2.example.test".to_string()]),
+ }
+}
+
+fn external_target(id: &str, kind: &str) -> RadrootsSocialTarget {
+ RadrootsSocialTarget::External {
+ id: id.to_string(),
+ external_kind: kind.to_string(),
+ hint: Some("https://example.test/object".to_string()),
+ }
+}
+
+fn assert_event_target(target: &RadrootsSocialTarget, id: &str, author: &str, kind: u32) {
+ match target {
+ RadrootsSocialTarget::Event {
+ id: actual_id,
+ author: actual_author,
+ event_kind,
+ relays,
+ } => {
+ assert_eq!(actual_id, id);
+ assert_eq!(actual_author.as_deref(), Some(author));
+ assert_eq!(*event_kind, Some(kind));
+ assert_eq!(relays.as_ref().map(Vec::len), Some(1));
+ }
+ _ => panic!("expected event target"),
+ }
+}
+
+fn assert_address_target(target: &RadrootsSocialTarget, author: &str, kind: u32, d_tag: &str) {
+ match target {
+ RadrootsSocialTarget::Address {
+ address,
+ author: actual_author,
+ event_kind,
+ relays,
+ } => {
+ assert_eq!(address, &format!("{kind}:{author}:{d_tag}"));
+ assert_eq!(actual_author.as_deref(), Some(author));
+ assert_eq!(*event_kind, Some(kind));
+ assert_eq!(relays.as_ref().map(Vec::len), Some(1));
+ }
+ _ => panic!("expected address target"),
+ }
}
#[test]
-fn comment_build_tags_requires_root_id() {
+fn comment_build_tags_requires_strict_nip22_target_fields() {
let comment = RadrootsComment {
- root: common::event_ref("", "author", KIND_POST),
- parent: common::event_ref("parent", "author", KIND_POST),
+ root: RadrootsSocialTarget::Event {
+ id: "not-hex".to_string(),
+ author: Some(AUTHOR.to_string()),
+ event_kind: Some(KIND_ARTICLE),
+ relays: None,
+ },
+ parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
content: "hello".to_string(),
};
-
- let err = comment_build_tags(&comment).unwrap_err();
assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("root.id")
+ comment_build_tags(&comment),
+ Err(EventEncodeError::InvalidField("root"))
));
-}
-#[test]
-fn comment_build_tags_requires_parent_author() {
let comment = RadrootsComment {
- root: common::event_ref("root", "author", KIND_POST),
- parent: common::event_ref("parent", "", KIND_POST),
+ root: event_target(ROOT_ID, AUTHOR, KIND_POST),
+ parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
content: "hello".to_string(),
};
-
- let err = comment_build_tags(&comment).unwrap_err();
assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("parent.author")
+ comment_build_tags(&comment),
+ Err(EventEncodeError::InvalidField("root"))
));
}
#[test]
fn comment_to_wire_parts_requires_content() {
let comment = RadrootsComment {
- root: common::event_ref("root", "author", KIND_POST),
- parent: common::event_ref("parent", "author", KIND_POST),
+ root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE),
+ parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
content: " ".to_string(),
};
@@ -68,266 +119,172 @@ fn comment_to_wire_parts_requires_content() {
err,
EventEncodeError::EmptyRequiredField("content")
));
-
- let comment = RadrootsComment {
- root: common::event_ref("", "author", KIND_POST),
- parent: common::event_ref("parent", "author", KIND_POST),
- content: "hello".to_string(),
- };
- let err = to_wire_parts(&comment).unwrap_err();
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("root.id")
- ));
}
#[test]
-fn comment_to_wire_parts_sets_kind_content_and_tags() {
+fn comment_roundtrips_event_and_address_targets() {
let comment = RadrootsComment {
- root: common::event_ref("root", "author", KIND_POST),
- parent: common::event_ref("parent", "author", KIND_POST),
+ root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE),
+ parent: address_target(PARENT_AUTHOR, KIND_ARTICLE, D_TAG),
content: "hello".to_string(),
};
let parts = to_wire_parts(&comment).unwrap();
+
assert_eq!(parts.kind, KIND_COMMENT);
- assert_eq!(parts.content, "hello");
- assert_eq!(parts.tags.len(), 6);
+ assert!(parts.tags.iter().any(|tag| tag[0] == "E"));
+ assert!(parts.tags.iter().any(|tag| tag[0] == "P"));
+ assert!(parts.tags.iter().any(|tag| tag[0] == "K"));
+ assert!(parts.tags.iter().any(|tag| tag[0] == "a"));
+ assert!(parts.tags.iter().any(|tag| tag[0] == "p"));
+ assert!(parts.tags.iter().any(|tag| tag[0] == "k"));
+
+ let parsed = comment_from_tags(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_event_target(&parsed.root, ROOT_ID, AUTHOR, KIND_ARTICLE);
+ assert_address_target(&parsed.parent, PARENT_AUTHOR, KIND_ARTICLE, D_TAG);
+ assert_eq!(parsed.content, "hello");
let custom_parts = to_wire_parts_with_kind(&comment, KIND_POST).unwrap();
assert_eq!(custom_parts.kind, KIND_POST);
- assert_eq!(custom_parts.content, "hello");
- assert_eq!(custom_parts.tags.len(), 6);
}
#[test]
-fn comment_build_tags_includes_address_tags_when_refs_have_d_tag() {
+fn comment_roundtrips_external_targets() {
let comment = RadrootsComment {
- root: common::event_ref_with_d(
- "root",
- "author",
- KIND_POST,
- "root-d",
- Some(vec!["wss://relay".to_string()]),
- ),
- parent: common::event_ref_with_d(
- "parent",
- "author",
- KIND_POST,
- "parent-d",
- Some(vec!["wss://relay-2".to_string()]),
- ),
- content: "hello".to_string(),
+ root: external_target("https://example.test/root", "web"),
+ parent: external_target("https://example.test/parent", "web"),
+ content: "external comment".to_string(),
};
- let tags = comment_build_tags(&comment).unwrap();
- assert_eq!(tags.len(), 8);
- assert!(tags.iter().any(|tag| tag[0] == "A"));
- assert!(tags.iter().any(|tag| tag[0] == "a"));
-}
-
-#[test]
-fn comment_roundtrip_from_tags_with_parent() {
- let root = common::event_ref_with_d(
- "root",
- "author",
- KIND_POST,
- "root-d",
- Some(vec!["wss://relay".to_string()]),
- );
- let parent = common::event_ref_with_d(
- "parent",
- "author",
- KIND_POST,
- "parent-d",
- Some(vec!["wss://relay-2".to_string()]),
- );
-
- let mut tags = Vec::new();
- push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A");
- push_nip10_ref_tags(&mut tags, &parent, "e", "p", "k", "a");
-
- let comment = comment_from_tags(KIND_COMMENT, &tags, "hello").unwrap();
-
- assert_event_ref_fields(&comment.root, &root);
- assert_event_ref_fields(&comment.parent, &parent);
- assert_eq!(comment.content, "hello");
-}
-
-#[test]
-fn comment_from_tags_defaults_parent_to_root() {
- let root = common::event_ref("root", "author", KIND_POST);
- let mut tags = Vec::new();
- push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A");
-
- let comment = comment_from_tags(KIND_COMMENT, &tags, "hello").unwrap();
-
- assert_event_ref_fields(&comment.root, &root);
- assert_event_ref_fields(&comment.parent, &root);
-}
-
-#[test]
-fn comment_roundtrip_from_legacy_tags() {
- let root = common::event_ref("root", "author", KIND_POST);
- let parent = common::event_ref("parent", "author", KIND_POST);
-
- let tags = vec![
- build_event_ref_tag(TAG_E_ROOT, &root),
- build_event_ref_tag(TAG_E_PREV, &parent),
- ];
-
- let comment = comment_from_tags(KIND_COMMENT, &tags, "hello").unwrap();
-
- assert_event_ref_fields(&comment.root, &root);
- assert_event_ref_fields(&comment.parent, &parent);
-}
-
-#[test]
-fn comment_from_tags_requires_root_tag() {
- let tags = vec![vec!["p".to_string(), "x".to_string()]];
-
- let err = comment_from_tags(KIND_COMMENT, &tags, "hello").unwrap_err();
- assert!(matches!(err, EventParseError::MissingTag("E")));
+ let parts = to_wire_parts(&comment).unwrap();
+ let parsed = comment_from_tags(parts.kind, &parts.tags, &parts.content).unwrap();
+
+ match parsed.root {
+ RadrootsSocialTarget::External {
+ id,
+ external_kind,
+ hint,
+ } => {
+ assert_eq!(id, "https://example.test/root");
+ assert_eq!(external_kind, "web");
+ assert_eq!(hint.as_deref(), Some("https://example.test/object"));
+ }
+ _ => panic!("expected external root"),
+ }
+ match parsed.parent {
+ RadrootsSocialTarget::External { id, .. } => {
+ assert_eq!(id, "https://example.test/parent");
+ }
+ _ => panic!("expected external parent"),
+ }
}
#[test]
-fn comment_from_tags_propagates_root_and_parent_reference_parse_errors() {
- let err = comment_from_tags(
- KIND_COMMENT,
- &[
- vec!["E".to_string()],
- vec!["P".to_string(), "author".to_string()],
- vec!["K".to_string(), KIND_POST.to_string()],
- ],
- "hello",
- )
- .unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag("E")));
+fn comment_from_tags_rejects_legacy_missing_and_kind1_shapes() {
+ let legacy_tags = vec![vec![
+ TAG_E_ROOT.to_string(),
+ ROOT_ID.to_string(),
+ AUTHOR.to_string(),
+ KIND_ARTICLE.to_string(),
+ ]];
+ assert!(matches!(
+ comment_from_tags(KIND_COMMENT, &legacy_tags, "hello"),
+ Err(EventParseError::InvalidTag(TAG_E_ROOT))
+ ));
- let err = comment_from_tags(
- KIND_COMMENT,
- &[
- vec!["e".to_string()],
- vec!["p".to_string(), "author".to_string()],
- vec!["k".to_string(), KIND_POST.to_string()],
- build_event_ref_tag(TAG_E_ROOT, &common::event_ref("root", "author", KIND_POST)),
+ let legacy_parent_tags = vec![
+ vec!["E".to_string(), ROOT_ID.to_string()],
+ vec!["P".to_string(), AUTHOR.to_string()],
+ vec!["K".to_string(), KIND_ARTICLE.to_string()],
+ vec![
+ TAG_E_PREV.to_string(),
+ PARENT_ID.to_string(),
+ PARENT_AUTHOR.to_string(),
+ KIND_ARTICLE.to_string(),
],
- "hello",
- )
- .unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag("e")));
+ ];
+ assert!(matches!(
+ comment_from_tags(KIND_COMMENT, &legacy_parent_tags, "hello"),
+ Err(EventParseError::InvalidTag(TAG_E_PREV))
+ ));
- let err = comment_from_tags(
- KIND_COMMENT,
- &[vec![TAG_E_ROOT.to_string(), "root".to_string()]],
- "hello",
- )
- .unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag("e_root")));
+ let missing_parent_tags = vec![
+ vec!["E".to_string(), ROOT_ID.to_string()],
+ vec!["P".to_string(), AUTHOR.to_string()],
+ vec!["K".to_string(), KIND_ARTICLE.to_string()],
+ ];
+ assert!(matches!(
+ comment_from_tags(KIND_COMMENT, &missing_parent_tags, "hello"),
+ Err(EventParseError::MissingTag("e"))
+ ));
- let err = comment_from_tags(
- KIND_COMMENT,
- &[
- build_event_ref_tag(TAG_E_ROOT, &common::event_ref("root", "author", KIND_POST)),
- vec![TAG_E_PREV.to_string(), "parent".to_string()],
- ],
- "hello",
- )
- .unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag("e_prev")));
+ let kind1_tags = vec![
+ vec!["E".to_string(), ROOT_ID.to_string()],
+ vec!["P".to_string(), AUTHOR.to_string()],
+ vec!["K".to_string(), KIND_POST.to_string()],
+ vec!["e".to_string(), PARENT_ID.to_string()],
+ vec!["p".to_string(), PARENT_AUTHOR.to_string()],
+ vec!["k".to_string(), KIND_ARTICLE.to_string()],
+ ];
+ assert!(matches!(
+ comment_from_tags(KIND_COMMENT, &kind1_tags, "hello"),
+ Err(EventParseError::InvalidTag("K"))
+ ));
}
#[test]
-fn comment_from_tags_rejects_empty_content() {
- let root = common::event_ref("root", "author", KIND_POST);
- let mut tags = Vec::new();
- push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A");
-
- let err = comment_from_tags(KIND_COMMENT, &tags, " ").unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag("content")));
-}
+fn comment_from_tags_rejects_empty_content_and_wrong_kind() {
+ let tags = comment_build_tags(&RadrootsComment {
+ root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE),
+ parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
+ content: "hello".to_string(),
+ })
+ .unwrap();
-#[test]
-fn comment_from_tags_rejects_wrong_kind() {
- let tags = vec![vec!["e".to_string(), "x".to_string()]];
- let err = comment_from_tags(KIND_POST, &tags, "hello").unwrap_err();
assert!(matches!(
- err,
- EventParseError::InvalidKind {
+ comment_from_tags(KIND_COMMENT, &tags, " "),
+ Err(EventParseError::InvalidTag("content"))
+ ));
+ assert!(matches!(
+ comment_from_tags(KIND_POST, &tags, "hello"),
+ Err(EventParseError::InvalidKind {
expected: "1111",
got: KIND_POST
- }
+ })
));
}
#[test]
fn comment_metadata_and_index_from_event_roundtrip() {
- let root = common::event_ref_with_d(
- "root",
- "author",
- KIND_POST,
- "root-d",
- Some(vec!["wss://relay".to_string()]),
- );
- let parent = common::event_ref_with_d(
- "parent",
- "author",
- KIND_POST,
- "parent-d",
- Some(vec!["wss://relay-2".to_string()]),
- );
-
- let mut tags = Vec::new();
- push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A");
- push_nip10_ref_tags(&mut tags, &parent, "e", "p", "k", "a");
+ let parts = to_wire_parts(&RadrootsComment {
+ root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE),
+ parent: address_target(PARENT_AUTHOR, KIND_ARTICLE, D_TAG),
+ content: "hello".to_string(),
+ })
+ .unwrap();
let metadata = data_from_event(
"id".to_string(),
"author".to_string(),
77,
KIND_COMMENT,
- "hello".to_string(),
- tags.clone(),
+ parts.content.clone(),
+ parts.tags.clone(),
)
.unwrap();
assert_eq!(metadata.id, "id");
- assert_eq!(metadata.author, "author");
assert_eq!(metadata.published_at, 77);
- assert_eq!(metadata.data.content, "hello");
- assert_event_ref_fields(&metadata.data.root, &root);
- assert_event_ref_fields(&metadata.data.parent, &parent);
+ assert_event_target(&metadata.data.root, ROOT_ID, AUTHOR, KIND_ARTICLE);
let index = parsed_from_event(
"id".to_string(),
"author".to_string(),
77,
KIND_COMMENT,
- "hello".to_string(),
- tags,
+ parts.content,
+ parts.tags,
"sig".to_string(),
)
.unwrap();
assert_eq!(index.event.created_at, 77);
- assert_eq!(index.event.kind, KIND_COMMENT);
assert_eq!(index.event.sig, "sig");
- assert_eq!(index.data.data.content, "hello");
-}
-
-#[test]
-fn comment_index_from_event_propagates_parse_errors() {
- let err = parsed_from_event(
- "id".to_string(),
- "author".to_string(),
- 77,
- KIND_POST,
- "hello".to_string(),
- Vec::new(),
- "sig".to_string(),
- )
- .unwrap_err();
- assert!(matches!(
- err,
- EventParseError::InvalidKind {
- expected: "1111",
- got: KIND_POST
- }
- ));
+ assert_address_target(&index.data.data.parent, PARENT_AUTHOR, KIND_ARTICLE, D_TAG);
}
diff --git a/crates/events_codec/tests/post.rs b/crates/events_codec/tests/post.rs
@@ -1,10 +1,22 @@
use radroots_events::{
+ farm::RadrootsFarmRef,
kinds::{KIND_COMMENT, KIND_POST},
post::RadrootsPost,
+ social::{
+ RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaMetadata,
+ RadrootsSocialTarget,
+ },
+ tags::{TAG_A, TAG_G, TAG_IMETA, TAG_LOCATION, TAG_Q, TAG_T},
};
use radroots_events_codec::error::{EventEncodeError, EventParseError};
-use radroots_events_codec::post::decode::{data_from_event, parsed_from_event, post_from_content};
-use radroots_events_codec::post::encode::to_wire_parts;
+use radroots_events_codec::post::decode::{
+ data_from_event, parsed_from_event, post_from_content, post_from_event,
+};
+use radroots_events_codec::post::encode::{post_build_tags, to_wire_parts};
+
+const QUOTE_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+const ARTICLE_D_TAG: &str = "BBBBBBBBBBBBBBBBBBBBBA";
#[test]
fn post_to_wire_parts_requires_content() {
@@ -44,6 +56,162 @@ fn post_to_wire_parts_sets_kind_and_content() {
}
#[test]
+fn post_to_wire_parts_roundtrips_optional_social_tags() {
+ let post = RadrootsPost {
+ content: "field update".to_string(),
+ farm: Some(RadrootsSocialFarmAnchor {
+ farm: RadrootsFarmRef {
+ pubkey: "farm_pubkey".to_string(),
+ d_tag: FARM_D_TAG.to_string(),
+ },
+ relays: Some(vec!["wss://farm-relay.example.test".to_string()]),
+ }),
+ address_refs: Some(vec![RadrootsSocialTarget::Address {
+ address: format!("30023:article_author:{ARTICLE_D_TAG}"),
+ author: Some("article_author".to_string()),
+ event_kind: Some(30023),
+ relays: Some(vec!["wss://article-relay.example.test".to_string()]),
+ }]),
+ location: Some(RadrootsSocialLocation {
+ name: Some("North field".to_string()),
+ geohash: Some("c23nb62w20st".to_string()),
+ }),
+ topics: Some(vec!["soil".to_string(), "cover-crops".to_string()]),
+ quote_refs: Some(vec![
+ RadrootsSocialTarget::Event {
+ id: QUOTE_ID.to_string(),
+ author: None,
+ event_kind: None,
+ relays: Some(vec!["wss://quote-relay.example.test".to_string()]),
+ },
+ RadrootsSocialTarget::Address {
+ address: format!("30023:quote_author:{ARTICLE_D_TAG}"),
+ author: Some("quote_author".to_string()),
+ event_kind: Some(30023),
+ relays: None,
+ },
+ ]),
+ media: Some(vec![RadrootsSocialMediaMetadata {
+ imeta: Some(vec![vec![
+ "url https://media.example.test/field.jpg".to_string(),
+ "m image/jpeg".to_string(),
+ format!("x {QUOTE_ID}"),
+ "dim 1200x800".to_string(),
+ "alt Field rows".to_string(),
+ "service https://media.example.test".to_string(),
+ ]]),
+ ..RadrootsSocialMediaMetadata::default()
+ }]),
+ };
+
+ let parts = to_wire_parts(&post).unwrap();
+ assert_eq!(parts.kind, KIND_POST);
+ assert!(parts.tags.iter().any(|tag| {
+ tag.first().map(|value| value.as_str()) == Some(TAG_A)
+ && tag.get(1).map(|value| value.as_str())
+ == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA")
+ }));
+ assert!(parts.tags.iter().any(|tag| {
+ tag.first().map(|value| value.as_str()) == Some(TAG_A)
+ && tag.get(1).map(|value| value.as_str())
+ == Some("30023:article_author:BBBBBBBBBBBBBBBBBBBBBA")
+ }));
+ assert!(parts.tags.iter().any(|tag| {
+ tag.first().map(|value| value.as_str()) == Some(TAG_LOCATION)
+ && tag.get(1).map(|value| value.as_str()) == Some("North field")
+ }));
+ assert!(parts.tags.iter().any(|tag| {
+ tag.first().map(|value| value.as_str()) == Some(TAG_G)
+ && tag.get(1).map(|value| value.as_str()) == Some("c23nb62w20st")
+ }));
+ assert!(parts.tags.iter().any(|tag| {
+ tag.first().map(|value| value.as_str()) == Some(TAG_T)
+ && tag.get(1).map(|value| value.as_str()) == Some("soil")
+ }));
+ assert!(parts.tags.iter().any(|tag| {
+ tag.first().map(|value| value.as_str()) == Some(TAG_Q)
+ && tag.get(1).map(|value| value.as_str()) == Some(QUOTE_ID)
+ }));
+ assert!(parts.tags.iter().any(|tag| {
+ tag.first().map(|value| value.as_str()) == Some(TAG_IMETA)
+ && tag
+ .iter()
+ .any(|value| value == "url https://media.example.test/field.jpg")
+ }));
+
+ let decoded = post_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.content, "field update");
+ assert_eq!(
+ decoded.farm.as_ref().map(|farm| farm.farm.pubkey.as_str()),
+ Some("farm_pubkey")
+ );
+ assert_eq!(decoded.address_refs.as_ref().map(Vec::len), Some(1));
+ assert_eq!(
+ decoded
+ .location
+ .as_ref()
+ .and_then(|location| location.name.as_deref()),
+ Some("North field")
+ );
+ assert_eq!(decoded.topics.as_ref().map(Vec::len), Some(2));
+ assert_eq!(decoded.quote_refs.as_ref().map(Vec::len), Some(2));
+ let media = decoded.media.as_ref().expect("media");
+ assert_eq!(
+ media[0].url.as_deref(),
+ Some("https://media.example.test/field.jpg")
+ );
+ assert_eq!(media[0].mime_type.as_deref(), Some("image/jpeg"));
+ assert_eq!(
+ media[0].dimensions.as_ref().map(|value| value.width),
+ Some(1200)
+ );
+ assert_eq!(media[0].alt.as_deref(), Some("Field rows"));
+ assert_eq!(media[0].services.as_ref().map(Vec::len), Some(1));
+}
+
+#[test]
+fn post_social_tags_reject_malformed_supported_structures() {
+ let mut post = RadrootsPost {
+ content: "field update".to_string(),
+ farm: None,
+ address_refs: Some(vec![RadrootsSocialTarget::Event {
+ id: QUOTE_ID.to_string(),
+ author: None,
+ event_kind: None,
+ relays: None,
+ }]),
+ location: None,
+ topics: None,
+ quote_refs: None,
+ media: None,
+ };
+ assert!(matches!(
+ post_build_tags(&post),
+ Err(EventEncodeError::InvalidField("address_refs"))
+ ));
+
+ post.address_refs = None;
+ post.quote_refs = Some(vec![RadrootsSocialTarget::Event {
+ id: "not-hex".to_string(),
+ author: None,
+ event_kind: None,
+ relays: None,
+ }]);
+ assert!(matches!(
+ post_build_tags(&post),
+ Err(EventEncodeError::InvalidField("quote_refs"))
+ ));
+
+ let err = post_from_event(
+ KIND_POST,
+ &[vec![TAG_IMETA.to_string(), "bad-imeta-entry".to_string()]],
+ "hello",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag(TAG_IMETA)));
+}
+
+#[test]
fn post_from_content_requires_kind_and_content() {
let err = post_from_content(KIND_COMMENT, "hello").unwrap_err();
assert!(matches!(
diff --git a/crates/events_codec/tests/reaction.rs b/crates/events_codec/tests/reaction.rs
@@ -1,13 +1,10 @@
-mod common;
-
-use radroots_events::tags::TAG_E_ROOT;
use radroots_events::{
- kinds::{KIND_POST, KIND_REACTION},
+ kinds::{KIND_ARTICLE, KIND_POST, KIND_REACTION},
reaction::RadrootsReaction,
+ social::RadrootsSocialTarget,
+ tags::TAG_E_ROOT,
};
-
use radroots_events_codec::error::{EventEncodeError, EventParseError};
-use radroots_events_codec::event_ref::{build_event_ref_tag, push_nip10_ref_tags};
use radroots_events_codec::reaction::decode::{
data_from_event, parsed_from_event, reaction_from_tags,
};
@@ -15,259 +12,244 @@ use radroots_events_codec::reaction::encode::{
reaction_build_tags, to_wire_parts, to_wire_parts_with_kind,
};
-#[test]
-fn reaction_build_tags_requires_root_fields() {
- let reaction = RadrootsReaction {
- root: common::event_ref("", "author", KIND_POST),
- content: "like".to_string(),
- };
+const EVENT_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+const AUTHOR: &str = "author_pubkey";
+const D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+
+fn event_target() -> RadrootsSocialTarget {
+ RadrootsSocialTarget::Event {
+ id: EVENT_ID.to_string(),
+ author: Some(AUTHOR.to_string()),
+ event_kind: Some(KIND_ARTICLE),
+ relays: Some(vec!["wss://relay.example.test".to_string()]),
+ }
+}
- let err = reaction_build_tags(&reaction).unwrap_err();
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("root.id")
- ));
+fn address_target() -> RadrootsSocialTarget {
+ RadrootsSocialTarget::Address {
+ address: format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE),
+ author: Some(AUTHOR.to_string()),
+ event_kind: Some(KIND_ARTICLE),
+ relays: Some(vec!["wss://relay2.example.test".to_string()]),
+ }
}
-#[test]
-fn reaction_build_tags_requires_root_author() {
- let reaction = RadrootsReaction {
- root: common::event_ref("root", "", KIND_POST),
- content: "like".to_string(),
- };
+fn assert_event_target(target: &RadrootsSocialTarget) {
+ match target {
+ RadrootsSocialTarget::Event {
+ id,
+ author,
+ event_kind,
+ relays,
+ } => {
+ assert_eq!(id, EVENT_ID);
+ assert_eq!(author.as_deref(), Some(AUTHOR));
+ assert_eq!(*event_kind, Some(KIND_ARTICLE));
+ assert_eq!(relays.as_ref().map(Vec::len), Some(1));
+ }
+ _ => panic!("expected event target"),
+ }
+}
- let err = reaction_build_tags(&reaction).unwrap_err();
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("root.author")
- ));
+fn assert_address_target(target: &RadrootsSocialTarget) {
+ match target {
+ RadrootsSocialTarget::Address {
+ address,
+ author,
+ event_kind,
+ relays,
+ } => {
+ assert_eq!(address, &format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE));
+ assert_eq!(author.as_deref(), Some(AUTHOR));
+ assert_eq!(*event_kind, Some(KIND_ARTICLE));
+ assert_eq!(relays.as_ref().map(Vec::len), Some(1));
+ }
+ _ => panic!("expected address target"),
+ }
}
#[test]
-fn reaction_to_wire_parts_requires_content() {
+fn reaction_build_tags_requires_valid_event_or_address_target() {
let reaction = RadrootsReaction {
- root: common::event_ref("root", "author", KIND_POST),
- content: " ".to_string(),
+ target: RadrootsSocialTarget::Event {
+ id: "not-hex".to_string(),
+ author: Some(AUTHOR.to_string()),
+ event_kind: Some(KIND_ARTICLE),
+ relays: None,
+ },
+ content: "+".to_string(),
};
-
- let err = to_wire_parts(&reaction).unwrap_err();
assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("content")
+ reaction_build_tags(&reaction),
+ Err(EventEncodeError::InvalidField("target.id"))
));
let reaction = RadrootsReaction {
- root: common::event_ref("", "author", KIND_POST),
+ target: RadrootsSocialTarget::External {
+ id: "https://example.test".to_string(),
+ external_kind: "web".to_string(),
+ hint: None,
+ },
content: "+".to_string(),
};
- let err = to_wire_parts(&reaction).unwrap_err();
assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("root.id")
+ reaction_build_tags(&reaction),
+ Err(EventEncodeError::InvalidField("target"))
));
}
#[test]
+fn reaction_to_wire_parts_accepts_empty_plus_minus_emoji_and_custom_content() {
+ for content in ["", "+", "-", "🔥", "harvest"] {
+ let reaction = RadrootsReaction {
+ target: event_target(),
+ content: content.to_string(),
+ };
+ let parts = to_wire_parts(&reaction).unwrap();
+ assert_eq!(parts.kind, KIND_REACTION);
+ assert_eq!(parts.content, content);
+ assert!(parts.tags.iter().any(|tag| tag[0] == "e"));
+ }
+}
+
+#[test]
fn reaction_to_wire_parts_with_kind_keeps_requested_kind() {
let reaction = RadrootsReaction {
- root: common::event_ref("root", "author", KIND_POST),
+ target: event_target(),
content: "+".to_string(),
};
let parts = to_wire_parts_with_kind(&reaction, KIND_POST).unwrap();
assert_eq!(parts.kind, KIND_POST);
assert_eq!(parts.content, "+");
- assert_eq!(parts.tags.len(), 3);
-
- let default_parts = to_wire_parts(&reaction).unwrap();
- assert_eq!(default_parts.kind, KIND_REACTION);
}
#[test]
-fn reaction_from_tags_requires_root_tag() {
- let tags = vec![vec!["p".to_string(), "x".to_string()]];
- let err = reaction_from_tags(KIND_REACTION, &tags, "+").unwrap_err();
- assert!(matches!(err, EventParseError::MissingTag("e")));
+fn reaction_roundtrips_event_target() {
+ let reaction = RadrootsReaction {
+ target: event_target(),
+ content: "+".to_string(),
+ };
+ let parts = to_wire_parts(&reaction).unwrap();
+ let parsed = reaction_from_tags(parts.kind, &parts.tags, &parts.content).unwrap();
+
+ assert_event_target(&parsed.target);
+ assert_eq!(parsed.content, "+");
}
#[test]
-fn reaction_from_tags_propagates_reference_parse_errors() {
- let err = reaction_from_tags(
- KIND_REACTION,
- &[
- vec!["e".to_string()],
- vec!["p".to_string(), "author".to_string()],
- vec!["k".to_string(), KIND_POST.to_string()],
- ],
- "+",
- )
- .unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag("e")));
+fn reaction_roundtrips_address_target() {
+ let reaction = RadrootsReaction {
+ target: address_target(),
+ content: "".to_string(),
+ };
+ let parts = to_wire_parts(&reaction).unwrap();
+ let parsed = reaction_from_tags(parts.kind, &parts.tags, &parts.content).unwrap();
- let err = reaction_from_tags(
- KIND_REACTION,
- &[vec![TAG_E_ROOT.to_string(), "root".to_string()]],
- "+",
- )
- .unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag("e_root")));
+ assert_address_target(&parsed.target);
+ assert_eq!(parsed.content, "");
}
#[test]
-fn reaction_from_tags_rejects_invalid_kind_and_content() {
- let root = common::event_ref("root", "author", KIND_POST);
- let mut tags = Vec::new();
- push_nip10_ref_tags(&mut tags, &root, "e", "p", "k", "a");
-
- let err = reaction_from_tags(KIND_POST, &tags, "+").unwrap_err();
+fn reaction_from_tags_rejects_missing_legacy_and_mismatched_targets() {
assert!(matches!(
- err,
- EventParseError::InvalidKind {
- expected: "7",
- got: KIND_POST
- }
+ reaction_from_tags(
+ KIND_REACTION,
+ &[vec!["p".to_string(), AUTHOR.to_string()]],
+ "+"
+ ),
+ Err(EventParseError::MissingTag("e"))
));
- let err = reaction_from_tags(KIND_REACTION, &tags, " ").unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag("content")));
-}
-
-#[test]
-fn reaction_roundtrip_from_tags() {
- let root = common::event_ref_with_d(
- "root",
- "author",
- KIND_POST,
- "note-1",
- Some(vec!["wss://relay".to_string()]),
- );
- let mut tags = Vec::new();
- push_nip10_ref_tags(&mut tags, &root, "e", "p", "k", "a");
-
- let reaction = reaction_from_tags(KIND_REACTION, &tags, "+").unwrap();
+ assert!(matches!(
+ reaction_from_tags(
+ KIND_REACTION,
+ &[vec![TAG_E_ROOT.to_string(), EVENT_ID.to_string()]],
+ "+"
+ ),
+ Err(EventParseError::InvalidTag(TAG_E_ROOT))
+ ));
- assert_eq!(reaction.root.id, root.id);
- assert_eq!(reaction.root.author, root.author);
- assert_eq!(reaction.root.kind, root.kind);
- assert_eq!(reaction.root.d_tag, root.d_tag);
- assert_eq!(reaction.root.relays, root.relays);
- assert_eq!(reaction.content, "+");
-}
+ assert!(matches!(
+ reaction_from_tags(
+ KIND_REACTION,
+ &[
+ vec!["e".to_string(), EVENT_ID.to_string()],
+ vec![
+ "a".to_string(),
+ format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE)
+ ]
+ ],
+ "+"
+ ),
+ Err(EventParseError::InvalidTag("e"))
+ ));
-#[test]
-fn reaction_build_tags_includes_address_tag_when_root_has_d_tag() {
- let reaction = RadrootsReaction {
- root: common::event_ref_with_d(
- "root",
- "author",
- KIND_POST,
- "note-1",
- Some(vec!["wss://relay".to_string()]),
+ assert!(matches!(
+ reaction_from_tags(
+ KIND_REACTION,
+ &[
+ vec![
+ "a".to_string(),
+ format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE)
+ ],
+ vec!["p".to_string(), "other_author".to_string()]
+ ],
+ "+"
),
- content: "+".to_string(),
- };
- let tags = reaction_build_tags(&reaction).unwrap();
- assert_eq!(tags.len(), 4);
- assert_eq!(tags[3][0], "a");
+ Err(EventParseError::InvalidTag("p"))
+ ));
}
#[test]
-fn reaction_roundtrip_from_legacy_tags() {
- let root = common::event_ref("root", "author", KIND_POST);
- let tags = vec![build_event_ref_tag(TAG_E_ROOT, &root)];
-
- let reaction = reaction_from_tags(KIND_REACTION, &tags, "+").unwrap();
+fn reaction_from_tags_rejects_invalid_kind() {
+ let tags = reaction_build_tags(&RadrootsReaction {
+ target: event_target(),
+ content: "+".to_string(),
+ })
+ .unwrap();
- assert_eq!(reaction.root.id, root.id);
- assert_eq!(reaction.root.author, root.author);
- assert_eq!(reaction.root.kind, root.kind);
- assert_eq!(reaction.content, "+");
+ assert!(matches!(
+ reaction_from_tags(KIND_POST, &tags, "+"),
+ Err(EventParseError::InvalidKind {
+ expected: "7",
+ got: KIND_POST
+ })
+ ));
}
#[test]
fn reaction_metadata_and_index_from_event_roundtrip() {
- let root = common::event_ref_with_d(
- "root",
- "author",
- KIND_POST,
- "note-1",
- Some(vec!["wss://relay".to_string()]),
- );
- let mut tags = Vec::new();
- push_nip10_ref_tags(&mut tags, &root, "e", "p", "k", "a");
+ let parts = to_wire_parts(&RadrootsReaction {
+ target: event_target(),
+ content: "".to_string(),
+ })
+ .unwrap();
let metadata = data_from_event(
"id".to_string(),
"author".to_string(),
99,
KIND_REACTION,
- "+".to_string(),
- tags.clone(),
+ parts.content.clone(),
+ parts.tags.clone(),
)
.unwrap();
assert_eq!(metadata.id, "id");
- assert_eq!(metadata.author, "author");
- assert_eq!(metadata.published_at, 99);
assert_eq!(metadata.kind, KIND_REACTION);
- assert_eq!(metadata.data.content, "+");
- assert_eq!(metadata.data.root.id, root.id);
+ assert_event_target(&metadata.data.target);
+ assert_eq!(metadata.data.content, "");
let index = parsed_from_event(
"id".to_string(),
"author".to_string(),
99,
KIND_REACTION,
- "+".to_string(),
- tags,
+ parts.content,
+ parts.tags,
"sig".to_string(),
)
.unwrap();
assert_eq!(index.event.kind, KIND_REACTION);
assert_eq!(index.event.sig, "sig");
- assert_eq!(index.data.data.content, "+");
-}
-
-#[test]
-fn reaction_metadata_and_index_propagate_parse_errors() {
- let tags = vec![vec!["e".to_string(), "root".to_string()]];
- let err = data_from_event(
- "id".to_string(),
- "author".to_string(),
- 99,
- KIND_POST,
- "+".to_string(),
- tags.clone(),
- )
- .unwrap_err();
- assert!(matches!(
- err,
- EventParseError::InvalidKind {
- expected: "7",
- got: KIND_POST
- }
- ));
-
- let err = parsed_from_event(
- "id".to_string(),
- "author".to_string(),
- 99,
- KIND_REACTION,
- " ".to_string(),
- tags,
- "sig".to_string(),
- )
- .unwrap_err();
- assert!(matches!(err, EventParseError::InvalidTag("content")));
-}
-
-#[test]
-fn reaction_build_tags_supports_root_without_d_tag() {
- let reaction = RadrootsReaction {
- root: common::event_ref("root", "author", KIND_POST),
- content: "+".to_string(),
- };
- let tags = reaction_build_tags(&reaction).unwrap();
- assert_eq!(tags.len(), 3);
- assert_eq!(tags[0][0], "e");
- assert_eq!(tags[1][0], "p");
- assert_eq!(tags[2][0], "k");
+ assert_event_target(&index.data.data.target);
}
diff --git a/crates/events_codec/tests/social_events.rs b/crates/events_codec/tests/social_events.rs
@@ -0,0 +1,175 @@
+#![cfg(feature = "serde_json")]
+
+use radroots_events::{
+ farm_crdt::RadrootsFarmCrdtDocumentKind,
+ farm_file::{RadrootsFarmFileDimensions, RadrootsFarmFileMetadata},
+ farm_workspace::RadrootsFarmWorkspaceRef,
+ file_metadata::RadrootsFileMetadata,
+ group::{RadrootsGroupEditableMetadata, RadrootsGroupMetadata},
+ kinds::{
+ KIND_ARTICLE, KIND_FARM, KIND_FARM_CRDT_CHANGE, KIND_GROUP_METADATA, KIND_LISTING,
+ KIND_POST, KIND_PUBLIC_FILE_METADATA, is_public_social_kind,
+ },
+ social::RadrootsSocialMediaDimensions,
+};
+use radroots_events_codec::{
+ article::decode::article_from_event,
+ error::EventParseError,
+ farm::decode::farm_from_event,
+ farm_file::{
+ decode::farm_file_metadata_from_event, encode::to_wire_parts as farm_file_to_wire_parts,
+ },
+ file_metadata::{
+ decode::file_metadata_from_event, encode::to_wire_parts as public_file_to_wire_parts,
+ },
+ group::{decode::group_metadata_from_event, encode::group_metadata_to_wire_parts},
+ listing::decode::listing_from_event,
+ post::decode::post_from_event,
+};
+
+const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+const OTHER_SHA256: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
+const D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+
+#[test]
+fn social_events_keep_public_and_private_file_metadata_apis_separate() {
+ let public = public_file_to_wire_parts(&public_file_metadata()).unwrap();
+ let decoded_public =
+ file_metadata_from_event(public.kind, &public.tags, &public.content).unwrap();
+ assert_eq!(decoded_public.url, "https://media.example.test/public.jpg");
+ assert!(matches!(
+ farm_file_metadata_from_event(public.kind, &public.tags, &public.content),
+ Err(EventParseError::MissingTag("d"))
+ ));
+
+ let private = farm_file_to_wire_parts(&private_farm_file_metadata()).unwrap();
+ let decoded_private =
+ farm_file_metadata_from_event(private.kind, &private.tags, &private.content).unwrap();
+ assert_eq!(decoded_private.owner_document_id, D_TAG);
+ assert!(matches!(
+ file_metadata_from_event(private.kind, &private.tags, &private.content),
+ Err(EventParseError::InvalidTag("radroots:owner_document"))
+ ));
+}
+
+#[test]
+fn social_events_reject_private_farm_ops_semantics_in_public_codecs() {
+ let private_content = serde_json::json!({
+ "workspace": {
+ "pubkey": "workspace_pubkey",
+ "d_tag": D_TAG
+ },
+ "farm_group_id": "field-group",
+ "document_id": D_TAG,
+ "document_kind": "FarmTask",
+ "encoded_change": "abc-DEF_012"
+ })
+ .to_string();
+ let farm_tags = vec![vec!["d".to_string(), D_TAG.to_string()]];
+
+ assert!(matches!(
+ farm_from_event(KIND_FARM, &farm_tags, &private_content),
+ Err(EventParseError::InvalidJson("content"))
+ ));
+ assert!(matches!(
+ post_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"),
+ Err(EventParseError::InvalidKind {
+ expected: "1",
+ got: KIND_FARM_CRDT_CHANGE
+ })
+ ));
+ assert!(matches!(
+ article_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"),
+ Err(EventParseError::InvalidKind {
+ expected: "30023",
+ got: KIND_FARM_CRDT_CHANGE
+ })
+ ));
+ assert!(matches!(
+ listing_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"),
+ Err(EventParseError::InvalidKind {
+ expected: "30402 or 30403",
+ got: KIND_FARM_CRDT_CHANGE
+ })
+ ));
+ assert!(is_public_social_kind(KIND_POST));
+ assert!(is_public_social_kind(KIND_ARTICLE));
+ assert!(is_public_social_kind(KIND_PUBLIC_FILE_METADATA));
+ assert!(!is_public_social_kind(KIND_FARM_CRDT_CHANGE));
+ assert!(!is_public_social_kind(KIND_LISTING));
+}
+
+#[test]
+fn social_events_keep_nip29_groups_out_of_public_social_classification() {
+ let group = RadrootsGroupMetadata {
+ d_tag: "field-group".to_string(),
+ metadata: RadrootsGroupEditableMetadata {
+ name: Some("Field Group".to_string()),
+ about: Some("Localhost field coordination".to_string()),
+ picture: None,
+ is_private: false,
+ is_restricted: false,
+ is_closed: false,
+ is_hidden: false,
+ supported_kinds: Some(vec![KIND_FARM_CRDT_CHANGE]),
+ },
+ };
+ let parts = group_metadata_to_wire_parts(&group).unwrap();
+ assert_eq!(parts.kind, KIND_GROUP_METADATA);
+ assert!(!is_public_social_kind(KIND_GROUP_METADATA));
+ assert_eq!(
+ group_metadata_from_event(parts.kind, &parts.tags, &parts.content)
+ .unwrap()
+ .metadata
+ .supported_kinds,
+ Some(vec![KIND_FARM_CRDT_CHANGE])
+ );
+}
+
+fn public_file_metadata() -> RadrootsFileMetadata {
+ RadrootsFileMetadata {
+ url: "https://media.example.test/public.jpg".to_string(),
+ mime_type: "image/jpeg".to_string(),
+ sha256: SHA256.to_string(),
+ original_sha256: Some(OTHER_SHA256.to_string()),
+ size: Some(4096),
+ dimensions: Some(RadrootsSocialMediaDimensions {
+ width: 1200,
+ height: 800,
+ }),
+ blurhash: None,
+ thumbnails: None,
+ summary: Some("Public field photo".to_string()),
+ alt: Some("Rows after harvest".to_string()),
+ fallback: None,
+ magnet: None,
+ content_hashes: None,
+ services: None,
+ content: Some("caption".to_string()),
+ }
+}
+
+fn private_farm_file_metadata() -> RadrootsFarmFileMetadata {
+ RadrootsFarmFileMetadata {
+ d_tag: D_TAG.to_string(),
+ workspace: RadrootsFarmWorkspaceRef {
+ pubkey: "workspace_pubkey".to_string(),
+ d_tag: D_TAG.to_string(),
+ },
+ farm_group_id: "field-group".to_string(),
+ owner_document_id: D_TAG.to_string(),
+ owner_document_kind: RadrootsFarmCrdtDocumentKind::FarmTask,
+ caption: Some("private caption".to_string()),
+ url: "https://media.example.test/private.jpg".to_string(),
+ mime_type: "image/jpeg".to_string(),
+ sha256: SHA256.to_string(),
+ original_sha256: Some(OTHER_SHA256.to_string()),
+ size_bytes: Some(4096),
+ dimensions: Some(RadrootsFarmFileDimensions { w: 1200, h: 800 }),
+ blurhash: None,
+ thumb: None,
+ image: None,
+ alt: Some("Private farm task attachment".to_string()),
+ fallbacks: Vec::new(),
+ }
+}
diff --git a/crates/events_codec/tests/tag_builders.rs b/crates/events_codec/tests/tag_builders.rs
@@ -7,7 +7,6 @@ use radroots_core::{
RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
use radroots_events::RadrootsNostrEventPtr;
-use radroots_events::RadrootsNostrEventRef;
use radroots_events::app_data::RadrootsAppData;
use radroots_events::comment::RadrootsComment;
use radroots_events::coop::RadrootsCoop;
@@ -24,7 +23,7 @@ use radroots_events::job_feedback::RadrootsJobFeedback;
use radroots_events::job_request::{RadrootsJobInput, RadrootsJobParam, RadrootsJobRequest};
use radroots_events::job_result::RadrootsJobResult;
use radroots_events::kinds::{
- KIND_JOB_FEEDBACK, KIND_JOB_REQUEST_MIN, KIND_JOB_RESULT_MIN, KIND_POST,
+ KIND_ARTICLE, KIND_JOB_FEEDBACK, KIND_JOB_REQUEST_MIN, KIND_JOB_RESULT_MIN,
};
use radroots_events::list::{RadrootsList, RadrootsListEntry};
use radroots_events::list_set::RadrootsListSet;
@@ -44,6 +43,7 @@ use radroots_events::resource_area::{
};
use radroots_events::resource_cap::{RadrootsResourceHarvestCap, RadrootsResourceHarvestProduct};
use radroots_events::seal::RadrootsSeal;
+use radroots_events::social::RadrootsSocialTarget;
use radroots_events_codec::error::EventEncodeError;
use radroots_events_codec::job::encode::JobEncodeError;
use radroots_events_codec::listing::encode::listing_build_tags;
@@ -60,12 +60,11 @@ fn cdn_url(path: &str) -> String {
format!("{CDN_PRIMARY_HTTPS}/{path}")
}
-fn sample_event_ref(id: &str) -> RadrootsNostrEventRef {
- RadrootsNostrEventRef {
+fn sample_social_target(id: &str) -> RadrootsSocialTarget {
+ RadrootsSocialTarget::Event {
id: id.to_string(),
- author: TEST_PUBKEY_HEX.to_string(),
- kind: KIND_POST,
- d_tag: None,
+ author: Some(TEST_PUBKEY_HEX.to_string()),
+ event_kind: Some(KIND_ARTICLE),
relays: None,
}
}
@@ -166,14 +165,20 @@ fn event_tag_builder_impls_build_tags_for_all_supported_types() {
assert!(!app_data.build_tags().unwrap().is_empty());
let comment = RadrootsComment {
- root: sample_event_ref("root"),
- parent: sample_event_ref("parent"),
+ root: sample_social_target(
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ ),
+ parent: sample_social_target(
+ "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
+ ),
content: "hello".to_string(),
};
assert!(!comment.build_tags().unwrap().is_empty());
let reaction = RadrootsReaction {
- root: sample_event_ref("root"),
+ target: sample_social_target(
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ ),
content: "+".to_string(),
};
assert!(!reaction.build_tags().unwrap().is_empty());