use gpui::SharedString; use nostr::prelude::*; /// Parsed NIP-34 repository announcement (plain data, ready for the UI). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Announcement { /// Repository ID (`d` tag). pub id: String, /// Author of the announcement event. pub owner: PublicKey, /// When the announcement was published (for latest-wins resolution). pub created_at: Timestamp, pub name: Option, pub description: Option, /// Webpage URLs for browsing. pub web: Vec, /// URLs for `git clone`. pub clone: Vec, /// Relays the repository monitors for patches and issues. pub relays: Vec, /// Earliest unique commit ID (`r` tag with `euc` marker). pub euc: Option, /// Other recognized maintainers. pub maintainers: Vec, /// Hashtags labelling the repository (`t` tags). pub hashtags: Vec, } /// Subject of a NIP-34 issue or pull request event: the `subject` tag, /// falling back to the first non-empty line of the content. pub fn activity_subject(event: &Event) -> SharedString { let subject = event .tags .iter() .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { Ok(Nip34Tag::Subject(subject)) => Some(subject), _ => None, }); subject .map(SharedString::from) .or_else(|| { event .content .lines() .map(str::trim) .find(|line| !line.is_empty()) .map(SharedString::from) }) .unwrap_or(SharedString::from("Untitled")) } impl Announcement { /// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing. pub fn from_event(event: &Event) -> Option { if event.kind != Kind::GitRepoAnnouncement { return None; } let id = event.tags.identifier()?; let mut hashtags: Vec = Vec::new(); hashtags.extend(event.tags.hashtags().map(|t| t.to_string())); let mut name: Option = None; let mut description: Option = None; let mut web: Vec = Vec::new(); let mut clone: Vec = Vec::new(); let mut relays: Vec = Vec::new(); let mut euc: Option = None; let mut maintainers: Vec = Vec::new(); for tag in event.tags.iter() { match Nip34Tag::parse(tag.as_slice()) { Ok(Nip34Tag::Name(value)) => name = Some(value.into()), Ok(Nip34Tag::Description(value)) => description = Some(value.into()), Ok(Nip34Tag::Web(urls)) => web.extend(urls), Ok(Nip34Tag::Clone(urls)) => clone.extend(urls), Ok(Nip34Tag::Relays(urls)) => relays.extend(urls), Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()), Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys), _ => {} } } Some(Self { owner: event.pubkey, created_at: event.created_at, id, name, description, web, clone, relays, euc, maintainers, hashtags, }) } /// The repository address of this announcement. pub fn addr(&self) -> crate::RepoAddr { crate::repo_addr(self.owner, self.id.clone()) } /// The description of the repository, or a default if none is provided. pub fn description(&self) -> SharedString { self.description .clone() .unwrap_or(SharedString::from("No description")) } } #[cfg(test)] mod tests { use super::*; const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272"; fn keys() -> Keys { Keys::new( SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") .expect("valid secret key"), ) } /// Build a signed kind `30617` event from raw tag values. fn announcement_event(tags: &[&[&str]]) -> Event { let tags: Vec = tags .iter() .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) .collect(); EventBuilder::new(Kind::GitRepoAnnouncement, "") .tags(tags) .finalize(&keys()) .expect("signed event") } #[test] fn parses_full_announcement() { let event = announcement_event(&[ &["d", "my-repo"], &["name", "My Repo"], &["description", "A test repository"], &["web", "https://example.com/repo"], &["clone", "https://example.com/repo.git"], &["relays", "wss://relay.example.com"], &["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"], &["maintainers", MAINTAINER_HEX], &["t", "rust"], &["t", "nostr"], ]); let announcement = Announcement::from_event(&event).expect("parses"); assert_eq!(announcement.owner, keys().public_key()); assert_eq!(announcement.id, "my-repo"); assert_eq!(announcement.name.as_deref(), Some("My Repo")); assert_eq!( announcement.description.as_deref(), Some("A test repository") ); assert_eq!( announcement.web, vec![Url::parse("https://example.com/repo").unwrap()] ); assert_eq!( announcement.clone, vec![Url::parse("https://example.com/repo.git").unwrap()] ); assert_eq!( announcement.relays, vec![RelayUrl::parse("wss://relay.example.com").unwrap()] ); assert_eq!( announcement.euc.as_deref(), Some("aa231c4c6a5777dc89b42207b499891a344add5c") ); assert_eq!( announcement.maintainers, vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")] ); assert_eq!(announcement.hashtags, vec!["rust", "nostr"]); } #[test] fn requires_d_tag() { let event = announcement_event(&[&["name", "No id"]]); assert!(Announcement::from_event(&event).is_none()); } #[test] fn ignores_other_kinds() { let event = EventBuilder::new(Kind::GitIssue, "") .finalize(&keys()) .expect("signed event"); assert!(Announcement::from_event(&event).is_none()); } #[test] fn drops_malformed_values() { let event = announcement_event(&[ &["d", "my-repo"], &["clone", "not a url"], &["relays", "wss://good.example.com"], &["maintainers", "not-a-pubkey"], ]); let announcement = Announcement::from_event(&event).expect("parses"); // An invalid URL keeps the whole clone tag from being parsed. assert!(announcement.clone.is_empty()); assert_eq!( announcement.relays, vec![RelayUrl::parse("wss://good.example.com").unwrap()] ); assert!(announcement.maintainers.is_empty()); } #[test] fn ignores_unknown_tags() { let event = announcement_event(&[&["d", "my-repo"], &["t", "label"], &["subject", "n/a"]]); let announcement = Announcement::from_event(&event).expect("parses"); assert_eq!(announcement.id, "my-repo"); assert!(announcement.name.is_none()); assert!(announcement.web.is_empty()); } }