1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
//! [NIP-31] Dealing with unknown event kinds.
//!
//! A custom event kind that is **not** meant to be rendered as plain
//! text (i.e. not a `kind: 1` note) SHOULD ship an `["alt", <summary>]`
//! tag carrying a short human-readable description. The goal is that a
//! `kind: 1`-centric client — used to only display text notes —
//! still has something meaningful to show when a user references an
//! unknown kind from their timeline.
//!
//! This module is both an *index* and a read-side helper:
//!
//! - [`ALT_TAG_KEY`] pins the literal key (`"alt"`) so every read /
//! write path shares the same string constant.
//! - [`alt_description`] returns the first `alt` value in a [`Tags`]
//! list, which is how the spec instructs consumers to read the
//! fallback.
//!
//! The *write* side is handled by the typed constructor
//! [`crate::Tag::alt`], so that all NIP-24 / NIP-31 tag authoring
//! stays inside the single `event::tag` surface rather than
//! ping-ponging between modules.
//!
//! # Usage
//!
//! ```
//! use nula_core::nips::nip31;
//! use nula_core::{EventBuilder, Keys, Kind, Tag};
//!
//! let keys = Keys::generate().unwrap();
//! // A pretend "custom forum post" kind: a `kind: 1`-only client will
//! // show the alt text instead of an opaque JSON blob.
//! let event = EventBuilder::new(Kind::from(30023), "# hello")
//! .tag(Tag::alt("blog post titled ‘hello’"))
//! .sign_with_keys(&keys)
//! .unwrap();
//!
//! assert_eq!(
//! nip31::alt_description(&event.tags),
//! Some("blog post titled ‘hello’"),
//! );
//! ```
//!
//! [NIP-31]: https://github.com/nostr-protocol/nips/blob/master/31.md
use crate::event::{TagKind, Tags};
/// Literal tag key used by NIP-31 fallback descriptions.
pub const ALT_TAG_KEY: &str = "alt";
/// Return the first `alt` tag's description, if any.
///
/// NIP-31 does not forbid multiple `alt` tags on the same event but
/// only the first one has a defined meaning; later duplicates are
/// ignored here. Tags whose head is not exactly `"alt"` are skipped;
/// tags with the right head but no argument value (a stray `["alt"]`
/// on the wire) return [`None`].
#[must_use]
pub fn alt_description(tags: &Tags) -> Option<&str> {
for tag in tags {
if !is_alt_tag(&tag.kind()) {
continue;
}
if let Some(description) = tag.values().get(1) {
return Some(description.as_str());
}
}
None
}
fn is_alt_tag(kind: &TagKind) -> bool {
matches!(kind, TagKind::Custom(s) if s == ALT_TAG_KEY)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Kind, Tag};
use crate::{EventBuilder, Keys};
fn fixture_keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
#[test]
fn tag_constructor_shape_matches_spec() {
let tag = Tag::alt("short summary");
assert_eq!(tag.values(), &["alt".to_owned(), "short summary".into()]);
}
#[test]
fn reads_back_first_alt_description_from_event() {
let keys = fixture_keys();
let event = EventBuilder::new(Kind::from(30023), "{body}")
.tag(Tag::alt("a blog post"))
.sign_with_keys(&keys)
.unwrap();
assert_eq!(alt_description(&event.tags), Some("a blog post"));
}
#[test]
fn returns_none_when_no_alt_tag_is_present() {
let keys = fixture_keys();
let event = EventBuilder::new(Kind::from(30023), "{body}")
.sign_with_keys(&keys)
.unwrap();
assert_eq!(alt_description(&event.tags), None);
}
#[test]
fn duplicate_alt_tags_prefer_the_first_occurrence() {
let mut tags = Tags::new();
tags.push(Tag::alt("primary"));
tags.push(Tag::alt("secondary"));
assert_eq!(alt_description(&tags), Some("primary"));
}
#[test]
fn bare_alt_head_without_value_returns_none() {
let mut tags = Tags::new();
// A malformed `["alt"]` with no description argument.
tags.push(Tag::new(["alt"]).unwrap());
assert_eq!(alt_description(&tags), None);
}
}