use std::fmt;
use std::str::FromStr;
use thiserror::Error;
use crate::event::{
Alphabet, Event, EventBuilder, EventId, EventIdError, Kind, SingleLetterTag, Tag, TagKind,
};
use crate::key::{PublicKey, PublicKeyError};
use crate::types::{RelayUrl, RelayUrlError};
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum NoteMarker {
Root,
Reply,
Mention,
}
impl NoteMarker {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Root => "root",
Self::Reply => "reply",
Self::Mention => "mention",
}
}
}
impl fmt::Display for NoteMarker {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Error)]
#[non_exhaustive]
pub enum NoteMarkerError {
#[error("unknown NIP-10 marker `{0}`")]
Unknown(String),
}
impl FromStr for NoteMarker {
type Err = NoteMarkerError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"root" => Ok(Self::Root),
"reply" => Ok(Self::Reply),
"mention" => Ok(Self::Mention),
other => Err(NoteMarkerError::Unknown(other.to_owned())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EventReference {
pub event_id: EventId,
pub relay_hint: Option<RelayUrl>,
pub marker: Option<NoteMarker>,
pub author_hint: Option<PublicKey>,
}
impl EventReference {
#[must_use]
pub const fn new(event_id: EventId) -> Self {
Self {
event_id,
relay_hint: None,
marker: None,
author_hint: None,
}
}
#[must_use]
pub fn with_relay_hint(mut self, relay: RelayUrl) -> Self {
self.relay_hint = Some(relay);
self
}
#[must_use]
pub const fn with_marker(mut self, marker: NoteMarker) -> Self {
self.marker = Some(marker);
self
}
#[must_use]
pub const fn with_author_hint(mut self, author: PublicKey) -> Self {
self.author_hint = Some(author);
self
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ThreadContext {
pub events: Vec<EventReference>,
pub mentioned_pubkeys: Vec<PublicKey>,
}
impl ThreadContext {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn reference(mut self, reference: EventReference) -> Self {
self.events.push(reference);
self
}
#[must_use]
pub fn mention(mut self, pubkey: PublicKey) -> Self {
self.mentioned_pubkeys.push(pubkey);
self
}
#[must_use]
pub fn root(&self) -> Option<&EventReference> {
self.events
.iter()
.find(|r| r.marker == Some(NoteMarker::Root))
}
#[must_use]
pub fn reply(&self) -> Option<&EventReference> {
self.events
.iter()
.find(|r| r.marker == Some(NoteMarker::Reply))
}
pub fn mentions(&self) -> impl Iterator<Item = &EventReference> {
self.events
.iter()
.filter(|r| r.marker == Some(NoteMarker::Mention))
}
#[must_use]
pub fn infer_legacy_markers(mut self) -> Self {
let unmarked: Vec<usize> = self
.events
.iter()
.enumerate()
.filter_map(|(i, r)| if r.marker.is_none() { Some(i) } else { None })
.collect();
let assign = |slot: &mut Self, idx: usize, marker: NoteMarker| {
if let Some(r) = slot.events.get_mut(idx) {
r.marker = Some(marker);
}
};
match unmarked.as_slice() {
[] => {}
[only] => assign(&mut self, *only, NoteMarker::Root),
[first, middle @ .., last] => {
assign(&mut self, *first, NoteMarker::Root);
assign(&mut self, *last, NoteMarker::Reply);
for &idx in middle {
assign(&mut self, idx, NoteMarker::Mention);
}
}
}
self
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
let mut tags = Vec::with_capacity(self.events.len() + self.mentioned_pubkeys.len());
for r in &self.events {
tags.push(build_e_tag(&e_kind, r));
}
for pk in &self.mentioned_pubkeys {
tags.push(Tag::with(&p_kind, [pk.to_hex()]));
}
tags
}
#[must_use]
pub fn from_event(event: &Event) -> Self {
let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
let mut context = Self::new();
for tag in &event.tags {
let head = tag.kind();
if head == e_kind
&& let Ok(reference) = EventReference::from_tag(tag)
{
context.events.push(reference);
} else if head == p_kind
&& let Some(pk) = tag
.values()
.get(1)
.and_then(|s| s.parse::<PublicKey>().ok())
{
context.mentioned_pubkeys.push(pk);
}
}
context
}
}
impl EventBuilder {
#[must_use]
pub fn note_with_context<S: Into<String>>(content: S, context: &ThreadContext) -> Self {
Self::new(Kind::TEXT_NOTE, content).tags(context.to_tags())
}
}
fn build_e_tag(e_kind: &TagKind, reference: &EventReference) -> Tag {
let event_id = reference.event_id.to_hex();
let relay = reference
.relay_hint
.as_ref()
.map(|r| r.as_str().to_owned())
.unwrap_or_default();
let marker = reference
.marker
.map(|m| m.as_str().to_owned())
.unwrap_or_default();
let author = reference
.author_hint
.map(PublicKey::to_hex)
.unwrap_or_default();
if !author.is_empty() {
Tag::with(e_kind, [event_id, relay, marker, author])
} else if !marker.is_empty() {
Tag::with(e_kind, [event_id, relay, marker])
} else if !relay.is_empty() {
Tag::with(e_kind, [event_id, relay])
} else {
Tag::with(e_kind, [event_id])
}
}
#[derive(Debug, Clone, Error)]
#[non_exhaustive]
pub enum ThreadError {
#[error("expected `e` tag, got `{0}`")]
NotEventTag(String),
#[error("`e` tag is missing the event id")]
MissingEventId,
#[error(transparent)]
InvalidEventId(#[from] EventIdError),
#[error(transparent)]
InvalidRelay(#[from] RelayUrlError),
#[error(transparent)]
InvalidMarker(#[from] NoteMarkerError),
#[error(transparent)]
InvalidAuthor(#[from] PublicKeyError),
}
impl EventReference {
pub fn from_tag(tag: &Tag) -> Result<Self, ThreadError> {
let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
if tag.kind() != e_kind {
return Err(ThreadError::NotEventTag(tag.kind().as_str().to_owned()));
}
let mut values = tag.values().iter().skip(1);
let id = values
.next()
.ok_or(ThreadError::MissingEventId)?
.parse::<EventId>()?;
let relay_hint = match values.next() {
Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
_ => None,
};
let marker = match values.next() {
Some(s) if !s.is_empty() => Some(s.parse::<NoteMarker>()?),
_ => None,
};
let author_hint = match values.next() {
Some(s) if !s.is_empty() => Some(s.parse::<PublicKey>()?),
_ => None,
};
Ok(Self {
event_id: id,
relay_hint,
marker,
author_hint,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
use crate::types::Timestamp;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
fn event_id(seed: u8) -> EventId {
EventId::from_byte_array([seed; 32])
}
fn pk(seed: u8) -> PublicKey {
let mut bytes = [0u8; 32];
bytes[31] = seed;
let sk = crate::SecretKey::from_byte_array(bytes).unwrap();
*Keys::from_secret_key(sk).public_key()
}
#[test]
fn marker_round_trip() {
for marker in [NoteMarker::Root, NoteMarker::Reply, NoteMarker::Mention] {
let s = marker.as_str();
assert_eq!(s.parse::<NoteMarker>().unwrap(), marker);
}
}
#[test]
fn marker_rejects_unknown() {
let err = "thread".parse::<NoteMarker>().unwrap_err();
assert!(matches!(err, NoteMarkerError::Unknown(_)));
}
#[test]
fn round_trip_through_event() {
let context = ThreadContext::new()
.reference(
EventReference::new(event_id(0xaa))
.with_relay_hint(RelayUrl::parse("wss://relay.example/").unwrap())
.with_marker(NoteMarker::Root)
.with_author_hint(pk(1)),
)
.reference(
EventReference::new(event_id(0xbb))
.with_marker(NoteMarker::Reply)
.with_author_hint(pk(2)),
)
.reference(EventReference::new(event_id(0xcc)).with_marker(NoteMarker::Mention))
.mention(pk(3));
let event = EventBuilder::note_with_context("hi thread", &context)
.created_at(Timestamp::from_secs(1))
.sign_with_keys(&keys())
.unwrap();
event.verify().unwrap();
let parsed = ThreadContext::from_event(&event);
assert_eq!(parsed, context);
assert_eq!(parsed.root().unwrap().event_id, event_id(0xaa));
assert_eq!(parsed.reply().unwrap().event_id, event_id(0xbb));
let mentions: Vec<_> = parsed.mentions().collect();
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].event_id, event_id(0xcc));
}
#[test]
fn legacy_positional_tags_decode_without_marker() {
let event = EventBuilder::text_note("legacy thread")
.created_at(Timestamp::from_secs(2))
.tag(Tag::new(["e", &event_id(0xaa).to_hex()]).unwrap())
.sign_with_keys(&keys())
.unwrap();
let parsed = ThreadContext::from_event(&event);
assert_eq!(parsed.events.len(), 1);
assert!(parsed.events[0].marker.is_none());
assert!(parsed.root().is_none());
}
#[test]
fn malformed_e_tag_is_skipped_in_lenient_parse() {
let event = EventBuilder::text_note("bad ref")
.created_at(Timestamp::from_secs(3))
.tags([
Tag::new(["e", "not-a-hex-id"]).unwrap(),
Tag::new(["e", &event_id(0x10).to_hex()]).unwrap(),
])
.sign_with_keys(&keys())
.unwrap();
let parsed = ThreadContext::from_event(&event);
assert_eq!(parsed.events.len(), 1);
}
#[test]
fn from_tag_strict_returns_errors() {
let bad = Tag::new(["e", "not-a-hex-id"]).unwrap();
let err = EventReference::from_tag(&bad).unwrap_err();
assert!(matches!(err, ThreadError::InvalidEventId(_)));
}
#[test]
fn from_tag_rejects_non_e_tag() {
let tag = Tag::new(["p", &pk(1).to_hex()]).unwrap();
let err = EventReference::from_tag(&tag).unwrap_err();
assert!(matches!(err, ThreadError::NotEventTag(_)));
}
#[test]
fn legacy_positional_single_e_tag_becomes_root() {
let context = ThreadContext::new()
.reference(EventReference::new(event_id(0xaa)))
.infer_legacy_markers();
assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
assert!(context.reply().is_none());
}
#[test]
fn legacy_positional_multi_e_tag_assigns_root_reply_mention() {
let context = ThreadContext::new()
.reference(EventReference::new(event_id(0xaa)))
.reference(EventReference::new(event_id(0xbb)))
.reference(EventReference::new(event_id(0xcc)))
.reference(EventReference::new(event_id(0xdd)))
.infer_legacy_markers();
assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
assert_eq!(context.events[1].marker, Some(NoteMarker::Mention));
assert_eq!(context.events[2].marker, Some(NoteMarker::Mention));
assert_eq!(context.events[3].marker, Some(NoteMarker::Reply));
}
#[test]
fn legacy_positional_two_e_tags_become_root_and_reply() {
let context = ThreadContext::new()
.reference(EventReference::new(event_id(0xaa)))
.reference(EventReference::new(event_id(0xbb)))
.infer_legacy_markers();
assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
assert_eq!(context.events[1].marker, Some(NoteMarker::Reply));
}
#[test]
fn legacy_positional_inference_preserves_existing_markers() {
let context = ThreadContext::new()
.reference(EventReference::new(event_id(0xaa)).with_marker(NoteMarker::Root))
.reference(EventReference::new(event_id(0xbb)))
.infer_legacy_markers();
assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
assert_eq!(context.events[1].marker, Some(NoteMarker::Root));
}
#[test]
fn legacy_positional_no_e_tags_is_a_noop() {
let context = ThreadContext::new().infer_legacy_markers();
assert!(context.events.is_empty());
}
}