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 }