1use crate::event::{
39 Alphabet, Coordinate, Event, EventBuilder, EventId, Kind, SingleLetterTag, Tag, TagKind, Tags,
40};
41use crate::key::PublicKey;
42use crate::types::RelayUrl;
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51#[non_exhaustive]
52pub enum Reaction {
53 Like,
55 Dislike,
57 Emoji(String),
61 CustomEmoji(String),
65}
66
67impl Reaction {
68 pub const LIKE: &'static str = "+";
70 pub const DISLIKE: &'static str = "-";
72
73 #[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 #[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 #[must_use]
111 pub const fn is_positive(&self) -> bool {
112 matches!(self, Self::Like)
113 }
114
115 #[must_use]
117 pub const fn is_negative(&self) -> bool {
118 matches!(self, Self::Dislike)
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Hash)]
131pub struct ReactionTarget {
132 pub event_id: EventId,
134 pub author: PublicKey,
136 pub kind: Option<Kind>,
138 pub coordinate: Option<Coordinate>,
140 pub relay_hint: Option<RelayUrl>,
142}
143
144impl ReactionTarget {
145 #[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 #[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 #[must_use]
180 pub fn with_relay_hint(mut self, relay: RelayUrl) -> Self {
181 self.relay_hint = Some(relay);
182 self
183 }
184
185 #[must_use]
188 pub const fn with_kind(mut self, kind: Kind) -> Self {
189 self.kind = Some(kind);
190 self
191 }
192
193 #[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 #[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#[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#[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#[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 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 assert_eq!(reaction.content(), raw);
385 assert_eq!(Reaction::parse(&reaction.content()), reaction);
386 }
387 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 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 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}