1use thiserror::Error;
36
37use crate::event::{
38 Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
39 SingleLetterTag, Tag, TagKind, Tags,
40};
41use crate::key::{PublicKey, PublicKeyError};
42use crate::nips::nip22::{Comment, CommentScope};
43use crate::types::{ImageDimensions, ImageError, RelayUrl, RelayUrlError};
44
45pub const KIND_COMMUNITY_DEFINITION: Kind = Kind::new(34_550);
47pub const KIND_POST_APPROVAL: Kind = Kind::new(4_550);
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52#[non_exhaustive]
53pub enum CommunityRelayMarker {
54 Author,
56 Requests,
58 Approvals,
60 Default,
62 Other(String),
64}
65
66impl CommunityRelayMarker {
67 #[must_use]
69 pub const fn as_str(&self) -> Option<&str> {
70 match self {
71 Self::Author => Some("author"),
72 Self::Requests => Some("requests"),
73 Self::Approvals => Some("approvals"),
74 Self::Default => None,
75 Self::Other(s) => Some(s.as_str()),
76 }
77 }
78
79 #[must_use]
81 pub fn parse(marker: Option<&str>) -> Self {
82 match marker {
83 None => Self::Default,
84 Some("author") => Self::Author,
85 Some("requests") => Self::Requests,
86 Some("approvals") => Self::Approvals,
87 Some(other) => Self::Other(other.to_owned()),
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct CommunityRelay {
95 pub url: RelayUrl,
97 pub marker: CommunityRelayMarker,
99}
100
101impl CommunityRelay {
102 #[must_use]
104 pub const fn new(url: RelayUrl, marker: CommunityRelayMarker) -> Self {
105 Self { url, marker }
106 }
107
108 #[must_use]
110 pub const fn unmarked(url: RelayUrl) -> Self {
111 Self {
112 url,
113 marker: CommunityRelayMarker::Default,
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CommunityImage {
121 pub url: String,
123 pub dim: Option<ImageDimensions>,
125}
126
127impl CommunityImage {
128 #[must_use]
130 pub fn new(url: impl Into<String>) -> Self {
131 Self {
132 url: url.into(),
133 dim: None,
134 }
135 }
136
137 #[must_use]
139 pub const fn dim(mut self, dim: ImageDimensions) -> Self {
140 self.dim = Some(dim);
141 self
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct CommunityModerator {
148 pub pubkey: PublicKey,
150 pub relay_hint: Option<RelayUrl>,
152}
153
154impl CommunityModerator {
155 #[must_use]
157 pub const fn new(pubkey: PublicKey) -> Self {
158 Self {
159 pubkey,
160 relay_hint: None,
161 }
162 }
163
164 #[must_use]
166 pub fn relay_hint(mut self, hint: RelayUrl) -> Self {
167 self.relay_hint = Some(hint);
168 self
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct CommunityDefinition {
175 pub identifier: String,
177 pub name: Option<String>,
179 pub description: Option<String>,
181 pub image: Option<CommunityImage>,
183 pub moderators: Vec<CommunityModerator>,
185 pub relays: Vec<CommunityRelay>,
187 pub extra_tags: Vec<Tag>,
189}
190
191impl CommunityDefinition {
192 #[must_use]
195 pub fn new(identifier: impl Into<String>) -> Self {
196 Self {
197 identifier: identifier.into(),
198 name: None,
199 description: None,
200 image: None,
201 moderators: Vec::new(),
202 relays: Vec::new(),
203 extra_tags: Vec::new(),
204 }
205 }
206
207 #[must_use]
209 pub fn name(mut self, name: impl Into<String>) -> Self {
210 self.name = Some(name.into());
211 self
212 }
213
214 #[must_use]
216 pub fn description(mut self, description: impl Into<String>) -> Self {
217 self.description = Some(description.into());
218 self
219 }
220
221 #[must_use]
223 pub fn image(mut self, image: CommunityImage) -> Self {
224 self.image = Some(image);
225 self
226 }
227
228 #[must_use]
230 pub fn moderator(mut self, m: CommunityModerator) -> Self {
231 self.moderators.push(m);
232 self
233 }
234
235 #[must_use]
237 pub fn relay(mut self, r: CommunityRelay) -> Self {
238 self.relays.push(r);
239 self
240 }
241
242 #[must_use]
244 pub fn extra_tag(mut self, tag: Tag) -> Self {
245 self.extra_tags.push(tag);
246 self
247 }
248
249 #[must_use]
251 pub fn to_tags(&self) -> Vec<Tag> {
252 let mut tags: Vec<Tag> = Vec::with_capacity(
253 4 + self.moderators.len() + self.relays.len() + self.extra_tags.len(),
254 );
255 tags.push(Tag::d(&self.identifier));
256 if let Some(name) = &self.name {
257 tags.push(custom("name", [name.clone()]));
258 }
259 if let Some(desc) = &self.description {
260 tags.push(custom("description", [desc.clone()]));
261 }
262 if let Some(image) = &self.image {
263 let mut values: Vec<String> = Vec::with_capacity(2);
264 values.push(image.url.clone());
265 if let Some(dim) = image.dim {
266 values.push(dim.to_string());
267 }
268 tags.push(custom("image", values));
269 }
270 for m in &self.moderators {
271 let mut values: Vec<String> = Vec::with_capacity(4);
272 values.push(m.pubkey.to_hex());
273 values.push(
274 m.relay_hint
275 .as_ref()
276 .map(|r| r.as_str().to_owned())
277 .unwrap_or_default(),
278 );
279 values.push("moderator".to_owned());
280 tags.push(letter(Alphabet::P, values));
281 }
282 for r in &self.relays {
283 let mut values: Vec<String> = Vec::with_capacity(2);
284 values.push(r.url.as_str().to_owned());
285 if let Some(marker) = r.marker.as_str() {
286 values.push(marker.to_owned());
287 }
288 tags.push(custom("relay", values));
289 }
290 for tag in &self.extra_tags {
291 tags.push(tag.clone());
292 }
293 tags
294 }
295
296 pub fn from_event(event: &Event) -> Result<Self, CommunityError> {
306 if event.kind != KIND_COMMUNITY_DEFINITION {
307 return Err(CommunityError::WrongKind(event.kind));
308 }
309 let identifier = identifier_value(&event.tags)
310 .ok_or(CommunityError::MissingIdentifier)?
311 .to_owned();
312 let mut def = Self::new(identifier);
313
314 for tag in &event.tags {
315 match tag.kind() {
316 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {
317 }
319 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
320 handle_p_tag(tag, &mut def)?;
321 }
322 TagKind::Custom(name) if name == "name" => {
323 def.name = tag.get(1).map(str::to_owned);
324 }
325 TagKind::Custom(name) if name == "description" => {
326 def.description = tag.get(1).map(str::to_owned);
327 }
328 TagKind::Custom(name) if name == "image" => {
329 def.image = parse_image(tag)?;
330 }
331 TagKind::Custom(name) if name == "relay" => {
332 def.relays.push(parse_relay(tag)?);
333 }
334 _ => def.extra_tags.push(tag.clone()),
335 }
336 }
337 Ok(def)
338 }
339
340 #[must_use]
344 pub fn coordinate(&self, author: PublicKey) -> Coordinate {
345 Coordinate::new(KIND_COMMUNITY_DEFINITION, author, self.identifier.clone())
346 }
347}
348
349fn handle_p_tag(tag: &Tag, def: &mut CommunityDefinition) -> Result<(), CommunityError> {
350 if tag.get(3) == Some("moderator") {
351 def.moderators.push(parse_moderator(tag)?);
352 } else {
353 def.extra_tags.push(tag.clone());
354 }
355 Ok(())
356}
357
358fn parse_a_tag(tag: &Tag) -> Result<(Coordinate, Option<RelayUrl>), ApprovalError> {
359 let coord_str = tag.get(1).ok_or(ApprovalError::MalformedAddressTag)?;
360 let coord = Coordinate::parse(coord_str).map_err(ApprovalError::InvalidCoordinate)?;
361 let relay = tag_optional_relay(tag, 2)?;
362 Ok((coord, relay))
363}
364
365fn dispatch_a_tag(
366 parsed: (Coordinate, Option<RelayUrl>),
367 community: &mut Option<(Coordinate, Option<RelayUrl>)>,
368 address_target: &mut Option<(Coordinate, Option<RelayUrl>)>,
369) {
370 if parsed.0.kind == KIND_COMMUNITY_DEFINITION && community.is_none() {
371 *community = Some(parsed);
372 } else {
373 *address_target = Some(parsed);
374 }
375}
376
377fn parse_moderator(tag: &Tag) -> Result<CommunityModerator, CommunityError> {
378 let pk_hex = tag.get(1).ok_or(CommunityError::MalformedModerator)?;
379 let pubkey = PublicKey::parse(pk_hex).map_err(CommunityError::InvalidPublicKey)?;
380 let relay_hint = match tag.get(2) {
381 Some(s) if !s.is_empty() => {
382 Some(RelayUrl::parse(s).map_err(CommunityError::InvalidRelayUrl)?)
383 }
384 _ => None,
385 };
386 Ok(CommunityModerator { pubkey, relay_hint })
387}
388
389fn parse_image(tag: &Tag) -> Result<Option<CommunityImage>, CommunityError> {
390 let Some(url) = tag.get(1) else {
391 return Ok(None);
392 };
393 let mut image = CommunityImage::new(url.to_owned());
394 if let Some(dim_str) = tag.get(2)
395 && !dim_str.is_empty()
396 {
397 image.dim = Some(
398 dim_str
399 .parse::<ImageDimensions>()
400 .map_err(CommunityError::InvalidImageDim)?,
401 );
402 }
403 Ok(Some(image))
404}
405
406fn parse_relay(tag: &Tag) -> Result<CommunityRelay, CommunityError> {
407 let url_str = tag.get(1).ok_or(CommunityError::MalformedRelay)?;
408 let url = RelayUrl::parse(url_str).map_err(CommunityError::InvalidRelayUrl)?;
409 let marker = CommunityRelayMarker::parse(tag.get(2));
410 Ok(CommunityRelay { url, marker })
411}
412
413fn identifier_value(tags: &Tags) -> Option<&str> {
414 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
415 tags.find_first(&head).and_then(|tag| tag.get(1))
416}
417
418fn custom<I, S>(name: &str, args: I) -> Tag
419where
420 I: IntoIterator<Item = S>,
421 S: Into<String>,
422{
423 Tag::with(&TagKind::Custom(name.to_owned()), args)
424}
425
426fn letter<I, S>(alphabet: Alphabet, args: I) -> Tag
427where
428 I: IntoIterator<Item = S>,
429 S: Into<String>,
430{
431 let head = TagKind::single_letter(SingleLetterTag::lowercase(alphabet));
432 Tag::with(&head, args)
433}
434
435#[derive(Debug, Clone, PartialEq, Eq)]
443#[non_exhaustive]
444pub enum ApprovalTarget {
445 Event {
447 id: EventId,
449 relay_hint: Option<RelayUrl>,
451 },
452 Address {
454 coordinate: Coordinate,
456 relay_hint: Option<RelayUrl>,
458 },
459 Both {
462 id: EventId,
464 coordinate: Coordinate,
466 relay_hint: Option<RelayUrl>,
468 },
469}
470
471#[derive(Debug, Clone, PartialEq, Eq)]
473pub struct PostApproval {
474 pub community: Coordinate,
476 pub community_relay: Option<RelayUrl>,
478 pub target: ApprovalTarget,
480 pub post_kind: Kind,
482 pub post_author: PublicKey,
484 pub post_author_relay: Option<RelayUrl>,
486 pub original_event_json: String,
489}
490
491impl PostApproval {
492 #[must_use]
494 pub fn new(
495 community: Coordinate,
496 target: ApprovalTarget,
497 post_kind: Kind,
498 post_author: PublicKey,
499 original_event_json: impl Into<String>,
500 ) -> Self {
501 Self {
502 community,
503 community_relay: None,
504 target,
505 post_kind,
506 post_author,
507 post_author_relay: None,
508 original_event_json: original_event_json.into(),
509 }
510 }
511
512 #[must_use]
514 pub fn community_relay(mut self, relay: RelayUrl) -> Self {
515 self.community_relay = Some(relay);
516 self
517 }
518
519 #[must_use]
521 pub fn post_author_relay(mut self, relay: RelayUrl) -> Self {
522 self.post_author_relay = Some(relay);
523 self
524 }
525
526 #[must_use]
528 pub fn to_tags(&self) -> Vec<Tag> {
529 let mut tags: Vec<Tag> = Vec::with_capacity(5);
530 let mut a_values: Vec<String> = Vec::with_capacity(2);
532 a_values.push(self.community.to_wire());
533 if let Some(relay) = &self.community_relay {
534 a_values.push(relay.as_str().to_owned());
535 }
536 tags.push(letter(Alphabet::A, a_values));
537
538 match &self.target {
540 ApprovalTarget::Event { id, relay_hint } => {
541 tags.push(event_tag(*id, relay_hint.as_ref()));
542 }
543 ApprovalTarget::Address {
544 coordinate,
545 relay_hint,
546 } => {
547 tags.push(address_tag(coordinate, relay_hint.as_ref()));
548 }
549 ApprovalTarget::Both {
550 id,
551 coordinate,
552 relay_hint,
553 } => {
554 tags.push(event_tag(*id, relay_hint.as_ref()));
555 tags.push(address_tag(coordinate, relay_hint.as_ref()));
556 }
557 }
558
559 let mut p_values: Vec<String> = Vec::with_capacity(2);
561 p_values.push(self.post_author.to_hex());
562 if let Some(relay) = &self.post_author_relay {
563 p_values.push(relay.as_str().to_owned());
564 }
565 tags.push(letter(Alphabet::P, p_values));
566
567 tags.push(letter(Alphabet::K, [self.post_kind.as_u16().to_string()]));
569 tags
570 }
571
572 pub fn from_event(event: &Event) -> Result<Self, ApprovalError> {
581 if event.kind != KIND_POST_APPROVAL {
582 return Err(ApprovalError::WrongKind(event.kind));
583 }
584
585 let mut community: Option<(Coordinate, Option<RelayUrl>)> = None;
586 let mut event_target: Option<(EventId, Option<RelayUrl>)> = None;
587 let mut address_target: Option<(Coordinate, Option<RelayUrl>)> = None;
588 let mut post_author: Option<(PublicKey, Option<RelayUrl>)> = None;
589 let mut post_kind: Option<Kind> = None;
590
591 for tag in &event.tags {
592 match tag.kind() {
593 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
594 dispatch_a_tag(parse_a_tag(tag)?, &mut community, &mut address_target);
595 }
596 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
597 let id_hex = tag.get(1).ok_or(ApprovalError::MalformedEventTag)?;
598 let id = EventId::parse(id_hex).map_err(ApprovalError::InvalidEventId)?;
599 let relay = tag_optional_relay(tag, 2)?;
600 event_target = Some((id, relay));
601 }
602 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
603 let pk_hex = tag.get(1).ok_or(ApprovalError::MalformedAuthorTag)?;
604 let pk = PublicKey::parse(pk_hex).map_err(ApprovalError::InvalidPublicKey)?;
605 let relay = tag_optional_relay(tag, 2)?;
606 post_author = Some((pk, relay));
607 }
608 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::K => {
609 let k_str = tag.get(1).ok_or(ApprovalError::MalformedKindTag)?;
610 let raw: u16 = k_str.parse().map_err(|_| ApprovalError::MalformedKindTag)?;
611 post_kind = Some(Kind::new(raw));
612 }
613 _ => {}
614 }
615 }
616
617 let (community, community_relay) = community.ok_or(ApprovalError::MissingCommunity)?;
618 let target = match (event_target, address_target) {
619 (Some((id, r1)), Some((coordinate, r2))) => ApprovalTarget::Both {
620 id,
621 coordinate,
622 relay_hint: r1.or(r2),
623 },
624 (Some((id, relay_hint)), None) => ApprovalTarget::Event { id, relay_hint },
625 (None, Some((coordinate, relay_hint))) => ApprovalTarget::Address {
626 coordinate,
627 relay_hint,
628 },
629 (None, None) => return Err(ApprovalError::MissingTarget),
630 };
631 let (post_author, post_author_relay) =
632 post_author.ok_or(ApprovalError::MissingPostAuthor)?;
633 let post_kind = post_kind.ok_or(ApprovalError::MissingPostKind)?;
634
635 Ok(Self {
636 community,
637 community_relay,
638 target,
639 post_kind,
640 post_author,
641 post_author_relay,
642 original_event_json: event.content.clone(),
643 })
644 }
645}
646
647fn event_tag(id: EventId, relay: Option<&RelayUrl>) -> Tag {
648 let mut values: Vec<String> = Vec::with_capacity(2);
649 values.push(id.to_hex());
650 if let Some(r) = relay {
651 values.push(r.as_str().to_owned());
652 }
653 letter(Alphabet::E, values)
654}
655
656fn address_tag(coordinate: &Coordinate, relay: Option<&RelayUrl>) -> Tag {
657 let mut values: Vec<String> = Vec::with_capacity(2);
658 values.push(coordinate.to_wire());
659 if let Some(r) = relay {
660 values.push(r.as_str().to_owned());
661 }
662 letter(Alphabet::A, values)
663}
664
665fn tag_optional_relay(tag: &Tag, idx: usize) -> Result<Option<RelayUrl>, ApprovalError> {
666 match tag.get(idx) {
667 Some(s) if !s.is_empty() => Ok(Some(
668 RelayUrl::parse(s).map_err(ApprovalError::InvalidRelayUrl)?,
669 )),
670 _ => Ok(None),
671 }
672}
673
674#[derive(Debug, Error)]
676#[non_exhaustive]
677pub enum CommunityError {
678 #[error("expected kind 34550 (community definition), got {}", .0.as_u16())]
680 WrongKind(Kind),
681 #[error("NIP-72 community must carry a `d` tag")]
683 MissingIdentifier,
684 #[error("malformed `p` moderator tag (missing pubkey)")]
686 MalformedModerator,
687 #[error("malformed `relay` tag (missing URL)")]
689 MalformedRelay,
690 #[error("invalid public key: {0}")]
692 InvalidPublicKey(#[source] PublicKeyError),
693 #[error("invalid relay URL: {0}")]
695 InvalidRelayUrl(#[source] RelayUrlError),
696 #[error("invalid image dimensions: {0}")]
699 InvalidImageDim(#[source] ImageError),
700}
701
702#[derive(Debug, Error)]
704#[non_exhaustive]
705pub enum ApprovalError {
706 #[error("expected kind 4550 (post approval), got {}", .0.as_u16())]
708 WrongKind(Kind),
709 #[error("NIP-72 approval must carry a community `a` tag")]
711 MissingCommunity,
712 #[error("NIP-72 approval must carry an `e` and/or `a` post tag")]
714 MissingTarget,
715 #[error("NIP-72 approval must carry a `p` author tag")]
717 MissingPostAuthor,
718 #[error("NIP-72 approval must carry a `k` kind tag")]
720 MissingPostKind,
721 #[error("malformed `e` post tag (missing event id)")]
723 MalformedEventTag,
724 #[error("malformed `a` tag (missing coordinate)")]
726 MalformedAddressTag,
727 #[error("malformed `p` tag (missing author)")]
729 MalformedAuthorTag,
730 #[error("malformed `k` tag (must be unsigned 16-bit kind number)")]
732 MalformedKindTag,
733 #[error("invalid coordinate: {0}")]
735 InvalidCoordinate(#[source] CoordinateError),
736 #[error("invalid event id: {0}")]
738 InvalidEventId(#[source] EventIdError),
739 #[error("invalid public key: {0}")]
741 InvalidPublicKey(#[source] PublicKeyError),
742 #[error("invalid relay URL: {0}")]
744 InvalidRelayUrl(#[source] RelayUrlError),
745}
746
747impl EventBuilder {
748 #[must_use]
750 pub fn community_definition(definition: &CommunityDefinition) -> Self {
751 let mut builder = Self::new(KIND_COMMUNITY_DEFINITION, "");
752 for tag in definition.to_tags() {
753 builder = builder.tag(tag);
754 }
755 builder
756 }
757
758 #[must_use]
760 pub fn community_post_approval(approval: &PostApproval) -> Self {
761 let mut builder = Self::new(KIND_POST_APPROVAL, approval.original_event_json.clone());
762 for tag in approval.to_tags() {
763 builder = builder.tag(tag);
764 }
765 builder
766 }
767
768 #[must_use]
774 pub fn community_top_level_post(
775 community: Coordinate,
776 community_relay: Option<RelayUrl>,
777 community_author: PublicKey,
778 content: impl Into<String>,
779 ) -> Self {
780 let scope = CommentScope::Address {
781 coordinate: community,
782 relay_hint: community_relay,
783 };
784 let comment = Comment::top_level(scope, content)
785 .with_root_kind(KIND_COMMUNITY_DEFINITION)
786 .with_root_author(community_author);
787 Self::comment(&comment)
788 }
789
790 #[must_use]
797 #[allow(
798 clippy::too_many_arguments,
799 reason = "every argument maps directly to a NIP-72 §\"Nested replies\" tag column"
800 )]
801 pub fn community_nested_reply(
802 community: Coordinate,
803 community_relay: Option<RelayUrl>,
804 community_author: PublicKey,
805 parent_post: EventId,
806 parent_relay: Option<RelayUrl>,
807 parent_kind: Kind,
808 parent_author: PublicKey,
809 content: impl Into<String>,
810 ) -> Self {
811 let root = CommentScope::Address {
812 coordinate: community,
813 relay_hint: community_relay,
814 };
815 let parent = CommentScope::Event {
816 id: parent_post,
817 relay_hint: parent_relay,
818 };
819 let comment = Comment::top_level(root, content)
820 .with_root_kind(KIND_COMMUNITY_DEFINITION)
821 .with_root_author(community_author)
822 .with_parent(parent)
823 .with_parent_kind(parent_kind)
824 .with_parent_author(parent_author);
825 Self::comment(&comment)
826 }
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832 use crate::Keys;
833
834 fn keys() -> Keys {
835 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
836 }
837
838 fn other_keys() -> Keys {
839 Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
840 }
841
842 fn fixture_definition() -> CommunityDefinition {
843 let owner = *keys().public_key();
844 let _ = owner;
845 let mod1 = CommunityModerator::new(*keys().public_key())
846 .relay_hint(RelayUrl::parse("wss://moderator-relay.example/").unwrap());
847 let mod2 = CommunityModerator::new(*other_keys().public_key());
848 let auth_relay = CommunityRelay::new(
849 RelayUrl::parse("wss://author.example/").unwrap(),
850 CommunityRelayMarker::Author,
851 );
852 let req_relay = CommunityRelay::new(
853 RelayUrl::parse("wss://requests.example/").unwrap(),
854 CommunityRelayMarker::Requests,
855 );
856 let plain_relay =
857 CommunityRelay::unmarked(RelayUrl::parse("wss://plain.example/").unwrap());
858 CommunityDefinition::new("rust-nostr")
859 .name("Rust Nostr")
860 .description("Implementations and discussion")
861 .image(
862 CommunityImage::new("https://example.com/logo.png")
863 .dim(ImageDimensions::new(800, 600).unwrap()),
864 )
865 .moderator(mod1)
866 .moderator(mod2)
867 .relay(auth_relay)
868 .relay(req_relay)
869 .relay(plain_relay)
870 }
871
872 #[test]
873 fn community_definition_round_trips_through_event() {
874 let def = fixture_definition();
875 let event = EventBuilder::community_definition(&def)
876 .sign_with_keys(&keys())
877 .unwrap();
878 assert_eq!(event.kind, KIND_COMMUNITY_DEFINITION);
879 let parsed = CommunityDefinition::from_event(&event).unwrap();
880 assert_eq!(parsed, def);
881 }
882
883 #[test]
884 fn community_definition_rejects_wrong_kind() {
885 let event = EventBuilder::text_note("nope")
886 .sign_with_keys(&keys())
887 .unwrap();
888 assert!(matches!(
889 CommunityDefinition::from_event(&event),
890 Err(CommunityError::WrongKind(_))
891 ));
892 }
893
894 #[test]
895 fn community_definition_requires_d_tag() {
896 let event = EventBuilder::new(KIND_COMMUNITY_DEFINITION, "")
897 .sign_with_keys(&keys())
898 .unwrap();
899 assert!(matches!(
900 CommunityDefinition::from_event(&event),
901 Err(CommunityError::MissingIdentifier)
902 ));
903 }
904
905 #[test]
906 fn community_relay_marker_round_trips_unknown() {
907 let m = CommunityRelayMarker::parse(Some("custom-marker"));
908 assert_eq!(m, CommunityRelayMarker::Other("custom-marker".to_owned()));
909 assert_eq!(m.as_str(), Some("custom-marker"));
910 }
911
912 #[test]
913 fn coordinate_helper_uses_owner_pubkey() {
914 let def = CommunityDefinition::new("slug");
915 let coord = def.coordinate(*keys().public_key());
916 assert_eq!(coord.kind, KIND_COMMUNITY_DEFINITION);
917 assert_eq!(coord.identifier, "slug");
918 assert_eq!(coord.author, *keys().public_key());
919 }
920
921 #[test]
922 fn approval_with_event_target_round_trips() {
923 let community = Coordinate::new(KIND_COMMUNITY_DEFINITION, *keys().public_key(), "slug");
924 let id = EventId::from_byte_array([0x77; 32]);
925 let approval = PostApproval::new(
926 community,
927 ApprovalTarget::Event {
928 id,
929 relay_hint: None,
930 },
931 Kind::TEXT_NOTE,
932 *other_keys().public_key(),
933 r#"{"id":"77...","kind":1}"#,
934 );
935 let event = EventBuilder::community_post_approval(&approval)
936 .sign_with_keys(&keys())
937 .unwrap();
938 assert_eq!(event.kind, KIND_POST_APPROVAL);
939 let parsed = PostApproval::from_event(&event).unwrap();
940 assert_eq!(parsed, approval);
941 }
942
943 #[test]
944 fn approval_with_address_target_round_trips() {
945 let community = Coordinate::new(KIND_COMMUNITY_DEFINITION, *keys().public_key(), "slug");
946 let target_coord = Coordinate::new(
947 Kind::LONG_FORM_TEXT_NOTE,
948 *other_keys().public_key(),
949 "post-1",
950 );
951 let approval = PostApproval::new(
952 community,
953 ApprovalTarget::Address {
954 coordinate: target_coord,
955 relay_hint: None,
956 },
957 Kind::LONG_FORM_TEXT_NOTE,
958 *other_keys().public_key(),
959 "{}",
960 );
961 let event = EventBuilder::community_post_approval(&approval)
962 .sign_with_keys(&keys())
963 .unwrap();
964 let parsed = PostApproval::from_event(&event).unwrap();
965 assert_eq!(parsed, approval);
966 }
967
968 #[test]
969 fn approval_with_both_targets_round_trips() {
970 let community = Coordinate::new(KIND_COMMUNITY_DEFINITION, *keys().public_key(), "slug");
971 let target_coord = Coordinate::new(
972 Kind::LONG_FORM_TEXT_NOTE,
973 *other_keys().public_key(),
974 "post-1",
975 );
976 let target_id = EventId::from_byte_array([0xab; 32]);
977 let approval = PostApproval::new(
978 community,
979 ApprovalTarget::Both {
980 id: target_id,
981 coordinate: target_coord,
982 relay_hint: None,
983 },
984 Kind::LONG_FORM_TEXT_NOTE,
985 *other_keys().public_key(),
986 "{}",
987 );
988 let event = EventBuilder::community_post_approval(&approval)
989 .sign_with_keys(&keys())
990 .unwrap();
991 let parsed = PostApproval::from_event(&event).unwrap();
992 assert_eq!(parsed, approval);
993 }
994
995 #[test]
996 fn approval_rejects_wrong_kind() {
997 let event = EventBuilder::text_note("nope")
998 .sign_with_keys(&keys())
999 .unwrap();
1000 assert!(matches!(
1001 PostApproval::from_event(&event),
1002 Err(ApprovalError::WrongKind(_))
1003 ));
1004 }
1005
1006 #[test]
1007 fn community_top_level_post_uses_kind_1111() {
1008 let community = Coordinate::new(KIND_COMMUNITY_DEFINITION, *keys().public_key(), "slug");
1009 let event =
1010 EventBuilder::community_top_level_post(community, None, *keys().public_key(), "hi")
1011 .sign_with_keys(&other_keys())
1012 .unwrap();
1013 assert_eq!(event.kind, Kind::new(1111));
1014 }
1015
1016 #[test]
1017 fn community_nested_reply_uses_kind_1111() {
1018 let community = Coordinate::new(KIND_COMMUNITY_DEFINITION, *keys().public_key(), "slug");
1019 let parent = EventId::from_byte_array([0xab; 32]);
1020 let event = EventBuilder::community_nested_reply(
1021 community,
1022 None,
1023 *keys().public_key(),
1024 parent,
1025 None,
1026 Kind::new(1111),
1027 *other_keys().public_key(),
1028 "agreed",
1029 )
1030 .sign_with_keys(&other_keys())
1031 .unwrap();
1032 assert_eq!(event.kind, Kind::new(1111));
1033 }
1034}