Skip to main content

nula_core/nips/
nip25.rs

1//! [NIP-25] Reactions.
2//!
3//! A reaction is a `kind: 7` event ([`crate::Kind::REACTION`]) whose
4//! `content` carries one of:
5//!
6//! - `+` or the empty string — interpreted as a "like" / upvote.
7//! - `-` — interpreted as a "dislike" / downvote.
8//! - Any other text, conventionally a single emoji — interpreted as a
9//!   plain emoji reaction with no like/dislike polarity.
10//! - `:shortcode:` plus an `emoji` tag (see NIP-30) — interpreted as
11//!   a NIP-30 custom emoji.
12//!
13//! The event MUST carry an `e` tag pointing at the reacted event id
14//! and SHOULD carry a `p` tag pointing at its author. NIP-25 also
15//! recommends a `k` tag with the reacted kind, and (for replaceable
16//! events) an `a` tag with the addressable coordinate.
17//!
18//! This module ships:
19//!
20//! - [`Reaction`] — sum type over the four content shapes with
21//!   sniff-friendly `is_positive` / `is_negative` flags so client
22//!   code can drive UX without re-parsing.
23//! - [`ReactionTarget`] — bundle of `(event_id, author, kind?, coord?,
24//!   relay?)` carrying everything NIP-25 wants on the wire.
25//! - [`EventBuilder::reaction`] — typed builder for `kind: 7` that
26//!   pre-populates the `e` / `p` / `k` / `a` tags so callers cannot
27//!   forget the SHOULDs by accident.
28//! - [`target_event_id`] / [`target_pubkey`] / [`target_kind`] — read
29//!   helpers for inbound reaction events.
30//!
31//! External-content reactions (`kind: 17` with NIP-73 `i` / `k` tags)
32//! are deferred to the NIP-73 work item; this module covers the
33//! native-event path that all current clients (Damus, Amethyst,
34//! Coracle, Nostrudel) implement.
35//!
36//! [NIP-25]: https://github.com/nostr-protocol/nips/blob/master/25.md
37
38use crate::event::{
39    Alphabet, Coordinate, Event, EventBuilder, EventId, Kind, SingleLetterTag, Tag, TagKind, Tags,
40};
41use crate::key::PublicKey;
42use crate::types::RelayUrl;
43
44/// The four reaction shapes NIP-25 §Content recognises.
45///
46/// Use [`Reaction::parse`] to discriminate an inbound `content`
47/// string and [`Reaction::content`] to render one back to its wire
48/// form. The parser is lossless: every `&str` round-trips through
49/// `Reaction::parse(_).content()`.
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51#[non_exhaustive]
52pub enum Reaction {
53    /// `+` or empty content — interpreted as a like / upvote.
54    Like,
55    /// `-` — interpreted as a dislike / downvote.
56    Dislike,
57    /// Any other plain text. Convention is a single emoji glyph but
58    /// NIP-25 does not actually constrain the value, so the inner
59    /// `String` is the literal `content` field.
60    Emoji(String),
61    /// `:shortcode:` for a NIP-30 custom emoji. The inner string holds
62    /// the shortcode **without** the surrounding colons; the wire
63    /// form is recovered by [`Reaction::content`].
64    CustomEmoji(String),
65}
66
67impl Reaction {
68    /// Sentinel `content` value for a positive (like / upvote) reaction.
69    pub const LIKE: &'static str = "+";
70    /// Sentinel `content` value for a negative (dislike / downvote) reaction.
71    pub const DISLIKE: &'static str = "-";
72
73    /// Discriminate the shape of a `kind: 7` `content` string.
74    ///
75    /// The classification rules follow NIP-25 §Content verbatim:
76    ///
77    /// - `""` or `"+"` → [`Self::Like`].
78    /// - `"-"` → [`Self::Dislike`].
79    /// - `:shortcode:` (anything wrapped by a leading and trailing
80    ///   colon) → [`Self::CustomEmoji`] with the shortcode body.
81    /// - everything else → [`Self::Emoji`] with the input verbatim.
82    #[must_use]
83    pub fn parse(content: &str) -> Self {
84        if content.is_empty() || content == Self::LIKE {
85            return Self::Like;
86        }
87        if content == Self::DISLIKE {
88            return Self::Dislike;
89        }
90        if let Some(shortcode) = parse_custom_emoji(content) {
91            return Self::CustomEmoji(shortcode.to_owned());
92        }
93        Self::Emoji(content.to_owned())
94    }
95
96    /// Render `self` back to the wire-form `content` string.
97    #[must_use]
98    pub fn content(&self) -> String {
99        match self {
100            Self::Like => Self::LIKE.to_owned(),
101            Self::Dislike => Self::DISLIKE.to_owned(),
102            Self::Emoji(s) => s.clone(),
103            Self::CustomEmoji(shortcode) => format!(":{shortcode}:"),
104        }
105    }
106
107    /// True for [`Self::Like`] only. Plain emoji reactions are
108    /// **not** positive per NIP-25 §Content — they convey emotion
109    /// without polarity.
110    #[must_use]
111    pub const fn is_positive(&self) -> bool {
112        matches!(self, Self::Like)
113    }
114
115    /// True for [`Self::Dislike`] only.
116    #[must_use]
117    pub const fn is_negative(&self) -> bool {
118        matches!(self, Self::Dislike)
119    }
120}
121
122/// What a `kind: 7` reaction points at.
123///
124/// `event_id` and `author` are mandatory because NIP-25 mandates the
125/// `e` and SHOULD-mandates the `p` tags. `kind` is the reacted event's
126/// kind for the optional `k` tag, `coordinate` is set when the
127/// reacted event is addressable (`30000..40000`), and `relay_hint` is
128/// the optional relay-hint slot that goes onto every tag where it is
129/// applicable.
130#[derive(Debug, Clone, PartialEq, Eq, Hash)]
131pub struct ReactionTarget {
132    /// SHA-256 id of the reacted event.
133    pub event_id: EventId,
134    /// Author of the reacted event.
135    pub author: PublicKey,
136    /// Kind of the reacted event, surfaced via the `k` tag.
137    pub kind: Option<Kind>,
138    /// Addressable coordinate for replaceable / addressable events.
139    pub coordinate: Option<Coordinate>,
140    /// Relay hint propagated to the `e` / `a` / `p` tags.
141    pub relay_hint: Option<RelayUrl>,
142}
143
144impl ReactionTarget {
145    /// Build a target from the bare `(event_id, author)` pair.
146    ///
147    /// Use the `with_*` builders to layer on the optional NIP-25
148    /// hints.
149    #[must_use]
150    pub const fn new(event_id: EventId, author: PublicKey) -> Self {
151        Self {
152            event_id,
153            author,
154            kind: None,
155            coordinate: None,
156            relay_hint: None,
157        }
158    }
159
160    /// Capture every NIP-25 hint from a fully-resolved reacted event.
161    ///
162    /// This is the recommended way to construct a target because it
163    /// fills in `kind` and (when the kind is addressable) the
164    /// coordinate from the reacted event's `d` tag.
165    #[must_use]
166    pub fn from_event(event: &Event) -> Self {
167        let mut target = Self::new(event.id, event.pubkey);
168        target.kind = Some(event.kind);
169        if event.kind.is_addressable()
170            && let Some(d) = find_d_tag(&event.tags)
171        {
172            target.coordinate = Some(Coordinate::new(event.kind, event.pubkey, d.to_owned()));
173        }
174        target
175    }
176
177    /// Attach a relay hint that the resulting reaction event will
178    /// propagate onto its `e` / `a` / `p` tags.
179    #[must_use]
180    pub fn with_relay_hint(mut self, relay: RelayUrl) -> Self {
181        self.relay_hint = Some(relay);
182        self
183    }
184
185    /// Override the `kind` field (rarely needed once
186    /// [`Self::from_event`] has populated it).
187    #[must_use]
188    pub const fn with_kind(mut self, kind: Kind) -> Self {
189        self.kind = Some(kind);
190        self
191    }
192
193    /// Override the addressable coordinate.
194    #[must_use]
195    pub fn with_coordinate(mut self, coordinate: Coordinate) -> Self {
196        self.coordinate = Some(coordinate);
197        self
198    }
199}
200
201impl EventBuilder {
202    /// Build a [`Kind::REACTION`] event for `target` carrying
203    /// `reaction` as its content.
204    ///
205    /// The builder pre-populates every NIP-25 SHOULD / MUST tag:
206    ///
207    /// - `["e", <event_id>, <relay>?, <author>]` — pubkey hint at the
208    ///   end matches the spec's example (`tags.append(["e", liked.id,
209    ///   hint, liked.pubkey])`).
210    /// - `["p", <author>, <relay>?]`.
211    /// - `["k", <kind>]` when `target.kind` is set.
212    /// - `["a", <coordinate>, <relay>?]` when `target.coordinate` is
213    ///   set.
214    ///
215    /// Callers are free to add an `["emoji", <shortcode>, <url>]`
216    /// tag for [`Reaction::CustomEmoji`] payloads, but this method
217    /// does not synthesise one because the URL is policy-dependent
218    /// and lives in higher-level NIP-30 code.
219    #[must_use]
220    pub fn reaction(target: &ReactionTarget, reaction: &Reaction) -> Self {
221        let mut tags = Tags::new();
222        tags.push(reaction_e_tag(target));
223        tags.push(reaction_p_tag(target));
224        if let Some(kind) = target.kind {
225            tags.push(Tag::k(kind));
226        }
227        if let Some(coord) = target.coordinate.as_ref() {
228            tags.push(reaction_a_tag(coord, target.relay_hint.as_ref()));
229        }
230        let mut builder = Self::new(Kind::REACTION, reaction.content());
231        for tag in tags {
232            builder = builder.tag(tag);
233        }
234        builder
235    }
236}
237
238fn reaction_e_tag(target: &ReactionTarget) -> Tag {
239    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
240    let relay_slot = target
241        .relay_hint
242        .as_ref()
243        .map(|r| r.as_str().to_owned())
244        .unwrap_or_default();
245    Tag::with(
246        &head,
247        [target.event_id.to_hex(), relay_slot, target.author.to_hex()],
248    )
249}
250
251fn reaction_p_tag(target: &ReactionTarget) -> Tag {
252    target.relay_hint.as_ref().map_or_else(
253        || Tag::p(target.author),
254        |relay| Tag::p_with_relay(target.author, relay),
255    )
256}
257
258fn reaction_a_tag(coord: &Coordinate, relay: Option<&RelayUrl>) -> Tag {
259    relay.map_or_else(|| Tag::a(coord), |r| Tag::a_with_relay(coord, r))
260}
261
262fn find_d_tag(tags: &Tags) -> Option<&str> {
263    for tag in tags {
264        if matches!(tag.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::D && !s.uppercase)
265        {
266            return tag.values().get(1).map(String::as_str);
267        }
268    }
269    None
270}
271
272fn parse_custom_emoji(content: &str) -> Option<&str> {
273    let stripped = content.strip_prefix(':')?.strip_suffix(':')?;
274    if stripped.is_empty() || stripped.contains(':') {
275        return None;
276    }
277    Some(stripped)
278}
279
280/// Extract the reacted event id from a `kind: 7` event's tags.
281///
282/// NIP-25 mandates the `e` tag's last value carries the target event
283/// id, but tolerates earlier `e` tags pointing at unrelated events
284/// (for example, NIP-10 thread context). The helper therefore returns
285/// the **last** `e` tag's id rather than the first, mirroring the
286/// spec's instruction: *"the target event id should be last of the e
287/// tags"*.
288#[must_use]
289pub fn target_event_id(tags: &Tags) -> Option<EventId> {
290    last_single_letter_value(tags, Alphabet::E).and_then(|hex| EventId::parse(hex).ok())
291}
292
293/// Extract the reacted author's pubkey from a `kind: 7` event's tags.
294///
295/// Same "last `p` wins" rule as [`target_event_id`]: NIP-25 says the
296/// target pubkey lives at the *end* of the `p` list when multiple
297/// `p` tags are present.
298#[must_use]
299pub fn target_pubkey(tags: &Tags) -> Option<PublicKey> {
300    last_single_letter_value(tags, Alphabet::P).and_then(|hex| PublicKey::parse(hex).ok())
301}
302
303/// Extract the reacted event kind from the optional `k` tag.
304#[must_use]
305pub fn target_kind(tags: &Tags) -> Option<Kind> {
306    first_single_letter_value(tags, Alphabet::K)
307        .and_then(|raw| raw.parse::<u16>().ok())
308        .map(Kind::new)
309}
310
311fn first_single_letter_value(tags: &Tags, letter: Alphabet) -> Option<&str> {
312    for tag in tags {
313        let TagKind::SingleLetter(s) = tag.kind() else {
314            continue;
315        };
316        if s.character != letter || s.uppercase {
317            continue;
318        }
319        if let Some(value) = tag.values().get(1) {
320            return Some(value.as_str());
321        }
322    }
323    None
324}
325
326fn last_single_letter_value(tags: &Tags, letter: Alphabet) -> Option<&str> {
327    let mut last: Option<&str> = None;
328    for tag in tags {
329        let TagKind::SingleLetter(s) = tag.kind() else {
330            continue;
331        };
332        if s.character != letter || s.uppercase {
333            continue;
334        }
335        if let Some(value) = tag.values().get(1) {
336            last = Some(value.as_str());
337        }
338    }
339    last
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::Keys;
346
347    fn fixture_keys() -> Keys {
348        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
349    }
350
351    fn fixture_target_event() -> Event {
352        let keys = fixture_keys();
353        EventBuilder::text_note("liked post")
354            .sign_with_keys(&keys)
355            .unwrap()
356    }
357
358    #[test]
359    fn parse_classifies_canonical_content_strings() {
360        assert_eq!(Reaction::parse("+"), Reaction::Like);
361        assert_eq!(Reaction::parse(""), Reaction::Like);
362        assert_eq!(Reaction::parse("-"), Reaction::Dislike);
363        assert_eq!(Reaction::parse("🔥"), Reaction::Emoji("🔥".into()));
364        assert_eq!(
365            Reaction::parse(":soapbox:"),
366            Reaction::CustomEmoji("soapbox".into()),
367        );
368    }
369
370    #[test]
371    fn malformed_custom_emoji_falls_back_to_plain_emoji() {
372        // Empty shortcode and stray colons must not promote to CustomEmoji.
373        assert_eq!(Reaction::parse("::"), Reaction::Emoji("::".into()));
374        assert_eq!(Reaction::parse(":a:b:"), Reaction::Emoji(":a:b:".into()),);
375    }
376
377    #[test]
378    fn content_round_trips_through_parse() {
379        for raw in ["+", "-", "🔥", ":soapbox:", "👏", "+1"] {
380            let reaction = Reaction::parse(raw);
381            // For `""` Reaction::Like renders to `"+"`, the canonical
382            // wire form. The other inputs are stable through the round
383            // trip.
384            assert_eq!(reaction.content(), raw);
385            assert_eq!(Reaction::parse(&reaction.content()), reaction);
386        }
387        // Empty content is canonicalised to `+` on the way back.
388        assert_eq!(Reaction::parse("").content(), "+");
389    }
390
391    #[test]
392    fn polarity_flags_only_fire_for_plus_and_minus() {
393        assert!(Reaction::Like.is_positive());
394        assert!(!Reaction::Like.is_negative());
395        assert!(Reaction::Dislike.is_negative());
396        assert!(!Reaction::Dislike.is_positive());
397        assert!(!Reaction::Emoji("👏".into()).is_positive());
398        assert!(!Reaction::Emoji("👏".into()).is_negative());
399        assert!(!Reaction::CustomEmoji("soapbox".into()).is_positive());
400    }
401
402    #[test]
403    fn from_event_extracts_kind_and_coordinate_for_addressable() {
404        let keys = fixture_keys();
405        let event = EventBuilder::new(Kind::LONG_FORM_TEXT_NOTE, "post body")
406            .tag(Tag::d("my-post"))
407            .sign_with_keys(&keys)
408            .unwrap();
409
410        let target = ReactionTarget::from_event(&event);
411        assert_eq!(target.event_id, event.id);
412        assert_eq!(target.author, event.pubkey);
413        assert_eq!(target.kind, Some(Kind::LONG_FORM_TEXT_NOTE));
414        assert_eq!(target.coordinate.as_ref().unwrap().identifier, "my-post");
415    }
416
417    #[test]
418    fn from_event_omits_coordinate_for_regular_kinds() {
419        let event = fixture_target_event();
420        let target = ReactionTarget::from_event(&event);
421        assert_eq!(target.kind, Some(Kind::TEXT_NOTE));
422        assert!(target.coordinate.is_none());
423    }
424
425    #[test]
426    fn reaction_builder_emits_required_tags_and_content() {
427        let keys = fixture_keys();
428        let target_event = fixture_target_event();
429        let target = ReactionTarget::from_event(&target_event);
430
431        let event = EventBuilder::reaction(&target, &Reaction::Like)
432            .sign_with_keys(&keys)
433            .unwrap();
434
435        assert_eq!(event.kind, Kind::REACTION);
436        assert_eq!(event.content, "+");
437        assert_eq!(target_event_id(&event.tags), Some(target_event.id));
438        assert_eq!(target_pubkey(&event.tags), Some(target_event.pubkey));
439        assert_eq!(target_kind(&event.tags), Some(Kind::TEXT_NOTE));
440        event.verify().unwrap();
441    }
442
443    #[test]
444    fn reaction_builder_adds_a_tag_for_addressable_target() {
445        let keys = fixture_keys();
446        let target_event = EventBuilder::new(Kind::LONG_FORM_TEXT_NOTE, "post")
447            .tag(Tag::d("ident"))
448            .sign_with_keys(&keys)
449            .unwrap();
450        let target = ReactionTarget::from_event(&target_event);
451
452        let event = EventBuilder::reaction(&target, &Reaction::Emoji("🔥".into()))
453            .sign_with_keys(&keys)
454            .unwrap();
455
456        assert_eq!(event.content, "🔥");
457        let has_a_tag = event.tags.iter().any(|t| {
458            matches!(
459                t.kind(),
460                TagKind::SingleLetter(s) if s.character == Alphabet::A && !s.uppercase
461            )
462        });
463        assert!(has_a_tag, "addressable reactions must carry an `a` tag");
464    }
465
466    #[test]
467    fn reaction_builder_propagates_relay_hint_to_e_and_p_tags() {
468        let keys = fixture_keys();
469        let relay = RelayUrl::parse("wss://relay.example/").unwrap();
470        let target_event = fixture_target_event();
471        let target = ReactionTarget::from_event(&target_event).with_relay_hint(relay.clone());
472
473        let event = EventBuilder::reaction(&target, &Reaction::Like)
474            .sign_with_keys(&keys)
475            .unwrap();
476
477        let e_tag = event
478            .tags
479            .iter()
480            .find(|t| matches!(t.kind(), TagKind::SingleLetter(s) if s.character == Alphabet::E))
481            .unwrap();
482        // ["e", id, relay, author] per the NIP-25 spec example.
483        assert_eq!(e_tag.values().len(), 4);
484        assert_eq!(e_tag.get(2), Some(relay.as_str()));
485        assert_eq!(e_tag.get(3), Some(target_event.pubkey.to_hex().as_str()));
486    }
487
488    #[test]
489    fn target_helpers_pick_the_last_e_and_p_tag() {
490        // Construct a synthetic reaction event with thread-context tags
491        // followed by the actual target tags, mirroring the NIP-25
492        // wording.
493        let keys = fixture_keys();
494        let unrelated =
495            EventId::parse("1111111111111111111111111111111111111111111111111111111111111111")
496                .unwrap();
497        let target_event = fixture_target_event();
498        let target = ReactionTarget::from_event(&target_event);
499
500        let event = EventBuilder::new(Kind::REACTION, "+")
501            .tag(Tag::e(unrelated))
502            .tag(Tag::e(target_event.id))
503            .tag(Tag::p(*keys.public_key()))
504            .tag(Tag::p(target.author))
505            .tag(Tag::k(target.kind.unwrap()))
506            .sign_with_keys(&keys)
507            .unwrap();
508
509        assert_eq!(target_event_id(&event.tags), Some(target_event.id));
510        assert_eq!(target_pubkey(&event.tags), Some(target.author));
511        assert_eq!(target_kind(&event.tags), Some(Kind::TEXT_NOTE));
512    }
513}