1use thiserror::Error;
12
13use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind};
14use crate::key::{PublicKey, PublicKeyError};
15use crate::nips::nip92::{MediaAttachment, MediaAttachmentError};
16use crate::types::{RelayUrl, RelayUrlError};
17
18pub const KIND_PICTURE: Kind = Kind::PICTURE;
20
21const TITLE_TAG: &str = "title";
22const CONTENT_WARNING_TAG: &str = "content-warning";
23const LOCATION_TAG: &str = "location";
24
25pub const SUPPORTED_MIME_TYPES: &[&str] = &[
28 "image/apng",
29 "image/avif",
30 "image/gif",
31 "image/jpeg",
32 "image/png",
33 "image/webp",
34];
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PictureTaggedUser {
39 pub pubkey: PublicKey,
41 pub relay_hint: Option<RelayUrl>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Default)]
47pub struct PicturePost {
48 pub title: Option<String>,
50 pub description: String,
52 pub pictures: Vec<MediaAttachment>,
54 pub content_warning: Option<String>,
56 pub tagged_users: Vec<PictureTaggedUser>,
58 pub media_types: Vec<String>,
60 pub hashes: Vec<String>,
62 pub hashtags: Vec<String>,
64 pub location: Option<String>,
66 pub geohash: Option<String>,
68 pub language_labels: Vec<String>,
70 pub extra_tags: Vec<Tag>,
72}
73
74#[derive(Debug, Error)]
76#[non_exhaustive]
77pub enum PictureError {
78 #[error("unexpected kind for NIP-68 picture: {}", .0.as_u16())]
80 WrongKind(Kind),
81 #[error("`p` tag missing user pubkey")]
83 MalformedTaggedUser,
84 #[error(transparent)]
86 InvalidPublicKey(#[from] PublicKeyError),
87 #[error(transparent)]
89 InvalidRelayUrl(#[from] RelayUrlError),
90 #[error(transparent)]
92 InvalidMediaAttachment(#[from] MediaAttachmentError),
93}
94
95impl PictureTaggedUser {
96 #[must_use]
98 pub const fn new(pubkey: PublicKey) -> Self {
99 Self {
100 pubkey,
101 relay_hint: None,
102 }
103 }
104
105 fn to_tag(&self) -> Tag {
106 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
107 self.relay_hint.as_ref().map_or_else(
108 || Tag::with(&head, [self.pubkey.to_hex()]),
109 |relay| Tag::with(&head, [self.pubkey.to_hex(), relay.as_str().to_owned()]),
110 )
111 }
112
113 fn from_tag(tag: &Tag) -> Result<Self, PictureError> {
114 let pk_hex = tag.get(1).ok_or(PictureError::MalformedTaggedUser)?;
115 let pubkey = PublicKey::parse(pk_hex)?;
116 let relay_hint = match tag.get(2) {
117 Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
118 _ => None,
119 };
120 Ok(Self { pubkey, relay_hint })
121 }
122}
123
124impl PicturePost {
125 #[must_use]
127 pub fn new(description: impl Into<String>, pictures: Vec<MediaAttachment>) -> Self {
128 Self {
129 description: description.into(),
130 pictures,
131 ..Self::default()
132 }
133 }
134
135 #[must_use]
137 pub fn title(mut self, title: impl Into<String>) -> Self {
138 self.title = Some(title.into());
139 self
140 }
141
142 #[must_use]
144 pub fn hashtag(mut self, tag: impl Into<String>) -> Self {
145 self.hashtags.push(tag.into().to_ascii_lowercase());
146 self
147 }
148
149 pub fn from_event(event: &Event) -> Result<Self, PictureError> {
155 if event.kind != KIND_PICTURE {
156 return Err(PictureError::WrongKind(event.kind));
157 }
158 let mut out = Self {
159 description: event.content.clone(),
160 ..Self::default()
161 };
162 for tag in &event.tags {
163 absorb_tag(tag, &mut out)?;
164 }
165 Ok(out)
166 }
167}
168
169fn absorb_tag(tag: &Tag, out: &mut PicturePost) -> Result<(), PictureError> {
170 match tag.kind() {
171 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
172 out.tagged_users.push(PictureTaggedUser::from_tag(tag)?);
173 }
174 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
175 if let Some(raw) = tag.get(1) {
176 out.hashtags.push(raw.to_ascii_lowercase());
177 }
178 }
179 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::G => {
180 out.geohash = tag.get(1).map(str::to_owned);
181 }
182 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::X => {
183 if let Some(raw) = tag.get(1) {
184 out.hashes.push(raw.to_owned());
185 }
186 }
187 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::M => {
188 if let Some(raw) = tag.get(1) {
189 out.media_types.push(raw.to_owned());
190 }
191 }
192 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::L => {
193 if let Some(raw) = tag.get(1) {
194 out.language_labels.push(raw.to_owned());
195 }
196 }
197 TagKind::SingleLetter(s) if s.uppercase && s.character == Alphabet::L => {
198 if let Some(raw) = tag.get(1) {
199 out.language_labels.push(raw.to_owned());
200 }
201 }
202 _ if tag.name() == "imeta" => {
203 out.pictures.push(MediaAttachment::from_tag(tag)?);
204 }
205 _ if tag.name() == TITLE_TAG => out.title = tag.get(1).map(str::to_owned),
206 _ if tag.name() == CONTENT_WARNING_TAG => {
207 out.content_warning = tag.get(1).map(str::to_owned);
208 }
209 _ if tag.name() == LOCATION_TAG => out.location = tag.get(1).map(str::to_owned),
210 _ => out.extra_tags.push(tag.clone()),
211 }
212 Ok(())
213}
214
215impl EventBuilder {
216 pub fn picture_post(post: &PicturePost) -> Result<Self, PictureError> {
223 let mut builder = Self::new(KIND_PICTURE, post.description.clone());
224 if let Some(title) = &post.title {
225 builder = builder.tag(Tag::with(&TagKind::from_wire(TITLE_TAG), [title.clone()]));
226 }
227 for picture in &post.pictures {
228 builder = builder.tag(picture.to_tag()?);
229 }
230 if let Some(reason) = &post.content_warning {
231 builder = builder.tag(Tag::with(
232 &TagKind::from_wire(CONTENT_WARNING_TAG),
233 [reason.clone()],
234 ));
235 }
236 for user in &post.tagged_users {
237 builder = builder.tag(user.to_tag());
238 }
239 for mime in &post.media_types {
240 builder = builder.tag(Tag::with(
241 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::M)),
242 [mime.clone()],
243 ));
244 }
245 for hash in &post.hashes {
246 builder = builder.tag(Tag::with(
247 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::X)),
248 [hash.clone()],
249 ));
250 }
251 for hashtag in &post.hashtags {
252 builder = builder.tag(Tag::t(hashtag));
253 }
254 if let Some(location) = &post.location {
255 builder = builder.tag(Tag::with(
256 &TagKind::from_wire(LOCATION_TAG),
257 [location.clone()],
258 ));
259 }
260 if let Some(geohash) = &post.geohash {
261 builder = builder.tag(Tag::with(
262 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::G)),
263 [geohash.clone()],
264 ));
265 }
266 for tag in &post.extra_tags {
267 builder = builder.tag(tag.clone());
268 }
269 Ok(builder)
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::Keys;
277 use crate::types::Url;
278
279 fn keys() -> Keys {
280 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
281 }
282
283 fn sample_picture() -> MediaAttachment {
284 MediaAttachment::new(Url::parse("https://nostr.build/i/photo.jpg").unwrap())
285 .mime_type("image/jpeg")
286 .alt("scenic photo")
287 }
288
289 #[test]
290 fn picture_post_round_trip() {
291 let post = PicturePost::new("a marvelous photo", vec![sample_picture()])
292 .title("Sunset")
293 .hashtag("photo");
294 let event = EventBuilder::picture_post(&post)
295 .unwrap()
296 .sign_with_keys(&keys())
297 .unwrap();
298 let parsed = PicturePost::from_event(&event).unwrap();
299 assert_eq!(parsed.title.as_deref(), Some("Sunset"));
300 assert_eq!(parsed.pictures.len(), 1);
301 assert_eq!(parsed.hashtags, vec!["photo".to_owned()]);
302 }
303
304 #[test]
305 fn wrong_kind_is_rejected() {
306 let event = EventBuilder::text_note("nope")
307 .sign_with_keys(&keys())
308 .unwrap();
309 assert!(matches!(
310 PicturePost::from_event(&event),
311 Err(PictureError::WrongKind(_))
312 ));
313 }
314
315 #[test]
316 fn tagged_user_with_relay_hint_round_trips() {
317 let bare = PictureTaggedUser::new(*keys().public_key());
321 let hinted = PictureTaggedUser {
322 pubkey: *keys().public_key(),
323 relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
324 };
325 let post = PicturePost::new("with tags", vec![sample_picture()])
326 .title("Captioned")
327 ;
329 let mut post = post;
330 post.tagged_users = vec![bare.clone(), hinted.clone()];
331 let event = EventBuilder::picture_post(&post)
332 .unwrap()
333 .sign_with_keys(&keys())
334 .unwrap();
335 let parsed = PicturePost::from_event(&event).unwrap();
336 assert_eq!(parsed.tagged_users, vec![bare, hinted]);
337 }
338
339 #[test]
340 fn multiple_imeta_pictures_round_trip() {
341 let pic_a = MediaAttachment::new(Url::parse("https://nostr.build/i/a.png").unwrap())
344 .mime_type("image/png");
345 let pic_b = MediaAttachment::new(Url::parse("https://nostr.build/i/b.jpg").unwrap())
346 .mime_type("image/jpeg");
347 let post = PicturePost::new("variants", vec![pic_a, pic_b]);
348 let event = EventBuilder::picture_post(&post)
349 .unwrap()
350 .sign_with_keys(&keys())
351 .unwrap();
352 let parsed = PicturePost::from_event(&event).unwrap();
353 assert_eq!(parsed.pictures.len(), 2);
354 assert_eq!(parsed.pictures[0].mime_type.as_deref(), Some("image/png"));
355 assert_eq!(parsed.pictures[1].mime_type.as_deref(), Some("image/jpeg"));
356 }
357
358 #[test]
359 fn supported_mime_types_match_spec() {
360 assert_eq!(SUPPORTED_MIME_TYPES.len(), 6);
363 assert!(SUPPORTED_MIME_TYPES.contains(&"image/jpeg"));
364 assert!(SUPPORTED_MIME_TYPES.contains(&"image/webp"));
365 assert!(!SUPPORTED_MIME_TYPES.contains(&"image/tiff"));
366 }
367}