use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
use core::num::ParseIntError;
use core::str::FromStr;
use super::util::{take_and_parse_from_str, take_relay_url, take_string, take_timestamp};
use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions};
use crate::types::url;
use crate::{Event, EventBuilder, EventId, Kind, RelayUrl, Timestamp};
const ENDS_AT: &str = "endsAt";
const POLL_TYPE: &str = "polltype";
const POLL_OPTION: &str = "option";
const POLL_RESPONSE: &str = "response";
const RELAY: &str = "relay";
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
Url(url::Error),
ParseInt(ParseIntError),
Codec(TagCodecError),
UnknownPollType,
}
impl core::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Url(e) => e.fmt(f),
Self::ParseInt(e) => e.fmt(f),
Self::Codec(e) => e.fmt(f),
Self::UnknownPollType => f.write_str("unknown poll type"),
}
}
}
impl From<url::Error> for Error {
fn from(e: url::Error) -> Self {
Self::Url(e)
}
}
impl From<ParseIntError> for Error {
fn from(e: ParseIntError) -> Self {
Self::ParseInt(e)
}
}
impl From<TagCodecError> for Error {
fn from(e: TagCodecError) -> Self {
Self::Codec(e)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PollType {
SingleChoice,
MultipleChoice,
}
impl fmt::Display for PollType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl PollType {
pub fn as_str(&self) -> &str {
match self {
Self::SingleChoice => "singlechoice",
Self::MultipleChoice => "multiplechoice",
}
}
}
impl FromStr for PollType {
type Err = Error;
fn from_str(poll_type: &str) -> Result<Self, Self::Err> {
match poll_type {
"singlechoice" => Ok(Self::SingleChoice),
"multiplechoice" => Ok(Self::MultipleChoice),
_ => Err(Error::UnknownPollType),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PollOption {
pub id: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Nip88Tag {
PollOption(PollOption),
PollResponse(String),
PollType(PollType),
Relay(RelayUrl),
PollEndsAt(Timestamp),
}
impl TagCodec for Nip88Tag {
type Error = Error;
fn parse<I, S>(tag: I) -> Result<Self, Self::Error>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut iter = tag.into_iter();
let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?;
match kind.as_ref() {
POLL_OPTION => Ok(Self::PollOption(PollOption {
id: take_string(&mut iter, "poll ID")?,
text: take_string(&mut iter, "poll option text")?,
})),
POLL_RESPONSE => Ok(Self::PollResponse(take_string(&mut iter, "poll response")?)),
POLL_TYPE => {
let poll_type: PollType =
take_and_parse_from_str::<_, _, _, Error>(&mut iter, "poll type")?;
Ok(Self::PollType(poll_type))
}
RELAY => {
let relay: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?;
Ok(Self::Relay(relay))
}
ENDS_AT => {
let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?;
Ok(Self::PollEndsAt(timestamp))
}
_ => Err(TagCodecError::Unknown.into()),
}
}
fn to_tag(&self) -> Tag {
match self {
Self::PollOption(option) => Tag::new(vec![
String::from(POLL_OPTION),
option.id.clone(),
option.text.clone(),
]),
Self::PollResponse(response) => {
Tag::new(vec![String::from(POLL_RESPONSE), response.clone()])
}
Self::PollType(poll_type) => {
Tag::new(vec![String::from(POLL_TYPE), poll_type.to_string()])
}
Self::Relay(relay) => Tag::new(vec![String::from(RELAY), relay.to_string()]),
Self::PollEndsAt(timestamp) => {
Tag::new(vec![String::from(ENDS_AT), timestamp.to_string()])
}
}
}
}
impl_tag_codec_conversions!(Nip88Tag);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Poll {
pub title: String,
pub r#type: PollType,
pub options: Vec<PollOption>,
pub relays: Vec<RelayUrl>,
pub ends_at: Option<Timestamp>,
}
impl Poll {
pub fn from_event(event: &Event) -> Result<Self, Error> {
let mut poll_type: PollType = PollType::SingleChoice;
let mut options: Vec<PollOption> = Vec::new();
let mut relays: Vec<RelayUrl> = Vec::new();
let mut ends_at: Option<Timestamp> = None;
for tag in event.tags.iter() {
match Nip88Tag::try_from(tag) {
Ok(Nip88Tag::PollType(value)) => poll_type = value,
Ok(Nip88Tag::PollOption(option)) => options.push(option),
Ok(Nip88Tag::Relay(url)) => relays.push(url),
Ok(Nip88Tag::PollEndsAt(timestamp)) => ends_at = Some(timestamp),
Ok(Nip88Tag::PollResponse(..)) | Err(Error::Codec(TagCodecError::Unknown)) => (),
Err(Error::UnknownPollType)
| Err(Error::Codec(TagCodecError::Missing("poll type"))) => (),
Err(e) => return Err(e),
}
}
Ok(Self {
title: event.content.clone(),
r#type: poll_type,
options,
relays,
ends_at,
})
}
#[allow(clippy::wrong_self_convention)]
pub(crate) fn to_event_builder(self) -> EventBuilder {
let mut tags: Vec<Tag> = Vec::with_capacity(1 + self.options.len() + self.relays.len());
tags.push(Nip88Tag::PollType(self.r#type).to_tag());
for option in self.options.into_iter() {
tags.push(Nip88Tag::PollOption(option).to_tag());
}
for url in self.relays.into_iter() {
tags.push(Nip88Tag::Relay(url).to_tag());
}
if let Some(timestamp) = self.ends_at {
tags.push(Nip88Tag::PollEndsAt(timestamp).to_tag());
}
EventBuilder::new(Kind::Poll, self.title).tags(tags)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PollResponse {
SingleChoice {
poll_id: EventId,
response: String,
},
MultipleChoice {
poll_id: EventId,
responses: Vec<String>,
},
}
impl PollResponse {
#[allow(clippy::wrong_self_convention)]
pub(crate) fn to_event_builder(self) -> EventBuilder {
let tags: Vec<Tag> = match self {
Self::SingleChoice { poll_id, response } => {
vec![
Tag::event(poll_id),
Nip88Tag::PollResponse(response).to_tag(),
]
}
Self::MultipleChoice { poll_id, responses } => {
let mut tags: Vec<Tag> = Vec::with_capacity(1 + responses.len());
tags.push(Tag::event(poll_id));
for response in responses.into_iter() {
tags.push(Nip88Tag::PollResponse(response).to_tag());
}
tags
}
};
EventBuilder::new(Kind::PollResponse, "").tags(tags)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::JsonUtil;
#[test]
fn test_poll_type() {
assert_eq!(PollType::SingleChoice.as_str(), "singlechoice");
assert_eq!(PollType::MultipleChoice.as_str(), "multiplechoice");
assert_eq!(
PollType::from_str("singlechoice").unwrap(),
PollType::SingleChoice
);
assert_eq!(
PollType::from_str("multiplechoice").unwrap(),
PollType::MultipleChoice
);
assert!(PollType::from_str("unknown").is_err());
}
#[test]
fn test_standardized_poll_tags() {
let option = Nip88Tag::parse(["option", "qj518h583", "Yay"]).unwrap();
assert_eq!(
option,
Nip88Tag::PollOption(PollOption {
id: "qj518h583".to_string(),
text: "Yay".to_string(),
})
);
assert_eq!(
option.to_tag(),
Tag::parse(["option", "qj518h583", "Yay"]).unwrap()
);
let poll_type = Nip88Tag::parse(["polltype", "multiplechoice"]).unwrap();
assert_eq!(poll_type, Nip88Tag::PollType(PollType::MultipleChoice));
assert_eq!(
poll_type.to_tag(),
Tag::parse(["polltype", "multiplechoice"]).unwrap()
);
let relay = RelayUrl::parse("wss://relay.damus.io").unwrap();
let relay_tag = Nip88Tag::parse(["relay", "wss://relay.damus.io"]).unwrap();
assert_eq!(relay_tag, Nip88Tag::Relay(relay.clone()));
assert_eq!(
relay_tag.to_tag(),
Tag::parse(["relay", relay.as_str()]).unwrap()
);
let ends_at = Nip88Tag::parse(["endsAt", "1788888888"]).unwrap();
assert_eq!(
ends_at,
Nip88Tag::PollEndsAt(Timestamp::from_secs(1788888888))
);
assert_eq!(
ends_at.to_tag(),
Tag::parse(["endsAt", "1788888888"]).unwrap()
);
let response = Nip88Tag::parse(["response", "qj518h583"]).unwrap();
assert_eq!(response, Nip88Tag::PollResponse("qj518h583".to_string()));
assert_eq!(
response.to_tag(),
Tag::parse(["response", "qj518h583"]).unwrap()
);
}
#[test]
fn test_poll_from_event() {
let event = Event::from_json(r#"{
"content": "Pineapple on pizza",
"created_at": 1719888496,
"id": "9d1b6b9562e66f2ecf35eb0a3c2decc736c47fddb13d6fb8f87185a153ea3634",
"kind": 1068,
"pubkey": "dee45a23c4f1d93f3a2043650c5081e4ac14a778e0acbef03de3768e4f81ac7b",
"sig": "7fa93bf3c430eaef784b0dacc217d3cd5eff1c520e7ef5d961381bc0f014dde6286618048d924808e54d1be03f2f2c2f0f8b5c9c2082a4480caf45a565ca9797",
"tags": [
["option", "qj518h583", "Yay"],
["option", "gga6cdnqj", "Nay"],
["relay", "wss://relay.damus.io"],
["relay", "wss://relay.example.com"],
["polltype", "multiplechoice"],
["endsAt", "1788888888"]
]
}"#).unwrap();
let poll = Poll::from_event(&event).unwrap();
assert_eq!(poll.title, "Pineapple on pizza");
assert_eq!(poll.r#type, PollType::MultipleChoice);
assert_eq!(
poll.options,
vec![
PollOption {
id: "qj518h583".to_string(),
text: "Yay".to_string(),
},
PollOption {
id: "gga6cdnqj".to_string(),
text: "Nay".to_string(),
}
]
);
assert_eq!(
poll.relays,
vec![
RelayUrl::from_str("wss://relay.damus.io").unwrap(),
RelayUrl::from_str("wss://relay.example.com").unwrap(),
]
);
assert_eq!(poll.ends_at, Some(Timestamp::from_secs(1788888888)));
}
#[test]
fn test_poll_from_event_without_poll_type() {
let event = Event::from_json(r#"{
"content": "Pineapple on pizza",
"created_at": 1719888496,
"id": "9d1b6b9562e66f2ecf35eb0a3c2decc736c47fddb13d6fb8f87185a153ea3634",
"kind": 1068,
"pubkey": "dee45a23c4f1d93f3a2043650c5081e4ac14a778e0acbef03de3768e4f81ac7b",
"sig": "7fa93bf3c430eaef784b0dacc217d3cd5eff1c520e7ef5d961381bc0f014dde6286618048d924808e54d1be03f2f2c2f0f8b5c9c2082a4480caf45a565ca9797",
"tags": [
["option", "qj518h583", "Yay"]
]
}"#).unwrap();
let poll = Poll::from_event(&event).unwrap();
assert_eq!(poll.title, "Pineapple on pizza");
assert_eq!(poll.r#type, PollType::SingleChoice);
assert_eq!(
poll.options,
vec![PollOption {
id: "qj518h583".to_string(),
text: "Yay".to_string(),
},]
);
assert!(poll.relays.is_empty());
assert!(poll.ends_at.is_none());
}
#[test]
fn test_poll_from_event_with_empty_poll_type() {
let event = Event::from_json(r#"{
"content": "Pineapple on pizza",
"created_at": 1719888496,
"id": "9d1b6b9562e66f2ecf35eb0a3c2decc736c47fddb13d6fb8f87185a153ea3634",
"kind": 1068,
"pubkey": "dee45a23c4f1d93f3a2043650c5081e4ac14a778e0acbef03de3768e4f81ac7b",
"sig": "7fa93bf3c430eaef784b0dacc217d3cd5eff1c520e7ef5d961381bc0f014dde6286618048d924808e54d1be03f2f2c2f0f8b5c9c2082a4480caf45a565ca9797",
"tags": [
["option", "qj518h583", "Yay"],
["polltype", ""]
]
}"#).unwrap();
let poll = Poll::from_event(&event).unwrap();
assert_eq!(poll.title, "Pineapple on pizza");
assert_eq!(poll.r#type, PollType::SingleChoice);
assert_eq!(
poll.options,
vec![PollOption {
id: "qj518h583".to_string(),
text: "Yay".to_string(),
},]
);
assert!(poll.relays.is_empty());
assert!(poll.ends_at.is_none());
}
#[test]
fn test_poll_from_event_with_malformed_polltype_tag() {
let event = Event::from_json(r#"{
"content": "Pineapple on pizza",
"created_at": 1719888496,
"id": "9d1b6b9562e66f2ecf35eb0a3c2decc736c47fddb13d6fb8f87185a153ea3634",
"kind": 1068,
"pubkey": "dee45a23c4f1d93f3a2043650c5081e4ac14a778e0acbef03de3768e4f81ac7b",
"sig": "7fa93bf3c430eaef784b0dacc217d3cd5eff1c520e7ef5d961381bc0f014dde6286618048d924808e54d1be03f2f2c2f0f8b5c9c2082a4480caf45a565ca9797",
"tags": [
["option", "qj518h583", "Yay"],
["polltype"]
]
}"#).unwrap();
let poll = Poll::from_event(&event).unwrap();
assert_eq!(poll.title, "Pineapple on pizza");
assert_eq!(poll.r#type, PollType::SingleChoice);
assert_eq!(
poll.options,
vec![PollOption {
id: "qj518h583".to_string(),
text: "Yay".to_string(),
},]
);
assert!(poll.relays.is_empty());
assert!(poll.ends_at.is_none());
}
}