lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit b5c84e8a7c3f3ce943e40066f90aef12bf2ecc62
parent fd923f4c367b6a12dee01dea71cb04ed21759f49
Author: triesap <tyson@radroots.org>
Date:   Fri, 12 Jun 2026 02:40:18 -0700

events: add social mvp models

- expand posts with optional social metadata while preserving content-only JSON
- add article and public generic file metadata event models
- add calendar date and time event models with date, timezone, and participant fields
- verify radroots_events with fmt, check, targeted tests, full tests, and no-default-features check

Diffstat:
Acrates/events/src/article.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events/src/calendar.rs | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events/src/file_metadata.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events/src/lib.rs | 3+++
Mcrates/events/src/post.rs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 392 insertions(+), 1 deletion(-)

diff --git a/crates/events/src/article.rs b/crates/events/src/article.rs @@ -0,0 +1,70 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use crate::social::{RadrootsSocialFarmAnchor, RadrootsSocialLocation}; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsArticle { + pub d_tag: String, + pub title: String, + pub content: String, + #[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", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub published_at: Option<u64>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub farm: Option<RadrootsSocialFarmAnchor>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub location: Option<RadrootsSocialLocation>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub topics: Option<Vec<String>>, +} + +#[cfg(all(test, feature = "std", feature = "serde"))] +mod tests { + use super::*; + + #[test] + fn article_represents_required_nip23_fields() { + let article = RadrootsArticle { + d_tag: "soil-notes".to_string(), + title: "soil notes".to_string(), + content: "# soil notes".to_string(), + summary: None, + image: None, + published_at: Some(1_700_000_000), + farm: None, + location: Some(RadrootsSocialLocation { + name: Some("field edge".to_string()), + geohash: Some("c23nb62w20st".to_string()), + }), + topics: Some(vec!["soil".to_string(), "cover-crops".to_string()]), + }; + + assert_eq!(article.d_tag, "soil-notes"); + assert_eq!(article.title, "soil notes"); + assert_eq!(article.published_at, Some(1_700_000_000)); + assert_eq!(article.topics.as_ref().expect("topics").len(), 2); + } +} diff --git a/crates/events/src/calendar.rs b/crates/events/src/calendar.rs @@ -0,0 +1,141 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use crate::social::{ + RadrootsCalendarDateValue, RadrootsCalendarParticipant, RadrootsSocialLocation, +}; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsCalendarDateEvent { + pub d_tag: String, + pub title: String, + pub start: String, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub end: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub days: Option<Vec<RadrootsCalendarDateValue>>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub location: Option<RadrootsSocialLocation>, + #[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", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub participants: Option<Vec<RadrootsCalendarParticipant>>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsCalendarTimeEvent { + pub d_tag: String, + pub title: String, + pub start: u64, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub end: Option<u64>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub start_tzid: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub end_tzid: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub location: Option<RadrootsSocialLocation>, + #[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", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub participants: Option<Vec<RadrootsCalendarParticipant>>, +} + +#[cfg(all(test, feature = "std", feature = "serde"))] +mod tests { + use super::*; + + #[test] + fn date_event_represents_all_day_event_fields() { + let event = RadrootsCalendarDateEvent { + d_tag: "market-day".to_string(), + title: "market day".to_string(), + start: "2026-06-20".to_string(), + end: None, + days: Some(vec![RadrootsCalendarDateValue { + value: "2026-06-20".to_string(), + }]), + location: Some(RadrootsSocialLocation { + name: Some("farm stand".to_string()), + geohash: Some("c23nb62w20st".to_string()), + }), + summary: Some("weekly pickup".to_string()), + image: None, + participants: None, + }; + + assert_eq!(event.d_tag, "market-day"); + assert_eq!(event.start, "2026-06-20"); + assert_eq!(event.days.expect("days")[0].value, "2026-06-20"); + } + + #[test] + fn time_event_represents_timestamped_event_fields() { + let event = RadrootsCalendarTimeEvent { + d_tag: "wash-pack".to_string(), + title: "wash pack shift".to_string(), + start: 1_781_895_600, + end: Some(1_781_899_200), + start_tzid: Some("America/Vancouver".to_string()), + end_tzid: Some("America/Vancouver".to_string()), + location: None, + summary: None, + image: None, + participants: Some(vec![RadrootsCalendarParticipant { + pubkey: "a".repeat(64), + relay: None, + role: Some("host".to_string()), + }]), + }; + + assert_eq!(event.start, 1_781_895_600); + assert_eq!(event.end, Some(1_781_899_200)); + assert_eq!(event.start_tzid.as_deref(), Some("America/Vancouver")); + assert_eq!(event.participants.expect("participants").len(), 1); + } +} diff --git a/crates/events/src/file_metadata.rs b/crates/events/src/file_metadata.rs @@ -0,0 +1,107 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use crate::social::{RadrootsSocialMediaDimensions, RadrootsSocialMediaThumbnail}; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsFileMetadata { + pub url: String, + pub mime_type: String, + pub sha256: String, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub original_sha256: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub size: Option<u64>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub dimensions: Option<RadrootsSocialMediaDimensions>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub blurhash: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub thumbnails: Option<Vec<RadrootsSocialMediaThumbnail>>, + #[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 alt: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub fallback: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub magnet: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub content_hashes: Option<Vec<String>>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub services: Option<Vec<String>>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub content: Option<String>, +} + +#[cfg(all(test, feature = "std", feature = "serde"))] +mod tests { + use super::*; + + #[test] + fn file_metadata_represents_required_nip94_fields() { + let metadata = RadrootsFileMetadata { + url: "https://example.test/file.jpg".to_string(), + mime_type: "image/jpeg".to_string(), + sha256: "a".repeat(64), + original_sha256: Some("b".repeat(64)), + size: Some(1024), + dimensions: Some(RadrootsSocialMediaDimensions { + width: 640, + height: 480, + }), + blurhash: None, + thumbnails: None, + summary: Some("field image".to_string()), + alt: Some("rows of lettuce".to_string()), + fallback: None, + magnet: Some("magnet:?xt=urn:btih:abc".to_string()), + content_hashes: Some(vec!["sha256:a".to_string()]), + services: Some(vec!["https://media.example.test".to_string()]), + content: Some("caption".to_string()), + }; + + assert_eq!(metadata.mime_type, "image/jpeg"); + assert_eq!(metadata.sha256.len(), 64); + assert_eq!(metadata.dimensions.expect("dimensions").width, 640); + assert!(metadata.magnet.expect("magnet").starts_with("magnet:")); + assert_eq!(metadata.services.expect("services").len(), 1); + } +} diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs @@ -8,6 +8,8 @@ use alloc::{string::String, vec::Vec}; pub mod account; pub mod app_data; +pub mod article; +pub mod calendar; pub mod comment; pub mod coop; pub mod document; @@ -15,6 +17,7 @@ pub mod farm; pub mod farm_crdt; pub mod farm_file; pub mod farm_workspace; +pub mod file_metadata; pub mod follow; pub mod geochat; pub mod gift_wrap; diff --git a/crates/events/src/post.rs b/crates/events/src/post.rs @@ -1,8 +1,78 @@ #[cfg(not(feature = "std"))] -use alloc::string::String; +use alloc::{string::String, vec::Vec}; + +use crate::social::{ + RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaMetadata, + RadrootsSocialTarget, +}; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct RadrootsPost { pub content: String, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub farm: Option<RadrootsSocialFarmAnchor>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub address_refs: Option<Vec<RadrootsSocialTarget>>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub location: Option<RadrootsSocialLocation>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub topics: Option<Vec<String>>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub quote_refs: Option<Vec<RadrootsSocialTarget>>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub media: Option<Vec<RadrootsSocialMediaMetadata>>, +} + +#[cfg(all(test, feature = "std", feature = "serde"))] +mod tests { + use super::*; + + #[test] + fn content_only_post_deserializes_without_social_metadata() { + let post: RadrootsPost = + serde_json::from_str(r#"{"content":"farm update"}"#).expect("post"); + + assert_eq!(post.content, "farm update"); + assert!(post.farm.is_none()); + assert!(post.address_refs.is_none()); + assert!(post.location.is_none()); + assert!(post.topics.is_none()); + assert!(post.quote_refs.is_none()); + assert!(post.media.is_none()); + } + + #[test] + fn content_only_post_serializes_without_null_social_metadata() { + let post = RadrootsPost { + content: "farm update".to_string(), + farm: None, + address_refs: None, + location: None, + topics: None, + quote_refs: None, + media: None, + }; + + let json = serde_json::to_string(&post).expect("json"); + assert_eq!(json, r#"{"content":"farm update"}"#); + } }