Skip to main content

nula_core/nips/
nip36.rs

1//! [NIP-36] Sensitive Content / Content Warning.
2//!
3//! `content-warning` is a single optional tag any kind of event MAY
4//! carry to signal that the body should be hidden behind a click /
5//! tap gate. The spec also lets producers attach NIP-32 `L`/`l`
6//! tags under the [`CONTENT_WARNING_NAMESPACE`] (or any other
7//! ontology such as `social.nos.ontology`) to qualify the reason.
8//!
9//! This module is intentionally tiny:
10//!
11//! - [`ContentWarning`] models the tag's optional reason column.
12//! - [`content_warning_from_tags`] reads the first warning off any
13//!   event so clients do not have to walk the tag list themselves.
14//! - [`Tag::content_warning`](crate::event::Tag::content_warning)
15//!   lives on [`Tag`] and is the canonical builder.
16//!
17//! For richer classification, pair this with NIP-32:
18//! [`crate::nips::nip32::Label`] handles the full `L`/`l` reader and
19//! builder surface.
20//!
21//! [NIP-36]: https://github.com/nostr-protocol/nips/blob/master/36.md
22
23use thiserror::Error;
24
25use crate::event::{Tag, TagKind, Tags};
26
27/// Wire name of the content-warning tag.
28pub const CONTENT_WARNING_TAG: &str = "content-warning";
29
30/// Reserved NIP-32 namespace used to qualify content warnings
31/// (spec §"Example": `["L", "content-warning"]`).
32pub const CONTENT_WARNING_NAMESPACE: &str = "content-warning";
33
34/// Typed view of a `content-warning` tag.
35///
36/// Per spec the reason column is optional — producers MAY publish a
37/// bare `["content-warning"]` tag when the reason is implicit.
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct ContentWarning {
40    /// Human-readable reason (spec §"options.reason").
41    pub reason: Option<String>,
42}
43
44impl ContentWarning {
45    /// Construct a warning without a reason. Equivalent to the bare
46    /// `["content-warning"]` tag.
47    #[must_use]
48    pub const fn unspecified() -> Self {
49        Self { reason: None }
50    }
51
52    /// Construct a warning carrying a free-form reason.
53    #[must_use]
54    pub fn with_reason(reason: impl Into<String>) -> Self {
55        Self {
56            reason: Some(reason.into()),
57        }
58    }
59
60    /// Render as a [`Tag`].
61    #[must_use]
62    pub fn to_tag(&self) -> Tag {
63        let head = TagKind::from_wire(CONTENT_WARNING_TAG);
64        self.reason.as_ref().map_or_else(
65            || Tag::with(&head, std::iter::empty::<String>()),
66            |reason| Tag::with(&head, [reason.clone()]),
67        )
68    }
69
70    /// Parse a single `content-warning` [`Tag`]. The tag head must be
71    /// `content-warning`; column 1 (if present) becomes
72    /// [`Self::reason`].
73    ///
74    /// # Errors
75    ///
76    /// Returns [`ContentWarningError::WrongTag`] when the tag is not
77    /// a `content-warning` tag.
78    pub fn from_tag(tag: &Tag) -> Result<Self, ContentWarningError> {
79        if tag.name() != CONTENT_WARNING_TAG {
80            return Err(ContentWarningError::WrongTag);
81        }
82        let reason = tag.get(1).filter(|s| !s.is_empty()).map(str::to_owned);
83        Ok(Self { reason })
84    }
85}
86
87/// Look up the first `content-warning` tag in `tags`.
88///
89/// Returns `None` when no tag is present. Multiple warnings on a
90/// single event are not defined by spec; only the first one wins.
91#[must_use]
92pub fn content_warning_from_tags(tags: &Tags) -> Option<ContentWarning> {
93    let head = TagKind::from_wire(CONTENT_WARNING_TAG);
94    tags.find_first(&head)
95        .and_then(|tag| ContentWarning::from_tag(tag).ok())
96}
97
98/// Errors raised by [`ContentWarning::from_tag`].
99#[derive(Debug, Error)]
100#[non_exhaustive]
101pub enum ContentWarningError {
102    /// The tag's head was not `content-warning`.
103    #[error("expected `content-warning` tag")]
104    WrongTag,
105}
106
107impl Tag {
108    /// Spec-compliant `content-warning` tag.
109    ///
110    /// Pass `None` for the bare `["content-warning"]` form, or a
111    /// string for `["content-warning", "<reason>"]`.
112    #[must_use]
113    pub fn content_warning(reason: Option<impl Into<String>>) -> Self {
114        let head = TagKind::from_wire(CONTENT_WARNING_TAG);
115        reason.map_or_else(
116            || Self::with(&head, std::iter::empty::<String>()),
117            |r| Self::with(&head, [r.into()]),
118        )
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::EventBuilder;
126    use crate::Keys;
127
128    fn keys() -> Keys {
129        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
130    }
131
132    #[test]
133    fn round_trip_with_reason() {
134        let warning = ContentWarning::with_reason("violent imagery");
135        let tag = warning.to_tag();
136        assert_eq!(tag.name(), CONTENT_WARNING_TAG);
137        assert_eq!(tag.get(1), Some("violent imagery"));
138        let parsed = ContentWarning::from_tag(&tag).unwrap();
139        assert_eq!(parsed, warning);
140    }
141
142    #[test]
143    fn round_trip_without_reason() {
144        let warning = ContentWarning::unspecified();
145        let tag = warning.to_tag();
146        assert_eq!(tag.name(), CONTENT_WARNING_TAG);
147        assert_eq!(tag.get(1), None);
148        let parsed = ContentWarning::from_tag(&tag).unwrap();
149        assert_eq!(parsed, warning);
150    }
151
152    #[test]
153    fn empty_reason_string_is_treated_as_none() {
154        let tag = Tag::with(&TagKind::from_wire(CONTENT_WARNING_TAG), [""]);
155        let parsed = ContentWarning::from_tag(&tag).unwrap();
156        assert_eq!(parsed.reason, None);
157    }
158
159    #[test]
160    fn wrong_tag_is_rejected() {
161        let tag = Tag::title("not a warning");
162        assert!(matches!(
163            ContentWarning::from_tag(&tag),
164            Err(ContentWarningError::WrongTag),
165        ));
166    }
167
168    #[test]
169    fn tag_builder_round_trips_through_event() {
170        let event = EventBuilder::text_note("sensitive")
171            .tag(Tag::content_warning(Some("nsfw")))
172            .sign_with_keys(&keys())
173            .unwrap();
174        let warning = content_warning_from_tags(&event.tags).unwrap();
175        assert_eq!(warning.reason.as_deref(), Some("nsfw"));
176    }
177
178    #[test]
179    fn content_warning_from_tags_returns_none_when_absent() {
180        let event = EventBuilder::text_note("clean")
181            .sign_with_keys(&keys())
182            .unwrap();
183        assert!(content_warning_from_tags(&event.tags).is_none());
184    }
185
186    #[test]
187    fn bare_tag_builder() {
188        let tag = Tag::content_warning(None::<String>);
189        assert_eq!(tag.name(), CONTENT_WARNING_TAG);
190        assert_eq!(tag.values().len(), 1, "only the tag head, no reason");
191    }
192}