Skip to main content

nula_core/nips/
nip68.rs

1//! [NIP-68] Picture-first feeds.
2//!
3//! `kind: 20` is an Instagram/Flickr/Snapchat/9GAG-style image post.
4//! `.content` carries a free-form description; the picture set lives
5//! in NIP-92 `imeta` tags. The spec restricts the served `m`/`imeta`
6//! MIME types to a small list — we surface the constant
7//! [`SUPPORTED_MIME_TYPES`] for callers to validate against.
8//!
9//! [NIP-68]: https://github.com/nostr-protocol/nips/blob/master/68.md
10
11use 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
18/// `kind: 20` — picture-first event.
19pub 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
25/// Supported `image/*` MIME types per NIP-68. Surfaced as a slice so
26/// callers can validate `MediaAttachment::mime_type` cheaply.
27pub 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/// A `p` tagged user on a picture event.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PictureTaggedUser {
39    /// Tagged user pubkey.
40    pub pubkey: PublicKey,
41    /// Optional recommended relay URL.
42    pub relay_hint: Option<RelayUrl>,
43}
44
45/// Typed bundle for a `kind: 20` picture event.
46#[derive(Debug, Clone, PartialEq, Eq, Default)]
47pub struct PicturePost {
48    /// `title` tag (recommended).
49    pub title: Option<String>,
50    /// Free-form description (mirrors `.content`).
51    pub description: String,
52    /// Picture variants (one or more `imeta` tags).
53    pub pictures: Vec<MediaAttachment>,
54    /// Optional `content-warning` reason for NSFW content.
55    pub content_warning: Option<String>,
56    /// `p` tagged users.
57    pub tagged_users: Vec<PictureTaggedUser>,
58    /// `m` MIME-type filters (de-duped from picture variants).
59    pub media_types: Vec<String>,
60    /// `x` SHA-256 hashes (de-duped from picture variants).
61    pub hashes: Vec<String>,
62    /// `t` hashtags (lower-cased).
63    pub hashtags: Vec<String>,
64    /// Optional `location` city/country line.
65    pub location: Option<String>,
66    /// Optional `g` geohash.
67    pub geohash: Option<String>,
68    /// `L`/`l` ISO-639-1 language labels (raw values).
69    pub language_labels: Vec<String>,
70    /// Forward-compatible passthrough for unknown tags.
71    pub extra_tags: Vec<Tag>,
72}
73
74/// Errors raised while parsing a NIP-68 event.
75#[derive(Debug, Error)]
76#[non_exhaustive]
77pub enum PictureError {
78    /// Event kind is not `20`.
79    #[error("unexpected kind for NIP-68 picture: {}", .0.as_u16())]
80    WrongKind(Kind),
81    /// `p` tag is missing the pubkey column.
82    #[error("`p` tag missing user pubkey")]
83    MalformedTaggedUser,
84    /// Wrapped pubkey parser error.
85    #[error(transparent)]
86    InvalidPublicKey(#[from] PublicKeyError),
87    /// Wrapped relay-URL parser error.
88    #[error(transparent)]
89    InvalidRelayUrl(#[from] RelayUrlError),
90    /// Wrapped imeta parser error.
91    #[error(transparent)]
92    InvalidMediaAttachment(#[from] MediaAttachmentError),
93}
94
95impl PictureTaggedUser {
96    /// Construct a tag without relay hint.
97    #[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    /// Construct a new picture post.
126    #[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    /// Attach a title.
136    #[must_use]
137    pub fn title(mut self, title: impl Into<String>) -> Self {
138        self.title = Some(title.into());
139        self
140    }
141
142    /// Add a hashtag.
143    #[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    /// Parse a `kind: 20` picture event.
150    ///
151    /// # Errors
152    ///
153    /// See [`PictureError`] for the failure modes.
154    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    /// Author a NIP-68 `kind: 20` picture event.
217    ///
218    /// # Errors
219    ///
220    /// Propagates [`MediaAttachmentError`] from any malformed
221    /// [`PicturePost::pictures`] entry.
222    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        // The `p` tag's optional second column carries a relay hint;
318        // confirm both the no-hint and with-hint shapes survive a wire
319        // round-trip.
320        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            // direct construction so the test exercises both shapes.
328            ;
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        // Picture-first posts may carry an alternate-variant set \u2014
342        // every imeta tag becomes one MediaAttachment.
343        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        // The spec enumerates exactly six image MIME types; lock the
361        // ordering and contents so accidental drift is loud.
362        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}