commit 2416d2395020067053afcb1e042a01f59259a40a parent b5c84e8a7c3f3ce943e40066f90aef12bf2ecc62 Author: triesap <tyson@radroots.org> Date: Fri, 12 Jun 2026 02:44:46 -0700 events: add social production models - add repost, generic repost, report, calendar collection, and RSVP models - add listing published_at metadata and relay-list/listing-draft model evidence tests - update existing post and listing constructors for the expanded model fields - verify radroots_events plus dependent events_codec check with serde_json Diffstat:
25 files changed, 342 insertions(+), 1 deletion(-)
diff --git a/crates/events/src/calendar.rs b/crates/events/src/calendar.rs @@ -2,11 +2,30 @@ use alloc::{string::String, vec::Vec}; use crate::social::{ - RadrootsCalendarDateValue, RadrootsCalendarParticipant, RadrootsSocialLocation, + RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus, + RadrootsCalendarParticipant, RadrootsSocialLocation, RadrootsSocialTarget, }; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] +pub struct RadrootsCalendar { + pub d_tag: String, + pub title: String, + pub events: Vec<RadrootsSocialTarget>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub summary: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub image: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] pub struct RadrootsCalendarDateEvent { pub d_tag: String, pub title: String, @@ -86,6 +105,29 @@ pub struct RadrootsCalendarTimeEvent { pub participants: Option<Vec<RadrootsCalendarParticipant>>, } +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsCalendarEventRsvp { + pub d_tag: String, + pub event: RadrootsSocialTarget, + pub status: RadrootsCalendarEventRsvpStatus, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub free_busy: Option<RadrootsCalendarEventFreeBusy>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub note: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub participants: Option<Vec<RadrootsCalendarParticipant>>, +} + #[cfg(all(test, feature = "std", feature = "serde"))] mod tests { use super::*; @@ -138,4 +180,47 @@ mod tests { assert_eq!(event.start_tzid.as_deref(), Some("America/Vancouver")); assert_eq!(event.participants.expect("participants").len(), 1); } + + #[test] + fn calendar_collection_represents_event_address_refs() { + let calendar = RadrootsCalendar { + d_tag: "farm-calendar".to_string(), + title: "farm calendar".to_string(), + events: vec![RadrootsSocialTarget::Address { + address: "31923:pubkey:wash-pack".to_string(), + author: None, + event_kind: Some(31923), + relays: None, + }], + summary: None, + image: None, + }; + + assert_eq!(calendar.d_tag, "farm-calendar"); + assert_eq!(calendar.events.len(), 1); + assert!(matches!( + calendar.events[0], + RadrootsSocialTarget::Address { .. } + )); + } + + #[test] + fn rsvp_represents_status_and_free_busy_state() { + let rsvp = RadrootsCalendarEventRsvp { + d_tag: "rsvp-1".to_string(), + event: RadrootsSocialTarget::Address { + address: "31923:pubkey:wash-pack".to_string(), + author: Some("a".repeat(64)), + event_kind: Some(31923), + relays: None, + }, + status: RadrootsCalendarEventRsvpStatus::Tentative, + free_busy: Some(RadrootsCalendarEventFreeBusy::Busy), + note: Some("depends on harvest".to_string()), + participants: None, + }; + + assert_eq!(rsvp.status, RadrootsCalendarEventRsvpStatus::Tentative); + assert_eq!(rsvp.free_busy, Some(RadrootsCalendarEventFreeBusy::Busy)); + } } diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs @@ -39,6 +39,8 @@ pub mod profile; pub mod reaction; pub mod relay_auth; pub mod relay_document; +pub mod report; +pub mod repost; pub mod resource_area; pub mod resource_cap; pub mod seal; diff --git a/crates/events/src/list.rs b/crates/events/src/list.rs @@ -14,3 +14,31 @@ pub struct RadrootsListEntry { pub tag: String, pub values: Vec<String>, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::kinds::{KIND_LIST_READ_WRITE_RELAYS, is_public_social_kind}; + + #[test] + fn generic_list_model_covers_nip65_relay_entries() { + let list = RadrootsList { + content: String::new(), + entries: vec![ + RadrootsListEntry { + tag: "r".to_string(), + values: vec!["wss://read.example".to_string(), "read".to_string()], + }, + RadrootsListEntry { + tag: "r".to_string(), + values: vec!["wss://write.example".to_string(), "write".to_string()], + }, + ], + }; + + assert_eq!(list.entries.len(), 2); + assert_eq!(list.entries[0].tag, "r"); + assert_eq!(list.entries[0].values[1], "read"); + assert!(is_public_social_kind(KIND_LIST_READ_WRITE_RELAYS)); + } +} diff --git a/crates/events/src/listing.rs b/crates/events/src/listing.rs @@ -55,6 +55,11 @@ pub enum RadrootsListingDeliveryMethod { #[derive(Clone, Debug)] pub struct RadrootsListing { pub d_tag: String, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub published_at: Option<u64>, #[cfg_attr(feature = "serde", serde(default))] pub farm: RadrootsFarmRef, pub product: RadrootsListingProduct, @@ -139,4 +144,39 @@ mod tests { assert!(farm_ref.pubkey.is_empty()); assert!(farm_ref.d_tag.is_empty()); } + + #[test] + fn listing_model_covers_published_draft_metadata() { + use crate::kinds::{KIND_LISTING_DRAFT, is_listing_kind}; + + let listing = super::RadrootsListing { + d_tag: "listing-draft".to_string(), + published_at: Some(1_700_000_000), + farm: RadrootsFarmRef::default(), + product: super::RadrootsListingProduct { + key: "lettuce".to_string(), + title: "lettuce".to_string(), + category: "produce".to_string(), + summary: None, + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".to_string(), + bins: vec![], + resource_area: None, + plot: None, + discounts: None, + inventory_available: None, + availability: None, + delivery_method: None, + location: None, + images: None, + }; + + assert_eq!(listing.published_at, Some(1_700_000_000)); + assert!(is_listing_kind(KIND_LISTING_DRAFT)); + } } diff --git a/crates/events/src/report.rs b/crates/events/src/report.rs @@ -0,0 +1,72 @@ +#[cfg(not(feature = "std"))] +use alloc::string::String; + +use crate::social::{RadrootsReportFileTarget, RadrootsReportType, RadrootsSocialTarget}; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsReport { + pub reported_pubkey: String, + pub report_type: RadrootsReportType, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub event: Option<RadrootsSocialTarget>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub file: Option<RadrootsReportFileTarget>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub content: Option<String>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn report_model_requires_reported_pubkey_field() { + let report = RadrootsReport { + reported_pubkey: "a".repeat(64), + report_type: RadrootsReportType::Spam, + event: Some(RadrootsSocialTarget::Event { + id: "b".repeat(64), + author: Some("a".repeat(64)), + event_kind: Some(1), + relays: None, + }), + file: None, + content: Some("repeated spam".to_string()), + }; + + assert_eq!(report.reported_pubkey.len(), 64); + assert_eq!(report.report_type, RadrootsReportType::Spam); + assert!(matches!( + report.event, + Some(RadrootsSocialTarget::Event { .. }) + )); + } + + #[test] + fn report_model_supports_file_targets_with_required_pubkey() { + let report = RadrootsReport { + reported_pubkey: "a".repeat(64), + report_type: RadrootsReportType::Malware, + event: None, + file: Some(RadrootsReportFileTarget { + sha256: Some("b".repeat(64)), + url: Some("https://example.test/file".to_string()), + magnet: None, + }), + content: None, + }; + + assert_eq!(report.reported_pubkey.len(), 64); + assert_eq!(report.file.expect("file").sha256.expect("hash").len(), 64); + } +} diff --git a/crates/events/src/repost.rs b/crates/events/src/repost.rs @@ -0,0 +1,65 @@ +#[cfg(not(feature = "std"))] +use alloc::string::String; + +use crate::social::RadrootsSocialTarget; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsRepost { + pub target: RadrootsSocialTarget, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub content: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsGenericRepost { + pub target: RadrootsSocialTarget, + pub target_kind: u32, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub content: Option<String>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repost_models_represent_note_and_generic_targets() { + let note_target = RadrootsSocialTarget::Event { + id: "a".repeat(64), + author: Some("b".repeat(64)), + event_kind: Some(1), + relays: None, + }; + let article_target = RadrootsSocialTarget::Address { + address: "30023:pubkey:article".to_string(), + author: Some("b".repeat(64)), + event_kind: Some(30023), + relays: Some(vec!["wss://relay.example".to_string()]), + }; + + let repost = RadrootsRepost { + target: note_target, + content: None, + }; + let generic = RadrootsGenericRepost { + target: article_target, + target_kind: 30023, + content: Some("long-form share".to_string()), + }; + + assert!(matches!(repost.target, RadrootsSocialTarget::Event { .. })); + assert_eq!(generic.target_kind, 30023); + assert!(matches!( + generic.target, + RadrootsSocialTarget::Address { .. } + )); + } +} diff --git a/crates/events_codec/src/farm/mod.rs b/crates/events_codec/src/farm/mod.rs @@ -295,6 +295,7 @@ mod tests { fn farm_listings_list_set_uses_listing_addresses() { let listings = vec![RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), + published_at: None, farm: RadrootsFarmRef { pubkey: "farm_pubkey".to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), diff --git a/crates/events_codec/src/listing/decode.rs b/crates/events_codec/src/listing/decode.rs @@ -490,6 +490,7 @@ pub fn listing_from_event_parts( Ok(RadrootsListing { d_tag, + published_at: None, farm: farm_ref, product, primary_bin_id, diff --git a/crates/events_codec/src/listing/tags.rs b/crates/events_codec/src/listing/tags.rs @@ -680,6 +680,7 @@ mod tests { fn base_listing() -> RadrootsListing { RadrootsListing { d_tag: TEST_D_TAG.to_string(), + published_at: None, farm: RadrootsFarmRef { pubkey: TEST_PUBKEY_HEX.to_string(), d_tag: TEST_FARM_D_TAG.to_string(), diff --git a/crates/events_codec/src/post/decode.rs b/crates/events_codec/src/post/decode.rs @@ -23,6 +23,12 @@ pub fn post_from_content(kind: u32, content: &str) -> Result<RadrootsPost, Event } Ok(RadrootsPost { content: content.to_string(), + farm: None, + address_refs: None, + location: None, + topics: None, + quote_refs: None, + media: None, }) } diff --git a/crates/events_codec/tests/domain_encode_non_serde.rs b/crates/events_codec/tests/domain_encode_non_serde.rs @@ -158,6 +158,7 @@ fn sample_listing() -> RadrootsListing { RadrootsListing { d_tag: VALID_DOC_D_TAG.to_string(), + published_at: None, farm: RadrootsFarmRef { pubkey: VALID_PUBKEY.to_string(), d_tag: VALID_FARM_D_TAG.to_string(), diff --git a/crates/events_codec/tests/listing.rs b/crates/events_codec/tests/listing.rs @@ -35,6 +35,7 @@ fn sample_listing(d_tag: &str) -> RadrootsListing { RadrootsListing { d_tag: d_tag.to_string(), + published_at: None, farm: RadrootsFarmRef { pubkey: "farm_pubkey".to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), @@ -80,6 +81,7 @@ fn sample_listing_full(d_tag: &str) -> RadrootsListing { RadrootsListing { d_tag: d_tag.to_string(), + published_at: None, farm: RadrootsFarmRef { pubkey: "farm_pubkey".to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), diff --git a/crates/events_codec/tests/post.rs b/crates/events_codec/tests/post.rs @@ -10,6 +10,12 @@ use radroots_events_codec::post::encode::to_wire_parts; fn post_to_wire_parts_requires_content() { let post = RadrootsPost { content: " ".to_string(), + farm: None, + address_refs: None, + location: None, + topics: None, + quote_refs: None, + media: None, }; let err = to_wire_parts(&post).unwrap_err(); @@ -23,6 +29,12 @@ fn post_to_wire_parts_requires_content() { fn post_to_wire_parts_sets_kind_and_content() { let post = RadrootsPost { content: "hello".to_string(), + farm: None, + address_refs: None, + location: None, + topics: None, + quote_refs: None, + media: None, }; let parts = to_wire_parts(&post).unwrap(); diff --git a/crates/events_codec/tests/structured_encode_default.rs b/crates/events_codec/tests/structured_encode_default.rs @@ -88,6 +88,7 @@ fn sample_listing(d_tag: &str) -> RadrootsListing { ); RadrootsListing { d_tag: d_tag.to_string(), + published_at: None, farm: RadrootsFarmRef { pubkey: TEST_PUBKEY_HEX.to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), diff --git a/crates/events_codec/tests/tag_builders.rs b/crates/events_codec/tests/tag_builders.rs @@ -115,6 +115,7 @@ fn sample_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), + published_at: None, farm: RadrootsFarmRef { pubkey: TEST_NPUB.to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), @@ -437,6 +438,12 @@ fn event_tag_builder_impls_build_tags_for_all_supported_types() { let post = RadrootsPost { content: "hello".to_string(), + farm: None, + address_refs: None, + location: None, + topics: None, + quote_refs: None, + media: None, }; assert!(post.build_tags().unwrap().is_empty()); } diff --git a/crates/events_codec_wasm/src/lib.rs b/crates/events_codec_wasm/src/lib.rs @@ -350,6 +350,7 @@ mod tests { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), + published_at: None, farm: RadrootsFarmRef { pubkey: "farm_pubkey".to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), diff --git a/crates/nostr/src/event_adapters.rs b/crates/nostr/src/event_adapters.rs @@ -24,6 +24,12 @@ pub fn to_post_event_metadata(e: &RadrootsNostrEvent) -> RadrootsParsedData<Radr e.kind.as_u16() as u32, RadrootsPost { content: e.content.clone(), + farm: None, + address_refs: None, + location: None, + topics: None, + quote_refs: None, + media: None, }, ) } diff --git a/crates/sdk/tests/client.rs b/crates/sdk/tests/client.rs @@ -46,6 +46,7 @@ fn sample_farm() -> RadrootsFarm { fn sample_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(), + published_at: None, farm: RadrootsFarmRef { pubkey: "seller".into(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), diff --git a/crates/sdk/tests/facade.rs b/crates/sdk/tests/facade.rs @@ -46,6 +46,7 @@ fn sample_farm() -> RadrootsFarm { fn sample_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(), + published_at: None, farm: RadrootsFarmRef { pubkey: "seller".into(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), diff --git a/crates/sdk/tests/radrootsd.rs b/crates/sdk/tests/radrootsd.rs @@ -322,6 +322,7 @@ async fn write_http_response( fn sample_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(), + published_at: None, farm: RadrootsFarmRef { pubkey: "seller".into(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), diff --git a/crates/sdk/tests/relay_direct.rs b/crates/sdk/tests/relay_direct.rs @@ -111,6 +111,7 @@ impl Drop for AckRelay { fn sample_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(), + published_at: None, farm: RadrootsFarmRef { pubkey: "seller".into(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), diff --git a/crates/trade/src/listing/overlay.rs b/crates/trade/src/listing/overlay.rs @@ -578,6 +578,7 @@ mod tests { fn base_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(), + published_at: None, farm: RadrootsFarmRef { pubkey: "farm-pubkey".into(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), @@ -643,6 +644,7 @@ mod tests { fn alternate_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(), + published_at: None, farm: RadrootsFarmRef { pubkey: "farm-pubkey-2".into(), d_tag: "AAAAAAAAAAAAAAAAAAAABA".into(), diff --git a/crates/trade/src/listing/projection.rs b/crates/trade/src/listing/projection.rs @@ -1825,6 +1825,7 @@ mod tests { fn base_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(), + published_at: None, farm: RadrootsFarmRef { pubkey: "farm-pubkey".into(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), @@ -1986,6 +1987,7 @@ mod tests { fn alternate_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(), + published_at: None, farm: RadrootsFarmRef { pubkey: "farm-pubkey-2".into(), d_tag: "AAAAAAAAAAAAAAAAAAAABA".into(), diff --git a/crates/trade/src/listing/publish.rs b/crates/trade/src/listing/publish.rs @@ -76,6 +76,7 @@ mod tests { fn base_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(), + published_at: None, farm: RadrootsFarmRef { pubkey: String::new(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs @@ -182,6 +182,7 @@ mod tests { fn base_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(), + published_at: None, farm: RadrootsFarmRef { pubkey: "seller".into(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),