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:
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"}"#);
+ }
}