lib

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

social_helpers.rs (15643B)


      1 #[cfg(not(feature = "std"))]
      2 use alloc::{format, string::String, vec::Vec};
      3 
      4 use radroots_events::social::{
      5     RadrootsCalendarParticipant, RadrootsSocialFarmAnchor, RadrootsSocialLocation,
      6     RadrootsSocialMediaDimensions, RadrootsSocialMediaThumbnail,
      7 };
      8 
      9 use crate::error::{EventEncodeError, EventParseError};
     10 use crate::field_helpers::{push_tag, push_tag_values, validate_non_empty_field};
     11 
     12 pub(crate) fn validate_http_url(value: &str, field: &'static str) -> Result<(), EventEncodeError> {
     13     if value.starts_with("https://") || value.starts_with("http://") {
     14         validate_non_empty_field(value, field)
     15     } else {
     16         Err(EventEncodeError::InvalidField(field))
     17     }
     18 }
     19 
     20 pub(crate) fn validate_date(value: &str, field: &'static str) -> Result<(), EventEncodeError> {
     21     if is_date(value) {
     22         Ok(())
     23     } else {
     24         Err(EventEncodeError::InvalidField(field))
     25     }
     26 }
     27 
     28 pub(crate) fn validate_date_tag(value: &str, tag: &'static str) -> Result<(), EventParseError> {
     29     if is_date(value) {
     30         Ok(())
     31     } else {
     32         Err(EventParseError::InvalidTag(tag))
     33     }
     34 }
     35 
     36 pub(crate) fn is_date(value: &str) -> bool {
     37     let bytes = value.as_bytes();
     38     bytes.len() == 10
     39         && bytes[0].is_ascii_digit()
     40         && bytes[1].is_ascii_digit()
     41         && bytes[2].is_ascii_digit()
     42         && bytes[3].is_ascii_digit()
     43         && bytes[4] == b'-'
     44         && bytes[5].is_ascii_digit()
     45         && bytes[6].is_ascii_digit()
     46         && bytes[7] == b'-'
     47         && bytes[8].is_ascii_digit()
     48         && bytes[9].is_ascii_digit()
     49 }
     50 
     51 pub(crate) fn validate_end_after_start(
     52     start: u64,
     53     end: Option<u64>,
     54     field: &'static str,
     55 ) -> Result<(), EventEncodeError> {
     56     if end.is_some_and(|end| end < start) {
     57         Err(EventEncodeError::InvalidField(field))
     58     } else {
     59         Ok(())
     60     }
     61 }
     62 
     63 pub(crate) fn validate_date_end_after_start(
     64     start: &str,
     65     end: Option<&str>,
     66     field: &'static str,
     67 ) -> Result<(), EventEncodeError> {
     68     if end.is_some_and(|end| end < start) {
     69         Err(EventEncodeError::InvalidField(field))
     70     } else {
     71         Ok(())
     72     }
     73 }
     74 
     75 pub(crate) fn push_location_tags(tags: &mut Vec<Vec<String>>, location: &RadrootsSocialLocation) {
     76     if let Some(name) = location
     77         .name
     78         .as_deref()
     79         .filter(|value| !value.trim().is_empty())
     80     {
     81         push_tag(tags, "location", name);
     82     }
     83     if let Some(geohash) = location
     84         .geohash
     85         .as_deref()
     86         .filter(|value| !value.trim().is_empty())
     87     {
     88         push_tag(tags, "g", geohash);
     89     }
     90 }
     91 
     92 pub(crate) fn location_from_tags(tags: &[Vec<String>]) -> Option<RadrootsSocialLocation> {
     93     let name = first_tag_value(tags, "location");
     94     let geohash = first_tag_value(tags, "g");
     95     if name.is_none() && geohash.is_none() {
     96         None
     97     } else {
     98         Some(RadrootsSocialLocation { name, geohash })
     99     }
    100 }
    101 
    102 pub(crate) fn push_farm_anchor(tags: &mut Vec<Vec<String>>, farm: &RadrootsSocialFarmAnchor) {
    103     if farm.farm.pubkey.trim().is_empty() || farm.farm.d_tag.trim().is_empty() {
    104         return;
    105     }
    106     let address = format!("30340:{}:{}", farm.farm.pubkey, farm.farm.d_tag);
    107     push_tag(tags, "a", address);
    108 }
    109 
    110 pub(crate) fn participants_from_tags(
    111     tags: &[Vec<String>],
    112 ) -> Option<Vec<RadrootsCalendarParticipant>> {
    113     let participants = tags
    114         .iter()
    115         .filter(|tag| tag.first().map(|value| value.as_str()) == Some("p"))
    116         .filter_map(|tag| {
    117             let pubkey = tag.get(1)?.clone();
    118             if pubkey.trim().is_empty() {
    119                 return None;
    120             }
    121             Some(RadrootsCalendarParticipant {
    122                 pubkey,
    123                 relay: tag.get(2).filter(|value| !value.trim().is_empty()).cloned(),
    124                 role: tag.get(3).filter(|value| !value.trim().is_empty()).cloned(),
    125             })
    126         })
    127         .collect::<Vec<_>>();
    128     if participants.is_empty() {
    129         None
    130     } else {
    131         Some(participants)
    132     }
    133 }
    134 
    135 pub(crate) fn push_participants(
    136     tags: &mut Vec<Vec<String>>,
    137     participants: Option<&Vec<RadrootsCalendarParticipant>>,
    138 ) {
    139     let Some(participants) = participants else {
    140         return;
    141     };
    142     for participant in participants {
    143         if participant.pubkey.trim().is_empty() {
    144             continue;
    145         }
    146         let mut tag = vec!["p".to_string(), participant.pubkey.clone()];
    147         if let Some(relay) = participant.relay.as_ref() {
    148             tag.push(relay.clone());
    149         }
    150         if let Some(role) = participant.role.as_ref() {
    151             if participant.relay.is_none() {
    152                 tag.push(String::new());
    153             }
    154             tag.push(role.clone());
    155         }
    156         tags.push(tag);
    157     }
    158 }
    159 
    160 pub(crate) fn first_tag_value(tags: &[Vec<String>], key: &str) -> Option<String> {
    161     tags.iter()
    162         .find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
    163         .and_then(|tag| tag.get(1))
    164         .filter(|value| !value.trim().is_empty())
    165         .cloned()
    166 }
    167 
    168 pub(crate) fn dimensions_tag(dimensions: &RadrootsSocialMediaDimensions) -> String {
    169     format!("{}x{}", dimensions.width, dimensions.height)
    170 }
    171 
    172 pub(crate) fn parse_dimensions_tag(
    173     value: &str,
    174     tag: &'static str,
    175 ) -> Result<RadrootsSocialMediaDimensions, EventParseError> {
    176     let Some((width, height)) = value.split_once('x') else {
    177         return Err(EventParseError::InvalidTag(tag));
    178     };
    179     let width = width
    180         .parse::<u32>()
    181         .map_err(|err| EventParseError::InvalidNumber(tag, err))?;
    182     let height = height
    183         .parse::<u32>()
    184         .map_err(|err| EventParseError::InvalidNumber(tag, err))?;
    185     if width == 0 || height == 0 {
    186         return Err(EventParseError::InvalidTag(tag));
    187     }
    188     Ok(RadrootsSocialMediaDimensions { width, height })
    189 }
    190 
    191 pub(crate) fn push_thumbnail(
    192     tags: &mut Vec<Vec<String>>,
    193     thumbnail: &RadrootsSocialMediaThumbnail,
    194 ) {
    195     if thumbnail.url.trim().is_empty() {
    196         return;
    197     }
    198     if let Some(dimensions) = thumbnail.dimensions.as_ref() {
    199         push_tag_values(
    200             tags,
    201             "thumb",
    202             [thumbnail.url.clone(), dimensions_tag(dimensions)],
    203         );
    204     } else {
    205         push_tag(tags, "thumb", thumbnail.url.clone());
    206     }
    207 }
    208 
    209 #[cfg(test)]
    210 mod tests {
    211     use super::*;
    212 
    213     #[test]
    214     fn validates_dates_and_ordered_time_ranges() {
    215         assert!(is_date("2026-06-20"));
    216         assert!(!is_date("2026-6-20"));
    217         for invalid in [
    218             "x026-06-20",
    219             "2x26-06-20",
    220             "20x6-06-20",
    221             "202x-06-20",
    222             "2026/06-20",
    223             "2026-x6-20",
    224             "2026-0x-20",
    225             "2026-06/20",
    226             "2026-06-x0",
    227             "2026-06-2x",
    228         ] {
    229             assert!(!is_date(invalid));
    230         }
    231         assert!(validate_http_url("https://example.test/file", "url").is_ok());
    232         assert!(validate_http_url("http://example.test/file", "url").is_ok());
    233         assert!(matches!(
    234             validate_http_url("ftp://example.test/file", "url"),
    235             Err(EventEncodeError::InvalidField("url"))
    236         ));
    237         assert!(validate_date("2026-06-20", "date").is_ok());
    238         assert!(matches!(
    239             validate_date("bad", "date"),
    240             Err(EventEncodeError::InvalidField("date"))
    241         ));
    242         assert!(validate_date_tag("2026-06-20", "start").is_ok());
    243         assert!(validate_end_after_start(10, Some(10), "end").is_ok());
    244         assert!(validate_end_after_start(10, None, "end").is_ok());
    245         assert!(matches!(
    246             validate_end_after_start(10, Some(9), "end"),
    247             Err(EventEncodeError::InvalidField("end"))
    248         ));
    249         assert!(validate_date_end_after_start("2026-06-20", None, "end").is_ok());
    250         assert!(validate_date_end_after_start("2026-06-20", Some("2026-06-20"), "end").is_ok());
    251         assert!(matches!(
    252             validate_date_end_after_start("2026-06-20", Some("2026-06-19"), "end"),
    253             Err(EventEncodeError::InvalidField("end"))
    254         ));
    255         assert!(matches!(
    256             validate_date_tag("bad", "start"),
    257             Err(EventParseError::InvalidTag("start"))
    258         ));
    259     }
    260 
    261     #[test]
    262     fn encodes_and_decodes_location_participant_and_dimensions_tags() {
    263         let mut tags = Vec::new();
    264         push_location_tags(
    265             &mut tags,
    266             &RadrootsSocialLocation {
    267                 name: Some("Pack shed".to_string()),
    268                 geohash: Some("c23nb62w20st".to_string()),
    269             },
    270         );
    271         push_participants(
    272             &mut tags,
    273             Some(&vec![RadrootsCalendarParticipant {
    274                 pubkey: "crew_pubkey".to_string(),
    275                 relay: None,
    276                 role: Some("participant".to_string()),
    277             }]),
    278         );
    279 
    280         let location = location_from_tags(&tags).expect("location");
    281         assert_eq!(location.name.as_deref(), Some("Pack shed"));
    282         assert_eq!(location.geohash.as_deref(), Some("c23nb62w20st"));
    283         let named_location =
    284             location_from_tags(&[vec!["location".to_string(), "Farm gate".to_string()]])
    285                 .expect("named location");
    286         assert_eq!(named_location.name.as_deref(), Some("Farm gate"));
    287         assert_eq!(named_location.geohash, None);
    288         let geohash_location =
    289             location_from_tags(&[vec!["g".to_string(), "c23nb62w20st".to_string()]])
    290                 .expect("geohash location");
    291         assert_eq!(geohash_location.name, None);
    292         assert_eq!(geohash_location.geohash.as_deref(), Some("c23nb62w20st"));
    293         let participants = participants_from_tags(&tags).expect("participants");
    294         assert_eq!(participants[0].pubkey, "crew_pubkey");
    295         assert_eq!(participants[0].role.as_deref(), Some("participant"));
    296 
    297         let mut empty_tags = Vec::new();
    298         push_location_tags(
    299             &mut empty_tags,
    300             &RadrootsSocialLocation {
    301                 name: Some(" ".to_string()),
    302                 geohash: Some(" ".to_string()),
    303             },
    304         );
    305         assert!(empty_tags.is_empty());
    306         assert_eq!(location_from_tags(&empty_tags), None);
    307         assert_eq!(
    308             first_tag_value(&[vec!["location".to_string()]], "location"),
    309             None
    310         );
    311         assert_eq!(
    312             first_tag_value(&[vec!["location".to_string(), " ".to_string()]], "location"),
    313             None
    314         );
    315 
    316         let mut anchor_tags = Vec::new();
    317         push_farm_anchor(
    318             &mut anchor_tags,
    319             &RadrootsSocialFarmAnchor {
    320                 farm: radroots_events::farm::RadrootsFarmRef {
    321                     pubkey: " ".to_string(),
    322                     d_tag: "farm-d-tag".to_string(),
    323                 },
    324                 relays: None,
    325             },
    326         );
    327         push_farm_anchor(
    328             &mut anchor_tags,
    329             &RadrootsSocialFarmAnchor {
    330                 farm: radroots_events::farm::RadrootsFarmRef {
    331                     pubkey: "farm_pubkey".to_string(),
    332                     d_tag: " ".to_string(),
    333                 },
    334                 relays: None,
    335             },
    336         );
    337         push_farm_anchor(
    338             &mut anchor_tags,
    339             &RadrootsSocialFarmAnchor {
    340                 farm: radroots_events::farm::RadrootsFarmRef {
    341                     pubkey: "farm_pubkey".to_string(),
    342                     d_tag: "farm-d-tag".to_string(),
    343                 },
    344                 relays: None,
    345             },
    346         );
    347         assert_eq!(
    348             anchor_tags,
    349             vec![vec![
    350                 "a".to_string(),
    351                 "30340:farm_pubkey:farm-d-tag".to_string()
    352             ]]
    353         );
    354 
    355         assert_eq!(participants_from_tags(&[]), None);
    356         let participants = participants_from_tags(&[
    357             vec!["p".to_string()],
    358             vec!["p".to_string(), " ".to_string()],
    359             vec![
    360                 "p".to_string(),
    361                 "crew_pubkey".to_string(),
    362                 "wss://relay.example.test".to_string(),
    363                 "host".to_string(),
    364             ],
    365         ])
    366         .expect("participants");
    367         assert_eq!(participants.len(), 1);
    368         assert_eq!(
    369             participants[0].relay.as_deref(),
    370             Some("wss://relay.example.test")
    371         );
    372         assert_eq!(participants[0].role.as_deref(), Some("host"));
    373 
    374         let mut participant_tags = Vec::new();
    375         push_participants(&mut participant_tags, None);
    376         push_participants(
    377             &mut participant_tags,
    378             Some(&vec![
    379                 RadrootsCalendarParticipant {
    380                     pubkey: " ".to_string(),
    381                     relay: None,
    382                     role: None,
    383                 },
    384                 RadrootsCalendarParticipant {
    385                     pubkey: "crew_pubkey".to_string(),
    386                     relay: Some("wss://relay.example.test".to_string()),
    387                     role: Some("host".to_string()),
    388                 },
    389                 RadrootsCalendarParticipant {
    390                     pubkey: "relay_only_pubkey".to_string(),
    391                     relay: Some("wss://relay.example.test".to_string()),
    392                     role: None,
    393                 },
    394             ]),
    395         );
    396         assert_eq!(
    397             participant_tags,
    398             vec![
    399                 vec![
    400                     "p".to_string(),
    401                     "crew_pubkey".to_string(),
    402                     "wss://relay.example.test".to_string(),
    403                     "host".to_string()
    404                 ],
    405                 vec![
    406                     "p".to_string(),
    407                     "relay_only_pubkey".to_string(),
    408                     "wss://relay.example.test".to_string()
    409                 ]
    410             ]
    411         );
    412 
    413         let dimensions = parse_dimensions_tag("1200x800", "dim").unwrap();
    414         assert_eq!(dimensions_tag(&dimensions), "1200x800");
    415         assert!(matches!(
    416             parse_dimensions_tag("0x800", "dim"),
    417             Err(EventParseError::InvalidTag("dim"))
    418         ));
    419         assert!(matches!(
    420             parse_dimensions_tag("1200x0", "dim"),
    421             Err(EventParseError::InvalidTag("dim"))
    422         ));
    423         assert!(matches!(
    424             parse_dimensions_tag("badx800", "dim"),
    425             Err(EventParseError::InvalidNumber("dim", _))
    426         ));
    427         assert!(matches!(
    428             parse_dimensions_tag("1200xbad", "dim"),
    429             Err(EventParseError::InvalidNumber("dim", _))
    430         ));
    431 
    432         let mut thumbnail_tags = Vec::new();
    433         push_thumbnail(
    434             &mut thumbnail_tags,
    435             &RadrootsSocialMediaThumbnail {
    436                 url: " ".to_string(),
    437                 dimensions: None,
    438             },
    439         );
    440         push_thumbnail(
    441             &mut thumbnail_tags,
    442             &RadrootsSocialMediaThumbnail {
    443                 url: "https://media.example.test/thumb.jpg".to_string(),
    444                 dimensions: None,
    445             },
    446         );
    447         push_thumbnail(
    448             &mut thumbnail_tags,
    449             &RadrootsSocialMediaThumbnail {
    450                 url: "https://media.example.test/thumb-large.jpg".to_string(),
    451                 dimensions: Some(RadrootsSocialMediaDimensions {
    452                     width: 320,
    453                     height: 240,
    454                 }),
    455             },
    456         );
    457         assert_eq!(
    458             thumbnail_tags,
    459             vec![
    460                 vec![
    461                     "thumb".to_string(),
    462                     "https://media.example.test/thumb.jpg".to_string()
    463                 ],
    464                 vec![
    465                     "thumb".to_string(),
    466                     "https://media.example.test/thumb-large.jpg".to_string(),
    467                     "320x240".to_string()
    468                 ],
    469             ]
    470         );
    471     }
    472 }