1use thiserror::Error;
52
53use crate::event::{
54 Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
55 SingleLetterTag, Tag, TagError, TagKind, Tags,
56};
57#[cfg(feature = "nip44")]
58use crate::key::SecretKey;
59use crate::key::{PublicKey, PublicKeyError};
60use crate::types::{RelayUrl, RelayUrlError};
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum ListItem {
66 Pubkey {
68 pubkey: PublicKey,
70 relay_hint: Option<RelayUrl>,
72 petname: Option<String>,
74 },
75 Event {
77 id: EventId,
79 relay_hint: Option<RelayUrl>,
81 },
82 Address {
84 coordinate: Coordinate,
86 relay_hint: Option<RelayUrl>,
88 },
89 Hashtag(String),
91 Word(String),
93 Relay(RelayUrl),
95 Server(String),
97 Emoji {
99 shortcode: String,
101 url: String,
103 },
104 Group {
106 id: String,
108 relay: RelayUrl,
110 name: Option<String>,
112 },
113 Other(Tag),
115}
116
117impl ListItem {
118 #[must_use]
120 pub fn to_tag(&self) -> Tag {
121 match self {
122 Self::Pubkey {
123 pubkey,
124 relay_hint,
125 petname,
126 } => {
127 let mut values: Vec<String> = Vec::with_capacity(3);
128 values.push(pubkey.to_hex());
129 values.push(relay_hint_value(relay_hint.as_ref()));
130 if let Some(name) = petname {
131 values.push(name.clone());
132 }
133 letter_tag(Alphabet::P, values)
134 }
135 Self::Event { id, relay_hint } => {
136 let mut values: Vec<String> = Vec::with_capacity(2);
137 values.push(id.to_hex());
138 if let Some(r) = relay_hint {
139 values.push(r.as_str().to_owned());
140 }
141 letter_tag(Alphabet::E, values)
142 }
143 Self::Address {
144 coordinate,
145 relay_hint,
146 } => {
147 let mut values: Vec<String> = Vec::with_capacity(2);
148 values.push(coordinate.to_wire());
149 if let Some(r) = relay_hint {
150 values.push(r.as_str().to_owned());
151 }
152 letter_tag(Alphabet::A, values)
153 }
154 Self::Hashtag(t) => letter_tag(Alphabet::T, [t.clone()]),
155 Self::Word(w) => custom_tag("word", [w.clone()]),
156 Self::Relay(url) => custom_tag("relay", [url.as_str().to_owned()]),
157 Self::Server(url) => custom_tag("server", [url.clone()]),
158 Self::Emoji { shortcode, url } => custom_tag("emoji", [shortcode.clone(), url.clone()]),
159 Self::Group { id, relay, name } => {
160 let mut values: Vec<String> = Vec::with_capacity(3);
161 values.push(id.clone());
162 values.push(relay.as_str().to_owned());
163 if let Some(n) = name {
164 values.push(n.clone());
165 }
166 custom_tag("group", values)
167 }
168 Self::Other(tag) => tag.clone(),
169 }
170 }
171
172 pub fn from_tag(tag: &Tag) -> Result<Self, ListItemError> {
183 match tag.kind() {
184 TagKind::SingleLetter(s) if !s.uppercase => match s.character {
185 Alphabet::P => parse_pubkey(tag),
186 Alphabet::E => parse_event(tag),
187 Alphabet::A => parse_address(tag),
188 Alphabet::T => parse_hashtag(tag),
189 _ => Ok(Self::Other(tag.clone())),
190 },
191 TagKind::Custom(name) if name == "word" => parse_word(tag),
192 TagKind::Custom(name) if name == "relay" => parse_relay(tag),
193 TagKind::Custom(name) if name == "server" => parse_server(tag),
194 TagKind::Custom(name) if name == "emoji" => parse_emoji(tag),
195 TagKind::Custom(name) if name == "group" => parse_group(tag),
196 _ => Ok(Self::Other(tag.clone())),
197 }
198 }
199}
200
201fn relay_hint_value(hint: Option<&RelayUrl>) -> String {
202 hint.map(|r| r.as_str().to_owned()).unwrap_or_default()
203}
204
205fn parse_pubkey(tag: &Tag) -> Result<ListItem, ListItemError> {
206 let pk_hex = tag.get(1).ok_or(ListItemError::MissingValue("p"))?;
207 let pubkey = PublicKey::parse(pk_hex).map_err(ListItemError::InvalidPublicKey)?;
208 let relay_hint = match tag.get(2) {
209 Some(s) if !s.is_empty() => {
210 Some(RelayUrl::parse(s).map_err(ListItemError::InvalidRelayUrl)?)
211 }
212 _ => None,
213 };
214 let petname = tag.get(3).map(str::to_owned);
215 Ok(ListItem::Pubkey {
216 pubkey,
217 relay_hint,
218 petname,
219 })
220}
221
222fn parse_event(tag: &Tag) -> Result<ListItem, ListItemError> {
223 let id_hex = tag.get(1).ok_or(ListItemError::MissingValue("e"))?;
224 let id = EventId::parse(id_hex).map_err(ListItemError::InvalidEventId)?;
225 let relay_hint = match tag.get(2) {
226 Some(s) if !s.is_empty() => {
227 Some(RelayUrl::parse(s).map_err(ListItemError::InvalidRelayUrl)?)
228 }
229 _ => None,
230 };
231 Ok(ListItem::Event { id, relay_hint })
232}
233
234fn parse_address(tag: &Tag) -> Result<ListItem, ListItemError> {
235 let coord_str = tag.get(1).ok_or(ListItemError::MissingValue("a"))?;
236 let coordinate = Coordinate::parse(coord_str).map_err(ListItemError::InvalidCoordinate)?;
237 let relay_hint = match tag.get(2) {
238 Some(s) if !s.is_empty() => {
239 Some(RelayUrl::parse(s).map_err(ListItemError::InvalidRelayUrl)?)
240 }
241 _ => None,
242 };
243 Ok(ListItem::Address {
244 coordinate,
245 relay_hint,
246 })
247}
248
249fn parse_hashtag(tag: &Tag) -> Result<ListItem, ListItemError> {
250 Ok(ListItem::Hashtag(
251 tag.get(1)
252 .ok_or(ListItemError::MissingValue("t"))?
253 .to_owned(),
254 ))
255}
256
257fn parse_word(tag: &Tag) -> Result<ListItem, ListItemError> {
258 Ok(ListItem::Word(
259 tag.get(1)
260 .ok_or(ListItemError::MissingValue("word"))?
261 .to_owned(),
262 ))
263}
264
265fn parse_relay(tag: &Tag) -> Result<ListItem, ListItemError> {
266 let url_str = tag.get(1).ok_or(ListItemError::MissingValue("relay"))?;
267 let url = RelayUrl::parse(url_str).map_err(ListItemError::InvalidRelayUrl)?;
268 Ok(ListItem::Relay(url))
269}
270
271fn parse_server(tag: &Tag) -> Result<ListItem, ListItemError> {
272 Ok(ListItem::Server(
273 tag.get(1)
274 .ok_or(ListItemError::MissingValue("server"))?
275 .to_owned(),
276 ))
277}
278
279fn parse_emoji(tag: &Tag) -> Result<ListItem, ListItemError> {
280 Ok(ListItem::Emoji {
281 shortcode: tag
282 .get(1)
283 .ok_or(ListItemError::MissingValue("emoji"))?
284 .to_owned(),
285 url: tag
286 .get(2)
287 .ok_or(ListItemError::MissingValue("emoji"))?
288 .to_owned(),
289 })
290}
291
292fn parse_group(tag: &Tag) -> Result<ListItem, ListItemError> {
293 let id = tag
294 .get(1)
295 .ok_or(ListItemError::MissingValue("group"))?
296 .to_owned();
297 let relay_str = tag.get(2).ok_or(ListItemError::MissingValue("group"))?;
298 let relay = RelayUrl::parse(relay_str).map_err(ListItemError::InvalidRelayUrl)?;
299 let name = tag.get(3).map(str::to_owned);
300 Ok(ListItem::Group { id, relay, name })
301}
302
303fn letter_tag<I, S>(alphabet: Alphabet, args: I) -> Tag
304where
305 I: IntoIterator<Item = S>,
306 S: Into<String>,
307{
308 let head = TagKind::single_letter(SingleLetterTag::lowercase(alphabet));
309 Tag::with(&head, args)
310}
311
312fn custom_tag<I, S>(name: &str, args: I) -> Tag
313where
314 I: IntoIterator<Item = S>,
315 S: Into<String>,
316{
317 Tag::with(&TagKind::Custom(name.to_owned()), args)
318}
319
320#[derive(Debug, Error)]
322#[non_exhaustive]
323pub enum ListItemError {
324 #[error("`{0}` tag missing value")]
326 MissingValue(&'static str),
327 #[error("invalid public key: {0}")]
329 InvalidPublicKey(#[source] PublicKeyError),
330 #[error("invalid event id: {0}")]
332 InvalidEventId(#[source] EventIdError),
333 #[error("invalid coordinate: {0}")]
335 InvalidCoordinate(#[source] CoordinateError),
336 #[error("invalid relay URL: {0}")]
338 InvalidRelayUrl(#[source] RelayUrlError),
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct List {
344 pub kind: Kind,
347 pub identifier: Option<String>,
351 pub title: Option<String>,
353 pub description: Option<String>,
355 pub image: Option<String>,
357 pub public_items: Vec<ListItem>,
359 pub private_items: Vec<ListItem>,
362 pub encrypted_payload: Option<String>,
365}
366
367impl List {
368 #[must_use]
370 pub const fn new(kind: Kind) -> Self {
371 Self {
372 kind,
373 identifier: None,
374 title: None,
375 description: None,
376 image: None,
377 public_items: Vec::new(),
378 private_items: Vec::new(),
379 encrypted_payload: None,
380 }
381 }
382
383 #[must_use]
385 pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
386 self.identifier = Some(identifier.into());
387 self
388 }
389
390 #[must_use]
392 pub fn title(mut self, title: impl Into<String>) -> Self {
393 self.title = Some(title.into());
394 self
395 }
396
397 #[must_use]
399 pub fn description(mut self, description: impl Into<String>) -> Self {
400 self.description = Some(description.into());
401 self
402 }
403
404 #[must_use]
406 pub fn image(mut self, image: impl Into<String>) -> Self {
407 self.image = Some(image.into());
408 self
409 }
410
411 #[must_use]
413 pub fn public_item(mut self, item: ListItem) -> Self {
414 self.public_items.push(item);
415 self
416 }
417
418 #[must_use]
421 pub fn private_item(mut self, item: ListItem) -> Self {
422 self.private_items.push(item);
423 self
424 }
425
426 #[must_use]
430 pub fn to_public_tags(&self) -> Vec<Tag> {
431 let mut tags: Vec<Tag> = Vec::with_capacity(4 + self.public_items.len());
432 if let Some(id) = &self.identifier {
433 tags.push(Tag::d(id));
434 }
435 if let Some(title) = &self.title {
436 tags.push(Tag::title(title));
437 }
438 if let Some(image) = &self.image {
439 tags.push(custom_tag("image", [image.clone()]));
440 }
441 if let Some(desc) = &self.description {
442 tags.push(custom_tag("description", [desc.clone()]));
443 }
444 for item in &self.public_items {
445 tags.push(item.to_tag());
446 }
447 tags
448 }
449
450 #[cfg(feature = "nip44")]
461 #[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
462 pub fn encrypt_private(
463 &self,
464 owner_secret: &SecretKey,
465 owner_public: &PublicKey,
466 ) -> Result<Option<String>, ListEncryptionError> {
467 if self.private_items.is_empty() {
468 return Ok(None);
469 }
470 let json =
471 serialize_items(&self.private_items).map_err(ListEncryptionError::InvalidJson)?;
472 let payload = crate::nips::nip44::encrypt(owner_secret, owner_public, &json)
473 .map_err(ListEncryptionError::Encrypt)?;
474 Ok(Some(payload))
475 }
476
477 #[cfg(feature = "nip44")]
492 #[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
493 pub fn decrypt_private(
494 &mut self,
495 owner_secret: &SecretKey,
496 owner_public: &PublicKey,
497 ) -> Result<(), ListEncryptionError> {
498 let Some(payload) = self.encrypted_payload.take() else {
499 return Err(ListEncryptionError::NoPayload);
500 };
501 let json = if payload.contains("?iv=") {
502 decrypt_nip04(owner_secret, owner_public, &payload)?
503 } else {
504 crate::nips::nip44::decrypt(owner_secret, owner_public, &payload)
505 .map_err(ListEncryptionError::Decrypt)?
506 };
507 self.private_items = deserialize_items(&json).map_err(ListEncryptionError::InvalidJson)?;
508 Ok(())
509 }
510
511 pub fn from_event(event: &Event) -> Result<Self, ListError> {
521 Self::from_tags_and_content(event.kind, &event.tags, &event.content)
522 }
523
524 fn from_tags_and_content(kind: Kind, tags: &Tags, content: &str) -> Result<Self, ListError> {
525 let mut list = Self::new(kind);
526 for tag in tags {
527 match tag.kind() {
528 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {
529 list.identifier = tag.get(1).map(str::to_owned);
530 }
531 TagKind::Custom(name) if name == "title" => {
532 list.title = tag.get(1).map(str::to_owned);
533 }
534 TagKind::Custom(name) if name == "description" => {
535 list.description = tag.get(1).map(str::to_owned);
536 }
537 TagKind::Custom(name) if name == "image" => {
538 list.image = tag.get(1).map(str::to_owned);
539 }
540 _ => {
541 let item = ListItem::from_tag(tag).map_err(ListError::InvalidItem)?;
542 list.public_items.push(item);
543 }
544 }
545 }
546 if !content.is_empty() {
547 list.encrypted_payload = Some(content.to_owned());
548 }
549 Ok(list)
550 }
551}
552
553#[cfg(feature = "nip44")]
554fn serialize_items(items: &[ListItem]) -> Result<String, serde_json::Error> {
555 let raw: Vec<Vec<String>> = items.iter().map(|i| i.to_tag().values().to_vec()).collect();
556 serde_json::to_string(&raw)
557}
558
559#[cfg(feature = "nip44")]
560fn deserialize_items(json: &str) -> Result<Vec<ListItem>, serde_json::Error> {
561 let raw: Vec<Vec<String>> = serde_json::from_str(json)?;
562 raw.into_iter()
563 .map(|values| {
564 let tag = Tag::new(values).map_err(serde_json::Error::custom)?;
565 let item = ListItem::from_tag(&tag).map_err(serde_json::Error::custom)?;
566 Ok(item)
567 })
568 .collect()
569}
570
571#[cfg(feature = "nip44")]
572trait CustomError {
573 fn custom<E: std::fmt::Display>(err: E) -> Self;
574}
575
576#[cfg(feature = "nip44")]
577impl CustomError for serde_json::Error {
578 fn custom<E: std::fmt::Display>(err: E) -> Self {
579 <Self as serde::de::Error>::custom(err.to_string())
580 }
581}
582
583#[cfg(all(feature = "nip44", feature = "nip04"))]
584fn decrypt_nip04(
585 owner_secret: &SecretKey,
586 owner_public: &PublicKey,
587 payload: &str,
588) -> Result<String, ListEncryptionError> {
589 crate::nips::nip04::decrypt(owner_secret, owner_public, payload)
590 .map_err(ListEncryptionError::Nip04)
591}
592
593#[cfg(all(feature = "nip44", not(feature = "nip04")))]
594const fn decrypt_nip04(
595 _owner_secret: &SecretKey,
596 _owner_public: &PublicKey,
597 _payload: &str,
598) -> Result<String, ListEncryptionError> {
599 Err(ListEncryptionError::Nip04Unavailable)
600}
601
602#[derive(Debug, Error)]
604#[non_exhaustive]
605pub enum ListError {
606 #[error("invalid list item: {0}")]
608 InvalidItem(#[source] ListItemError),
609 #[error("invalid tag: {0}")]
611 InvalidTag(#[from] TagError),
612}
613
614#[derive(Debug, Error)]
616#[non_exhaustive]
617#[cfg(feature = "nip44")]
618#[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
619pub enum ListEncryptionError {
620 #[error("no encrypted payload available; call from_event first")]
622 NoPayload,
623 #[error("NIP-44 encrypt failed: {0}")]
625 Encrypt(#[source] crate::nips::nip44::Nip44Error),
626 #[error("NIP-44 decrypt failed: {0}")]
628 Decrypt(#[source] crate::nips::nip44::Nip44Error),
629 #[error("invalid JSON inside encrypted payload: {0}")]
631 InvalidJson(#[source] serde_json::Error),
632 #[cfg(not(feature = "nip04"))]
634 #[error("NIP-04 fallback required but the `nip04` feature is disabled")]
635 Nip04Unavailable,
636 #[cfg(feature = "nip04")]
638 #[error("NIP-04 decrypt failed: {0}")]
639 Nip04(#[source] crate::nips::nip04::Nip04Error),
640}
641
642impl EventBuilder {
643 #[must_use]
650 pub fn list(list: &List) -> Self {
651 let mut builder = Self::new(list.kind, "");
652 for tag in list.to_public_tags() {
653 builder = builder.tag(tag);
654 }
655 builder
656 }
657
658 #[cfg(feature = "nip44")]
667 #[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
668 pub fn list_with_private_items(
669 list: &List,
670 owner_secret: &SecretKey,
671 owner_public: &PublicKey,
672 ) -> Result<Self, ListEncryptionError> {
673 let payload = list
674 .encrypt_private(owner_secret, owner_public)?
675 .unwrap_or_default();
676 let mut builder = Self::new(list.kind, payload);
677 for tag in list.to_public_tags() {
678 builder = builder.tag(tag);
679 }
680 Ok(builder)
681 }
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use crate::Keys;
688
689 fn keys() -> Keys {
690 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
691 }
692
693 #[test]
694 fn mute_list_round_trips_public_items() {
695 let pk = *keys().public_key();
696 let list = List::new(Kind::MUTE_LIST)
697 .public_item(ListItem::Pubkey {
698 pubkey: pk,
699 relay_hint: None,
700 petname: None,
701 })
702 .public_item(ListItem::Hashtag("spam".to_owned()))
703 .public_item(ListItem::Word("crypto".to_owned()))
704 .public_item(ListItem::Event {
705 id: EventId::from_byte_array([0xab; 32]),
706 relay_hint: None,
707 });
708 let event = EventBuilder::list(&list).sign_with_keys(&keys()).unwrap();
709 assert_eq!(event.kind, Kind::MUTE_LIST);
710 let parsed = List::from_event(&event).unwrap();
711 assert_eq!(parsed.public_items, list.public_items);
712 assert!(parsed.encrypted_payload.is_none());
713 }
714
715 #[test]
716 fn bookmark_set_round_trips_metadata() {
717 let list = List::new(Kind::BOOKMARK_SET)
718 .identifier("yaks")
719 .title("Yaks")
720 .description("articles about yaks")
721 .image("https://example.com/yak.png")
722 .public_item(ListItem::Address {
723 coordinate: Coordinate::new(
724 Kind::LONG_FORM_TEXT_NOTE,
725 *keys().public_key(),
726 "yak-1",
727 ),
728 relay_hint: None,
729 });
730 let event = EventBuilder::list(&list).sign_with_keys(&keys()).unwrap();
731 let parsed = List::from_event(&event).unwrap();
732 assert_eq!(parsed.identifier.as_deref(), Some("yaks"));
733 assert_eq!(parsed.title.as_deref(), Some("Yaks"));
734 assert_eq!(parsed.description.as_deref(), Some("articles about yaks"));
735 assert_eq!(parsed.image.as_deref(), Some("https://example.com/yak.png"));
736 assert_eq!(parsed.public_items.len(), 1);
737 }
738
739 #[test]
740 fn relay_set_round_trips_relays() {
741 let list = List::new(Kind::RELAY_SET)
742 .identifier("default")
743 .public_item(ListItem::Relay(
744 RelayUrl::parse("wss://relay.example/").unwrap(),
745 ));
746 let event = EventBuilder::list(&list).sign_with_keys(&keys()).unwrap();
747 let parsed = List::from_event(&event).unwrap();
748 assert_eq!(parsed.public_items.len(), 1);
749 assert!(matches!(&parsed.public_items[0], ListItem::Relay(_)));
750 }
751
752 #[test]
753 fn unknown_tags_round_trip_as_other() {
754 let list = List::new(Kind::INTEREST_SET)
755 .identifier("cust")
756 .public_item(ListItem::Other(Tag::with(
757 &TagKind::Custom("vendor".to_owned()),
758 ["xyz"],
759 )));
760 let event = EventBuilder::list(&list).sign_with_keys(&keys()).unwrap();
761 let parsed = List::from_event(&event).unwrap();
762 assert_eq!(parsed.public_items.len(), 1);
763 match &parsed.public_items[0] {
764 ListItem::Other(tag) => assert_eq!(tag.name(), "vendor"),
765 other => panic!("expected Other, got {other:?}"),
766 }
767 }
768
769 #[cfg(feature = "nip44")]
770 #[test]
771 fn private_items_round_trip_through_nip44() {
772 let owner = keys();
773 let list = List::new(Kind::MUTE_LIST)
774 .public_item(ListItem::Hashtag("public".to_owned()))
775 .private_item(ListItem::Hashtag("secret".to_owned()))
776 .private_item(ListItem::Pubkey {
777 pubkey: *owner.public_key(),
778 relay_hint: None,
779 petname: None,
780 });
781 let event =
782 EventBuilder::list_with_private_items(&list, owner.secret_key(), owner.public_key())
783 .unwrap()
784 .sign_with_keys(&owner)
785 .unwrap();
786
787 let mut parsed = List::from_event(&event).unwrap();
789 assert_eq!(parsed.public_items.len(), 1);
790 assert!(parsed.encrypted_payload.is_some());
791
792 parsed
794 .decrypt_private(owner.secret_key(), owner.public_key())
795 .unwrap();
796 assert_eq!(parsed.private_items.len(), 2);
797 assert!(parsed.encrypted_payload.is_none());
798 }
799
800 #[test]
801 fn pubkey_with_petname_round_trips() {
802 let pk = *keys().public_key();
803 let list = List::new(Kind::FOLLOW_SET)
804 .identifier("close-friends")
805 .public_item(ListItem::Pubkey {
806 pubkey: pk,
807 relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
808 petname: Some("alice".to_owned()),
809 });
810 let event = EventBuilder::list(&list).sign_with_keys(&keys()).unwrap();
811 let parsed = List::from_event(&event).unwrap();
812 match &parsed.public_items[0] {
813 ListItem::Pubkey {
814 pubkey,
815 relay_hint,
816 petname,
817 } => {
818 assert_eq!(*pubkey, pk);
819 assert!(relay_hint.is_some());
820 assert_eq!(petname.as_deref(), Some("alice"));
821 }
822 other => panic!("expected Pubkey, got {other:?}"),
823 }
824 }
825}