1use std::fmt;
23use std::str::FromStr;
24
25use thiserror::Error;
26
27use crate::event::{
28 Alphabet, Event, EventBuilder, EventId, EventIdError, Kind, SingleLetterTag, Tag, TagKind,
29};
30use crate::key::{PublicKey, PublicKeyError};
31use crate::types::{RelayUrl, RelayUrlError};
32
33#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
35#[non_exhaustive]
36pub enum NoteMarker {
37 Root,
39 Reply,
41 Mention,
43}
44
45impl NoteMarker {
46 #[must_use]
48 pub const fn as_str(self) -> &'static str {
49 match self {
50 Self::Root => "root",
51 Self::Reply => "reply",
52 Self::Mention => "mention",
53 }
54 }
55}
56
57impl fmt::Display for NoteMarker {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(self.as_str())
60 }
61}
62
63#[derive(Debug, Clone, Error)]
65#[non_exhaustive]
66pub enum NoteMarkerError {
67 #[error("unknown NIP-10 marker `{0}`")]
69 Unknown(String),
70}
71
72impl FromStr for NoteMarker {
73 type Err = NoteMarkerError;
74
75 fn from_str(s: &str) -> Result<Self, Self::Err> {
76 match s {
77 "root" => Ok(Self::Root),
78 "reply" => Ok(Self::Reply),
79 "mention" => Ok(Self::Mention),
80 other => Err(NoteMarkerError::Unknown(other.to_owned())),
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
87pub struct EventReference {
88 pub event_id: EventId,
90 pub relay_hint: Option<RelayUrl>,
92 pub marker: Option<NoteMarker>,
94 pub author_hint: Option<PublicKey>,
96}
97
98impl EventReference {
99 #[must_use]
101 pub const fn new(event_id: EventId) -> Self {
102 Self {
103 event_id,
104 relay_hint: None,
105 marker: None,
106 author_hint: None,
107 }
108 }
109
110 #[must_use]
112 pub fn with_relay_hint(mut self, relay: RelayUrl) -> Self {
113 self.relay_hint = Some(relay);
114 self
115 }
116
117 #[must_use]
119 pub const fn with_marker(mut self, marker: NoteMarker) -> Self {
120 self.marker = Some(marker);
121 self
122 }
123
124 #[must_use]
126 pub const fn with_author_hint(mut self, author: PublicKey) -> Self {
127 self.author_hint = Some(author);
128 self
129 }
130}
131
132#[derive(Debug, Default, Clone, PartialEq, Eq)]
134pub struct ThreadContext {
135 pub events: Vec<EventReference>,
137 pub mentioned_pubkeys: Vec<PublicKey>,
139}
140
141impl ThreadContext {
142 #[must_use]
144 pub fn new() -> Self {
145 Self::default()
146 }
147
148 #[must_use]
150 pub fn reference(mut self, reference: EventReference) -> Self {
151 self.events.push(reference);
152 self
153 }
154
155 #[must_use]
157 pub fn mention(mut self, pubkey: PublicKey) -> Self {
158 self.mentioned_pubkeys.push(pubkey);
159 self
160 }
161
162 #[must_use]
164 pub fn root(&self) -> Option<&EventReference> {
165 self.events
166 .iter()
167 .find(|r| r.marker == Some(NoteMarker::Root))
168 }
169
170 #[must_use]
172 pub fn reply(&self) -> Option<&EventReference> {
173 self.events
174 .iter()
175 .find(|r| r.marker == Some(NoteMarker::Reply))
176 }
177
178 pub fn mentions(&self) -> impl Iterator<Item = &EventReference> {
180 self.events
181 .iter()
182 .filter(|r| r.marker == Some(NoteMarker::Mention))
183 }
184
185 #[must_use]
204 pub fn infer_legacy_markers(mut self) -> Self {
205 let unmarked: Vec<usize> = self
206 .events
207 .iter()
208 .enumerate()
209 .filter_map(|(i, r)| if r.marker.is_none() { Some(i) } else { None })
210 .collect();
211 let assign = |slot: &mut Self, idx: usize, marker: NoteMarker| {
212 if let Some(r) = slot.events.get_mut(idx) {
213 r.marker = Some(marker);
214 }
215 };
216 match unmarked.as_slice() {
217 [] => {}
218 [only] => assign(&mut self, *only, NoteMarker::Root),
219 [first, middle @ .., last] => {
220 assign(&mut self, *first, NoteMarker::Root);
221 assign(&mut self, *last, NoteMarker::Reply);
222 for &idx in middle {
223 assign(&mut self, idx, NoteMarker::Mention);
224 }
225 }
226 }
227 self
228 }
229
230 #[must_use]
232 pub fn to_tags(&self) -> Vec<Tag> {
233 let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
234 let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
235
236 let mut tags = Vec::with_capacity(self.events.len() + self.mentioned_pubkeys.len());
237 for r in &self.events {
238 tags.push(build_e_tag(&e_kind, r));
239 }
240 for pk in &self.mentioned_pubkeys {
241 tags.push(Tag::with(&p_kind, [pk.to_hex()]));
242 }
243 tags
244 }
245
246 #[must_use]
253 pub fn from_event(event: &Event) -> Self {
254 let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
255 let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
256
257 let mut context = Self::new();
258 for tag in &event.tags {
259 let head = tag.kind();
260 if head == e_kind
261 && let Ok(reference) = EventReference::from_tag(tag)
262 {
263 context.events.push(reference);
264 } else if head == p_kind
265 && let Some(pk) = tag
266 .values()
267 .get(1)
268 .and_then(|s| s.parse::<PublicKey>().ok())
269 {
270 context.mentioned_pubkeys.push(pk);
271 }
272 }
273 context
274 }
275}
276
277impl EventBuilder {
278 #[must_use]
281 pub fn note_with_context<S: Into<String>>(content: S, context: &ThreadContext) -> Self {
282 Self::new(Kind::TEXT_NOTE, content).tags(context.to_tags())
283 }
284}
285
286fn build_e_tag(e_kind: &TagKind, reference: &EventReference) -> Tag {
287 let event_id = reference.event_id.to_hex();
288 let relay = reference
289 .relay_hint
290 .as_ref()
291 .map(|r| r.as_str().to_owned())
292 .unwrap_or_default();
293 let marker = reference
294 .marker
295 .map(|m| m.as_str().to_owned())
296 .unwrap_or_default();
297 let author = reference
298 .author_hint
299 .map(PublicKey::to_hex)
300 .unwrap_or_default();
301
302 if !author.is_empty() {
303 Tag::with(e_kind, [event_id, relay, marker, author])
304 } else if !marker.is_empty() {
305 Tag::with(e_kind, [event_id, relay, marker])
306 } else if !relay.is_empty() {
307 Tag::with(e_kind, [event_id, relay])
308 } else {
309 Tag::with(e_kind, [event_id])
310 }
311}
312
313#[derive(Debug, Clone, Error)]
316#[non_exhaustive]
317pub enum ThreadError {
318 #[error("expected `e` tag, got `{0}`")]
320 NotEventTag(String),
321 #[error("`e` tag is missing the event id")]
323 MissingEventId,
324 #[error(transparent)]
326 InvalidEventId(#[from] EventIdError),
327 #[error(transparent)]
329 InvalidRelay(#[from] RelayUrlError),
330 #[error(transparent)]
332 InvalidMarker(#[from] NoteMarkerError),
333 #[error(transparent)]
335 InvalidAuthor(#[from] PublicKeyError),
336}
337
338impl EventReference {
339 pub fn from_tag(tag: &Tag) -> Result<Self, ThreadError> {
347 let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
348 if tag.kind() != e_kind {
349 return Err(ThreadError::NotEventTag(tag.kind().as_str().to_owned()));
350 }
351 let mut values = tag.values().iter().skip(1);
352 let id = values
353 .next()
354 .ok_or(ThreadError::MissingEventId)?
355 .parse::<EventId>()?;
356 let relay_hint = match values.next() {
357 Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
358 _ => None,
359 };
360 let marker = match values.next() {
361 Some(s) if !s.is_empty() => Some(s.parse::<NoteMarker>()?),
362 _ => None,
363 };
364 let author_hint = match values.next() {
365 Some(s) if !s.is_empty() => Some(s.parse::<PublicKey>()?),
366 _ => None,
367 };
368 Ok(Self {
369 event_id: id,
370 relay_hint,
371 marker,
372 author_hint,
373 })
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use crate::Keys;
381 use crate::types::Timestamp;
382
383 fn keys() -> Keys {
384 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
385 }
386
387 fn event_id(seed: u8) -> EventId {
388 EventId::from_byte_array([seed; 32])
389 }
390
391 fn pk(seed: u8) -> PublicKey {
392 let mut bytes = [0u8; 32];
393 bytes[31] = seed;
394 let sk = crate::SecretKey::from_byte_array(bytes).unwrap();
395 *Keys::from_secret_key(sk).public_key()
396 }
397
398 #[test]
399 fn marker_round_trip() {
400 for marker in [NoteMarker::Root, NoteMarker::Reply, NoteMarker::Mention] {
401 let s = marker.as_str();
402 assert_eq!(s.parse::<NoteMarker>().unwrap(), marker);
403 }
404 }
405
406 #[test]
407 fn marker_rejects_unknown() {
408 let err = "thread".parse::<NoteMarker>().unwrap_err();
409 assert!(matches!(err, NoteMarkerError::Unknown(_)));
410 }
411
412 #[test]
413 fn round_trip_through_event() {
414 let context = ThreadContext::new()
415 .reference(
416 EventReference::new(event_id(0xaa))
417 .with_relay_hint(RelayUrl::parse("wss://relay.example/").unwrap())
418 .with_marker(NoteMarker::Root)
419 .with_author_hint(pk(1)),
420 )
421 .reference(
422 EventReference::new(event_id(0xbb))
423 .with_marker(NoteMarker::Reply)
424 .with_author_hint(pk(2)),
425 )
426 .reference(EventReference::new(event_id(0xcc)).with_marker(NoteMarker::Mention))
427 .mention(pk(3));
428
429 let event = EventBuilder::note_with_context("hi thread", &context)
430 .created_at(Timestamp::from_secs(1))
431 .sign_with_keys(&keys())
432 .unwrap();
433 event.verify().unwrap();
434 let parsed = ThreadContext::from_event(&event);
435 assert_eq!(parsed, context);
436
437 assert_eq!(parsed.root().unwrap().event_id, event_id(0xaa));
438 assert_eq!(parsed.reply().unwrap().event_id, event_id(0xbb));
439 let mentions: Vec<_> = parsed.mentions().collect();
440 assert_eq!(mentions.len(), 1);
441 assert_eq!(mentions[0].event_id, event_id(0xcc));
442 }
443
444 #[test]
445 fn legacy_positional_tags_decode_without_marker() {
446 let event = EventBuilder::text_note("legacy thread")
448 .created_at(Timestamp::from_secs(2))
449 .tag(Tag::new(["e", &event_id(0xaa).to_hex()]).unwrap())
450 .sign_with_keys(&keys())
451 .unwrap();
452 let parsed = ThreadContext::from_event(&event);
453 assert_eq!(parsed.events.len(), 1);
454 assert!(parsed.events[0].marker.is_none());
455 assert!(parsed.root().is_none());
456 }
457
458 #[test]
459 fn malformed_e_tag_is_skipped_in_lenient_parse() {
460 let event = EventBuilder::text_note("bad ref")
461 .created_at(Timestamp::from_secs(3))
462 .tags([
463 Tag::new(["e", "not-a-hex-id"]).unwrap(),
464 Tag::new(["e", &event_id(0x10).to_hex()]).unwrap(),
465 ])
466 .sign_with_keys(&keys())
467 .unwrap();
468 let parsed = ThreadContext::from_event(&event);
469 assert_eq!(parsed.events.len(), 1);
471 }
472
473 #[test]
474 fn from_tag_strict_returns_errors() {
475 let bad = Tag::new(["e", "not-a-hex-id"]).unwrap();
476 let err = EventReference::from_tag(&bad).unwrap_err();
477 assert!(matches!(err, ThreadError::InvalidEventId(_)));
478 }
479
480 #[test]
481 fn from_tag_rejects_non_e_tag() {
482 let tag = Tag::new(["p", &pk(1).to_hex()]).unwrap();
483 let err = EventReference::from_tag(&tag).unwrap_err();
484 assert!(matches!(err, ThreadError::NotEventTag(_)));
485 }
486
487 #[test]
488 fn legacy_positional_single_e_tag_becomes_root() {
489 let context = ThreadContext::new()
490 .reference(EventReference::new(event_id(0xaa)))
491 .infer_legacy_markers();
492 assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
493 assert!(context.reply().is_none());
494 }
495
496 #[test]
497 fn legacy_positional_multi_e_tag_assigns_root_reply_mention() {
498 let context = ThreadContext::new()
499 .reference(EventReference::new(event_id(0xaa)))
500 .reference(EventReference::new(event_id(0xbb)))
501 .reference(EventReference::new(event_id(0xcc)))
502 .reference(EventReference::new(event_id(0xdd)))
503 .infer_legacy_markers();
504 assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
505 assert_eq!(context.events[1].marker, Some(NoteMarker::Mention));
506 assert_eq!(context.events[2].marker, Some(NoteMarker::Mention));
507 assert_eq!(context.events[3].marker, Some(NoteMarker::Reply));
508 }
509
510 #[test]
511 fn legacy_positional_two_e_tags_become_root_and_reply() {
512 let context = ThreadContext::new()
513 .reference(EventReference::new(event_id(0xaa)))
514 .reference(EventReference::new(event_id(0xbb)))
515 .infer_legacy_markers();
516 assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
517 assert_eq!(context.events[1].marker, Some(NoteMarker::Reply));
518 }
519
520 #[test]
521 fn legacy_positional_inference_preserves_existing_markers() {
522 let context = ThreadContext::new()
527 .reference(EventReference::new(event_id(0xaa)).with_marker(NoteMarker::Root))
528 .reference(EventReference::new(event_id(0xbb)))
529 .infer_legacy_markers();
530 assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
531 assert_eq!(context.events[1].marker, Some(NoteMarker::Root));
532 }
533
534 #[test]
535 fn legacy_positional_no_e_tags_is_a_noop() {
536 let context = ThreadContext::new().infer_legacy_markers();
537 assert!(context.events.is_empty());
538 }
539}