notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

post.rs (7235B)


      1 use enostr::FullKeypair;
      2 use nostrdb::{Note, NoteBuilder, NoteReply};
      3 use std::collections::HashSet;
      4 
      5 use crate::media_upload::Nip94Event;
      6 
      7 pub struct NewPost {
      8     pub content: String,
      9     pub account: FullKeypair,
     10     pub media: Vec<Nip94Event>,
     11 }
     12 
     13 fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> {
     14     builder
     15         .start_tag()
     16         .tag_str("client")
     17         .tag_str("Damus Notedeck")
     18 }
     19 
     20 impl NewPost {
     21     pub fn new(content: String, account: FullKeypair, media: Vec<Nip94Event>) -> Self {
     22         NewPost {
     23             content,
     24             account,
     25             media,
     26         }
     27     }
     28 
     29     pub fn to_note(&self, seckey: &[u8; 32]) -> Note {
     30         let mut content = self.content.clone();
     31         append_urls(&mut content, &self.media);
     32 
     33         let mut builder = add_client_tag(NoteBuilder::new()).kind(1).content(&content);
     34 
     35         for hashtag in Self::extract_hashtags(&self.content) {
     36             builder = builder.start_tag().tag_str("t").tag_str(&hashtag);
     37         }
     38 
     39         if !self.media.is_empty() {
     40             builder = add_imeta_tags(builder, &self.media);
     41         }
     42 
     43         builder.sign(seckey).build().expect("note should be ok")
     44     }
     45 
     46     pub fn to_reply(&self, seckey: &[u8; 32], replying_to: &Note) -> Note {
     47         let mut content = self.content.clone();
     48         append_urls(&mut content, &self.media);
     49 
     50         let builder = add_client_tag(NoteBuilder::new()).kind(1).content(&content);
     51 
     52         let nip10 = NoteReply::new(replying_to.tags());
     53 
     54         let mut builder = if let Some(root) = nip10.root() {
     55             builder
     56                 .start_tag()
     57                 .tag_str("e")
     58                 .tag_str(&hex::encode(root.id))
     59                 .tag_str("")
     60                 .tag_str("root")
     61                 .start_tag()
     62                 .tag_str("e")
     63                 .tag_str(&hex::encode(replying_to.id()))
     64                 .tag_str("")
     65                 .tag_str("reply")
     66                 .sign(seckey)
     67         } else {
     68             // we're replying to a post that isn't in a thread,
     69             // just add a single reply-to-root tag
     70             builder
     71                 .start_tag()
     72                 .tag_str("e")
     73                 .tag_str(&hex::encode(replying_to.id()))
     74                 .tag_str("")
     75                 .tag_str("root")
     76                 .sign(seckey)
     77         };
     78 
     79         let mut seen_p: HashSet<&[u8; 32]> = HashSet::new();
     80 
     81         builder = builder
     82             .start_tag()
     83             .tag_str("p")
     84             .tag_str(&hex::encode(replying_to.pubkey()));
     85 
     86         seen_p.insert(replying_to.pubkey());
     87 
     88         for tag in replying_to.tags() {
     89             if tag.count() < 2 {
     90                 continue;
     91             }
     92 
     93             if tag.get_unchecked(0).variant().str() != Some("p") {
     94                 continue;
     95             }
     96 
     97             let id = if let Some(id) = tag.get_unchecked(1).variant().id() {
     98                 id
     99             } else {
    100                 continue;
    101             };
    102 
    103             if seen_p.contains(id) {
    104                 continue;
    105             }
    106 
    107             seen_p.insert(id);
    108 
    109             builder = builder.start_tag().tag_str("p").tag_str(&hex::encode(id));
    110         }
    111 
    112         if !self.media.is_empty() {
    113             builder = add_imeta_tags(builder, &self.media);
    114         }
    115 
    116         builder
    117             .sign(seckey)
    118             .build()
    119             .expect("expected build to work")
    120     }
    121 
    122     pub fn to_quote(&self, seckey: &[u8; 32], quoting: &Note) -> Note {
    123         let mut new_content = format!(
    124             "{}\nnostr:{}",
    125             self.content,
    126             enostr::NoteId::new(*quoting.id()).to_bech().unwrap()
    127         );
    128 
    129         append_urls(&mut new_content, &self.media);
    130 
    131         let mut builder = NoteBuilder::new().kind(1).content(&new_content);
    132 
    133         for hashtag in Self::extract_hashtags(&self.content) {
    134             builder = builder.start_tag().tag_str("t").tag_str(&hashtag);
    135         }
    136 
    137         if !self.media.is_empty() {
    138             builder = add_imeta_tags(builder, &self.media);
    139         }
    140 
    141         builder
    142             .start_tag()
    143             .tag_str("q")
    144             .tag_str(&hex::encode(quoting.id()))
    145             .start_tag()
    146             .tag_str("p")
    147             .tag_str(&hex::encode(quoting.pubkey()))
    148             .sign(seckey)
    149             .build()
    150             .expect("expected build to work")
    151     }
    152 
    153     fn extract_hashtags(content: &str) -> HashSet<String> {
    154         let mut hashtags = HashSet::new();
    155         for word in
    156             content.split(|c: char| c.is_whitespace() || (c.is_ascii_punctuation() && c != '#'))
    157         {
    158             if word.starts_with('#') && word.len() > 1 {
    159                 let tag = word[1..].to_lowercase();
    160                 if !tag.is_empty() {
    161                     hashtags.insert(tag);
    162                 }
    163             }
    164         }
    165         hashtags
    166     }
    167 }
    168 
    169 fn append_urls(content: &mut String, media: &Vec<Nip94Event>) {
    170     for ev in media {
    171         content.push(' ');
    172         content.push_str(&ev.url);
    173     }
    174 }
    175 
    176 fn add_imeta_tags<'a>(builder: NoteBuilder<'a>, media: &Vec<Nip94Event>) -> NoteBuilder<'a> {
    177     let mut builder = builder;
    178     for item in media {
    179         builder = builder
    180             .start_tag()
    181             .tag_str("imeta")
    182             .tag_str(&format!("url {}", item.url));
    183 
    184         if let Some(ox) = &item.ox {
    185             builder = builder.tag_str(&format!("ox {ox}"));
    186         };
    187         if let Some(x) = &item.x {
    188             builder = builder.tag_str(&format!("x {x}"));
    189         }
    190         if let Some(media_type) = &item.media_type {
    191             builder = builder.tag_str(&format!("m {media_type}"));
    192         }
    193         if let Some(dims) = &item.dimensions {
    194             builder = builder.tag_str(&format!("dim {}x{}", dims.0, dims.1));
    195         }
    196         if let Some(bh) = &item.blurhash {
    197             builder = builder.tag_str(&format!("blurhash {bh}"));
    198         }
    199         if let Some(thumb) = &item.thumb {
    200             builder = builder.tag_str(&format!("thumb {thumb}"));
    201         }
    202     }
    203     builder
    204 }
    205 
    206 #[cfg(test)]
    207 mod tests {
    208     use super::*;
    209 
    210     #[test]
    211     fn test_extract_hashtags() {
    212         let test_cases = vec![
    213             ("Hello #world", vec!["world"]),
    214             ("Multiple #tags #in #one post", vec!["tags", "in", "one"]),
    215             ("No hashtags here", vec![]),
    216             ("#tag1 with #tag2!", vec!["tag1", "tag2"]),
    217             ("Ignore # empty", vec![]),
    218             ("Testing emoji #🍌banana", vec!["🍌banana"]),
    219             ("Testing emoji #🍌", vec!["🍌"]),
    220             ("Duplicate #tag #tag #tag", vec!["tag"]),
    221             ("Mixed case #TaG #tag #TAG", vec!["tag"]),
    222             (
    223                 "#tag1, #tag2, #tag3 with commas",
    224                 vec!["tag1", "tag2", "tag3"],
    225             ),
    226             ("Separated by commas #tag1,#tag2", vec!["tag1", "tag2"]),
    227             ("Separated by periods #tag1.#tag2", vec!["tag1", "tag2"]),
    228             ("Separated by semicolons #tag1;#tag2", vec!["tag1", "tag2"]),
    229         ];
    230 
    231         for (input, expected) in test_cases {
    232             let result = NewPost::extract_hashtags(input);
    233             let expected: HashSet<String> = expected.into_iter().map(String::from).collect();
    234             assert_eq!(result, expected, "Failed for input: {}", input);
    235         }
    236     }
    237 }