commit b473e4f6b4cf26434239f65481505eb3ddee2dfd
parent 390a618e592f20acf6f51027e838cf56ba591300
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 03:24:56 -0700
nips: add reaction parser
Diffstat:
1 file changed, 274 insertions(+), 6 deletions(-)
diff --git a/crates/tangle_nips/src/lib.rs b/crates/tangle_nips/src/lib.rs
@@ -8,6 +8,7 @@ use tangle_protocol::{
pub const NIP99_PUBLIC_LISTING_KIND: u32 = 30_402;
pub const NIP99_DRAFT_LISTING_KIND: u32 = 30_403;
pub const NIP22_COMMENT_KIND: u32 = 1_111;
+pub const NIP25_REACTION_KIND: u32 = 7;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedTag {
@@ -555,6 +556,150 @@ fn normalized_optional_hint(
}
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ReactionValue {
+ Like,
+ Dislike,
+ Emoji(String),
+ Text(String),
+}
+
+impl ReactionValue {
+ pub fn canonical(&self) -> &str {
+ match self {
+ Self::Like => "like",
+ Self::Dislike => "dislike",
+ Self::Emoji(_) => "emoji",
+ Self::Text(_) => "text",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ReactionEvent {
+ event_id: EventId,
+ pubkey: PublicKeyHex,
+ created_at: UnixTimestamp,
+ content: String,
+ value: ReactionValue,
+ target_event_id: EventId,
+ target_relay_hint: Option<String>,
+ target_pubkey_hint: Option<PublicKeyHex>,
+ target_pubkey: Option<PublicKeyHex>,
+ target_address: Option<AddressCoordinate>,
+ target_kind: Option<String>,
+}
+
+impl ReactionEvent {
+ pub fn event_id(&self) -> &EventId {
+ &self.event_id
+ }
+
+ pub fn pubkey(&self) -> &PublicKeyHex {
+ &self.pubkey
+ }
+
+ pub fn created_at(&self) -> UnixTimestamp {
+ self.created_at
+ }
+
+ pub fn content(&self) -> &str {
+ &self.content
+ }
+
+ pub fn value(&self) -> &ReactionValue {
+ &self.value
+ }
+
+ pub fn target_event_id(&self) -> &EventId {
+ &self.target_event_id
+ }
+
+ pub fn target_relay_hint(&self) -> Option<&str> {
+ self.target_relay_hint.as_deref()
+ }
+
+ pub fn target_pubkey_hint(&self) -> Option<&PublicKeyHex> {
+ self.target_pubkey_hint.as_ref()
+ }
+
+ pub fn target_pubkey(&self) -> Option<&PublicKeyHex> {
+ self.target_pubkey.as_ref()
+ }
+
+ pub fn target_address(&self) -> Option<&AddressCoordinate> {
+ self.target_address.as_ref()
+ }
+
+ pub fn target_kind(&self) -> Option<&str> {
+ self.target_kind.as_deref()
+ }
+}
+
+pub fn parse_reaction_event(event: &Event) -> Result<Option<ReactionEvent>, String> {
+ if event.unsigned().kind().as_u32() != NIP25_REACTION_KIND {
+ return Ok(None);
+ }
+ let target = last_matching_tag(event, "e")
+ .ok_or_else(|| "reaction event must include an e target tag".to_owned())?;
+ let target_event_id = target
+ .first_value()
+ .ok_or_else(|| "reaction e target tag must include an event id".to_owned())
+ .and_then(EventId::new)?;
+ let target_relay_hint = normalized_optional_hint(target.values().get(1), "reaction", "relay")?;
+ let target_pubkey_hint = target
+ .values()
+ .get(2)
+ .map(|value| parse_pubkey_value(value, "reaction target hint", "pubkey"))
+ .transpose()?;
+ let target_pubkey = last_matching_tag(event, "p")
+ .and_then(|tag| tag.first_value().map(str::to_owned))
+ .map(|value| parse_pubkey_value(&value, "reaction target", "pubkey"))
+ .transpose()?;
+ let target_address = last_matching_tag(event, "a")
+ .and_then(|tag| tag.first_value().map(str::to_owned))
+ .map(|value| AddressCoordinate::from_str(&value))
+ .transpose()?;
+ let target_kind = optional_tag_value(event, "k")?;
+ if target_kind.as_ref().is_some_and(String::is_empty) {
+ return Err("reaction k tag must not be empty".to_owned());
+ }
+ Ok(Some(ReactionEvent {
+ event_id: event.id().clone(),
+ pubkey: event.unsigned().pubkey().clone(),
+ created_at: event.unsigned().created_at(),
+ content: event.unsigned().content().to_owned(),
+ value: reaction_value(event.unsigned().content()),
+ target_event_id,
+ target_relay_hint,
+ target_pubkey_hint,
+ target_pubkey,
+ target_address,
+ target_kind,
+ }))
+}
+
+fn reaction_value(content: &str) -> ReactionValue {
+ match content {
+ "" | "+" => ReactionValue::Like,
+ "-" => ReactionValue::Dislike,
+ value if looks_like_single_emoji(value) => ReactionValue::Emoji(value.to_owned()),
+ value => ReactionValue::Text(value.to_owned()),
+ }
+}
+
+fn looks_like_single_emoji(value: &str) -> bool {
+ let mut chars = value.chars();
+ match (chars.next(), chars.next()) {
+ (Some(character), None) => !character.is_ascii() && !character.is_alphanumeric(),
+ _ => false,
+ }
+}
+
+fn last_matching_tag(event: &Event, name: &str) -> Option<ParsedTag> {
+ matching_tags(event, name).into_iter().last()
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListingKind {
Public,
@@ -1309,12 +1454,13 @@ fn listing_kind_for_event(event: &Event) -> Option<ListingKind> {
mod tests {
use super::{
CommentTarget, DeletionTarget, FulfillmentMethod, ListingEffectiveStatus, ListingKind,
- ListingProjectionEvaluation, ListingUnit, NIP22_COMMENT_KIND, NIP99_PUBLIC_LISTING_KIND,
- evaluate_listing_projection, matching_tags, optional_tag_value, optional_tag_values,
- parse_comment_event, parse_deletion_request, parse_listing_fulfillment,
- parse_listing_identity, parse_listing_location, parse_listing_price, parse_listing_status,
- parse_listing_taxonomy, parse_listing_text, parse_listing_unit, parse_nip50_filter_search,
- parse_nip50_search, parse_relay_auth_event, parse_required_u64_tag, parse_u64_field,
+ ListingProjectionEvaluation, ListingUnit, NIP22_COMMENT_KIND, NIP25_REACTION_KIND,
+ NIP99_PUBLIC_LISTING_KIND, ReactionValue, evaluate_listing_projection, matching_tags,
+ optional_tag_value, optional_tag_values, parse_comment_event, parse_deletion_request,
+ parse_listing_fulfillment, parse_listing_identity, parse_listing_location,
+ parse_listing_price, parse_listing_status, parse_listing_taxonomy, parse_listing_text,
+ parse_listing_unit, parse_nip50_filter_search, parse_nip50_search, parse_reaction_event,
+ parse_relay_auth_event, parse_required_u64_tag, parse_u64_field,
repeated_or_missing_policy_boundary, required_tag_value, required_tag_values,
single_letter_tag_values, single_letter_values_for, tag_count,
};
@@ -1616,6 +1762,128 @@ mod tests {
}
#[test]
+ fn reaction_parser_extracts_addressable_target_and_like_reaction() {
+ let target_event = "2".repeat(EventId::HEX_LENGTH);
+ let previous_event = "3".repeat(EventId::HEX_LENGTH);
+ let target_pubkey = "4".repeat(PublicKeyHex::HEX_LENGTH);
+ let address = format!("30023:{target_pubkey}:article-a");
+ let event = event_with_kind_tags_and_content(
+ NIP25_REACTION_KIND.into(),
+ vec![
+ Tag::from_parts("e", &[&previous_event]).expect("old e"),
+ Tag::from_parts(
+ "e",
+ &[&target_event, "wss://relay.radroots.test", &target_pubkey],
+ )
+ .expect("e"),
+ Tag::from_parts("p", &[&target_pubkey]).expect("p"),
+ Tag::from_parts("a", &[&address]).expect("a"),
+ Tag::from_parts("k", &["30023"]).expect("k"),
+ ],
+ "+",
+ );
+
+ let reaction = parse_reaction_event(&event)
+ .expect("parse")
+ .expect("reaction");
+
+ assert_eq!(reaction.event_id(), event.id());
+ assert_eq!(reaction.pubkey(), event.unsigned().pubkey());
+ assert_eq!(reaction.created_at(), event.unsigned().created_at());
+ assert_eq!(reaction.content(), "+");
+ assert_eq!(reaction.value(), &ReactionValue::Like);
+ assert_eq!(reaction.value().canonical(), "like");
+ assert_eq!(reaction.target_event_id().as_str(), target_event);
+ assert_eq!(
+ reaction.target_relay_hint(),
+ Some("wss://relay.radroots.test")
+ );
+ assert_eq!(
+ reaction.target_pubkey_hint().expect("pubkey hint").as_str(),
+ target_pubkey
+ );
+ assert_eq!(
+ reaction.target_pubkey().expect("target pubkey").as_str(),
+ target_pubkey
+ );
+ assert_eq!(
+ reaction
+ .target_address()
+ .expect("target address")
+ .key()
+ .to_string(),
+ address
+ );
+ assert_eq!(reaction.target_kind(), Some("30023"));
+ }
+
+ #[test]
+ fn reaction_parser_classifies_empty_dislike_emoji_and_text_reactions() {
+ let target_event = "2".repeat(EventId::HEX_LENGTH);
+ let cases = [
+ ("", ReactionValue::Like),
+ ("-", ReactionValue::Dislike),
+ ("⭐", ReactionValue::Emoji("⭐".to_owned())),
+ ("agree", ReactionValue::Text("agree".to_owned())),
+ ];
+ for (content, expected) in cases {
+ let event = event_with_kind_tags_and_content(
+ NIP25_REACTION_KIND.into(),
+ vec![Tag::from_parts("e", &[&target_event]).expect("e")],
+ content,
+ );
+ let reaction = parse_reaction_event(&event)
+ .expect("parse")
+ .expect("reaction");
+
+ assert_eq!(reaction.value(), &expected);
+ }
+ let note = event_with_kind_and_tags(1, Vec::new());
+ assert_eq!(parse_reaction_event(¬e), Ok(None));
+ }
+
+ #[test]
+ fn reaction_parser_rejects_missing_and_malformed_targets() {
+ let bad_event_id = event_with_kind_tags_and_content(
+ NIP25_REACTION_KIND.into(),
+ vec![Tag::from_parts("e", &["bad"]).expect("e")],
+ "+",
+ );
+ let missing_event_id = event_with_kind_tags_and_content(
+ NIP25_REACTION_KIND.into(),
+ vec![Tag::from_parts("e", &[]).expect("e")],
+ "+",
+ );
+ let missing_target = event_with_kind_and_tags(NIP25_REACTION_KIND.into(), Vec::new());
+ let empty_kind = event_with_kind_tags_and_content(
+ NIP25_REACTION_KIND.into(),
+ vec![
+ Tag::from_parts("e", &[&"2".repeat(EventId::HEX_LENGTH)]).expect("e"),
+ Tag::from_parts("k", &[""]).expect("k"),
+ ],
+ "+",
+ );
+
+ assert_eq!(
+ parse_reaction_event(&missing_target).expect_err("missing target"),
+ "reaction event must include an e target tag"
+ );
+ assert!(
+ parse_reaction_event(&bad_event_id)
+ .expect_err("bad event id")
+ .contains("event id")
+ );
+ assert_eq!(
+ parse_reaction_event(&missing_event_id).expect_err("missing event id"),
+ "reaction e target tag must include an event id"
+ );
+ assert_eq!(
+ parse_reaction_event(&empty_kind).expect_err("empty kind"),
+ "reaction k tag must not be empty"
+ );
+ }
+
+ #[test]
fn deletion_request_parser_extracts_event_and_address_targets() {
let target_event_id = "2".repeat(EventId::HEX_LENGTH);
let target_pubkey = "3".repeat(PublicKeyHex::HEX_LENGTH);