Skip to main content

nula_core/nips/
nip7d.rs

1//! [NIP-7D] Threads.
2//!
3//! `kind: 11` carries a thread-root post. The spec recommends a `title`
4//! tag but neither requires it nor caps the body length. Replies MUST
5//! use NIP-22 `kind: 1111` comments scoped at the root `kind: 11`.
6//!
7//! [NIP-7D]: https://github.com/nostr-protocol/nips/blob/master/7D.md
8
9use thiserror::Error;
10
11use crate::event::{Event, EventBuilder, Kind, Tag, TagKind};
12
13/// `kind: 11` — thread root.
14pub const KIND_THREAD: Kind = Kind::THREAD;
15
16const TITLE_TAG: &str = "title";
17
18/// Typed bundle for a `kind: 11` thread-root event.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Thread {
21    /// Free-form body of the thread root.
22    pub content: String,
23    /// Optional `title` (recommended by spec).
24    pub title: Option<String>,
25    /// Forward-compatible passthrough for unknown tags.
26    pub extra_tags: Vec<Tag>,
27}
28
29/// Errors raised while parsing a NIP-7D event.
30#[derive(Debug, Error)]
31#[non_exhaustive]
32pub enum ThreadError {
33    /// Event kind is not `11`.
34    #[error("unexpected kind for NIP-7D thread: {}", .0.as_u16())]
35    WrongKind(Kind),
36}
37
38impl Thread {
39    /// Construct a thread-root with no title.
40    #[must_use]
41    pub fn new(content: impl Into<String>) -> Self {
42        Self {
43            content: content.into(),
44            title: None,
45            extra_tags: Vec::new(),
46        }
47    }
48
49    /// Attach a title.
50    #[must_use]
51    pub fn title(mut self, title: impl Into<String>) -> Self {
52        self.title = Some(title.into());
53        self
54    }
55
56    /// Parse a `kind: 11` thread event.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`ThreadError::WrongKind`] when `event.kind != 11`.
61    pub fn from_event(event: &Event) -> Result<Self, ThreadError> {
62        if event.kind != KIND_THREAD {
63            return Err(ThreadError::WrongKind(event.kind));
64        }
65        let mut title: Option<String> = None;
66        let mut extra_tags: Vec<Tag> = Vec::new();
67        for tag in &event.tags {
68            if tag.name() == TITLE_TAG && title.is_none() {
69                title = tag.get(1).map(str::to_owned);
70            } else {
71                extra_tags.push(tag.clone());
72            }
73        }
74        Ok(Self {
75            content: event.content.clone(),
76            title,
77            extra_tags,
78        })
79    }
80}
81
82impl EventBuilder {
83    /// Author a NIP-7D `kind: 11` thread root.
84    #[must_use]
85    pub fn thread(thread: &Thread) -> Self {
86        let mut builder = Self::new(KIND_THREAD, thread.content.clone());
87        if let Some(title) = &thread.title {
88            builder = builder.tag(Tag::with(&TagKind::from_wire(TITLE_TAG), [title.clone()]));
89        }
90        for tag in &thread.extra_tags {
91            builder = builder.tag(tag.clone());
92        }
93        builder
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::Keys;
101
102    fn keys() -> Keys {
103        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
104    }
105
106    #[test]
107    fn thread_round_trip() {
108        let thread = Thread::new("Good morning").title("GM");
109        let event = EventBuilder::thread(&thread)
110            .sign_with_keys(&keys())
111            .unwrap();
112        let parsed = Thread::from_event(&event).unwrap();
113        assert_eq!(parsed, thread);
114    }
115
116    #[test]
117    fn thread_without_title() {
118        let thread = Thread::new("orphan");
119        let event = EventBuilder::thread(&thread)
120            .sign_with_keys(&keys())
121            .unwrap();
122        let parsed = Thread::from_event(&event).unwrap();
123        assert!(parsed.title.is_none());
124    }
125
126    #[test]
127    fn wrong_kind_is_rejected() {
128        let event = EventBuilder::text_note("nope")
129            .sign_with_keys(&keys())
130            .unwrap();
131        assert!(matches!(
132            Thread::from_event(&event),
133            Err(ThreadError::WrongKind(_))
134        ));
135    }
136}