1use thiserror::Error;
14
15use crate::event::{
16 Alphabet, Event, EventBuilder, EventId, EventIdError, Kind, SingleLetterTag, Tag, TagKind,
17};
18use crate::types::{RelayUrl, RelayUrlError, Timestamp, TimestampError};
19
20pub const KIND_POLL: Kind = Kind::POLL;
22
23pub const KIND_POLL_RESPONSE: Kind = Kind::POLL_RESPONSE;
25
26const OPTION_TAG: &str = "option";
27const RELAY_TAG: &str = "relay";
28const POLLTYPE_TAG: &str = "polltype";
29const ENDS_AT_TAG: &str = "endsAt";
30const RESPONSE_TAG: &str = "response";
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
35pub enum PollType {
36 #[default]
38 SingleChoice,
39 MultipleChoice,
41 Custom(String),
43}
44
45impl PollType {
46 #[must_use]
48 #[expect(
49 clippy::missing_const_for_fn,
50 reason = "`Self::Custom` borrows from a heap `String`"
51 )]
52 pub fn as_str(&self) -> &str {
53 match self {
54 Self::SingleChoice => "singlechoice",
55 Self::MultipleChoice => "multiplechoice",
56 Self::Custom(s) => s.as_str(),
57 }
58 }
59
60 #[must_use]
63 pub fn parse(token: &str) -> Self {
64 match token {
65 "singlechoice" => Self::SingleChoice,
66 "multiplechoice" => Self::MultipleChoice,
67 _ => Self::Custom(token.to_owned()),
68 }
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Hash)]
74pub struct PollOption {
75 pub id: String,
77 pub label: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Default)]
83pub struct Poll {
84 pub label: String,
86 pub options: Vec<PollOption>,
88 pub relays: Vec<RelayUrl>,
90 pub poll_type: Option<PollType>,
92 pub ends_at: Option<Timestamp>,
94 pub extra_tags: Vec<Tag>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct PollResponse {
101 pub poll_id: EventId,
103 pub response_ids: Vec<String>,
105 pub extra_tags: Vec<Tag>,
107}
108
109#[derive(Debug, Error)]
111#[non_exhaustive]
112pub enum PollError {
113 #[error("unexpected kind for NIP-88 event: {}", .0.as_u16())]
115 WrongKind(Kind),
116 #[error("`option` tag missing id or label")]
118 MalformedOption,
119 #[error("poll response missing `e` reference to poll event id")]
121 MissingPollReference,
122 #[error("`response` tag missing option id")]
124 MalformedResponse,
125 #[error(transparent)]
127 InvalidRelayUrl(#[from] RelayUrlError),
128 #[error(transparent)]
130 InvalidEventId(#[from] EventIdError),
131 #[error(transparent)]
133 InvalidTimestamp(#[from] TimestampError),
134}
135
136impl Poll {
137 #[must_use]
139 pub fn new(label: impl Into<String>, options: Vec<PollOption>) -> Self {
140 Self {
141 label: label.into(),
142 options,
143 ..Self::default()
144 }
145 }
146
147 pub fn from_event(event: &Event) -> Result<Self, PollError> {
153 if event.kind != KIND_POLL {
154 return Err(PollError::WrongKind(event.kind));
155 }
156 let mut out = Self::new(event.content.clone(), Vec::new());
157 for tag in &event.tags {
158 absorb_poll_tag(tag, &mut out)?;
159 }
160 Ok(out)
161 }
162
163 #[must_use]
166 pub fn effective_type(&self) -> PollType {
167 self.poll_type.clone().unwrap_or_default()
168 }
169}
170
171fn absorb_poll_tag(tag: &Tag, out: &mut Poll) -> Result<(), PollError> {
172 match tag.name() {
173 OPTION_TAG => {
174 let id = tag.get(1).ok_or(PollError::MalformedOption)?.to_owned();
175 let label = tag.get(2).ok_or(PollError::MalformedOption)?.to_owned();
176 out.options.push(PollOption { id, label });
177 }
178 RELAY_TAG => {
179 if let Some(raw) = tag.get(1) {
180 out.relays.push(RelayUrl::parse(raw)?);
181 }
182 }
183 POLLTYPE_TAG => {
184 out.poll_type = tag.get(1).map(PollType::parse);
185 }
186 ENDS_AT_TAG => {
187 if let Some(raw) = tag.get(1) {
188 out.ends_at = Some(raw.parse::<Timestamp>()?);
189 }
190 }
191 _ => out.extra_tags.push(tag.clone()),
192 }
193 Ok(())
194}
195
196impl PollResponse {
197 #[must_use]
199 pub fn single(poll_id: EventId, option_id: impl Into<String>) -> Self {
200 Self {
201 poll_id,
202 response_ids: vec![option_id.into()],
203 extra_tags: Vec::new(),
204 }
205 }
206
207 pub fn from_event(event: &Event) -> Result<Self, PollError> {
213 if event.kind != KIND_POLL_RESPONSE {
214 return Err(PollError::WrongKind(event.kind));
215 }
216 let mut poll_id: Option<EventId> = None;
217 let mut response_ids: Vec<String> = Vec::new();
218 let mut extra_tags: Vec<Tag> = Vec::new();
219 for tag in &event.tags {
220 match tag.kind() {
221 TagKind::SingleLetter(s)
222 if !s.uppercase && s.character == Alphabet::E && poll_id.is_none() =>
223 {
224 let raw = tag.get(1).ok_or(PollError::MissingPollReference)?;
225 poll_id = Some(EventId::parse(raw)?);
226 }
227 _ if tag.name() == RESPONSE_TAG => {
228 let raw = tag.get(1).ok_or(PollError::MalformedResponse)?;
229 response_ids.push(raw.to_owned());
230 }
231 _ => extra_tags.push(tag.clone()),
232 }
233 }
234 Ok(Self {
235 poll_id: poll_id.ok_or(PollError::MissingPollReference)?,
236 response_ids,
237 extra_tags,
238 })
239 }
240}
241
242impl EventBuilder {
243 #[must_use]
245 pub fn poll(poll: &Poll) -> Self {
246 let mut builder = Self::new(KIND_POLL, poll.label.clone());
247 for option in &poll.options {
248 builder = builder.tag(Tag::with(
249 &TagKind::from_wire(OPTION_TAG),
250 [option.id.clone(), option.label.clone()],
251 ));
252 }
253 for relay in &poll.relays {
254 builder = builder.tag(Tag::with(
255 &TagKind::from_wire(RELAY_TAG),
256 [relay.as_str().to_owned()],
257 ));
258 }
259 if let Some(pt) = &poll.poll_type {
260 builder = builder.tag(Tag::with(
261 &TagKind::from_wire(POLLTYPE_TAG),
262 [pt.as_str().to_owned()],
263 ));
264 }
265 if let Some(ts) = poll.ends_at {
266 builder = builder.tag(Tag::with(
267 &TagKind::from_wire(ENDS_AT_TAG),
268 [ts.as_secs().to_string()],
269 ));
270 }
271 for tag in &poll.extra_tags {
272 builder = builder.tag(tag.clone());
273 }
274 builder
275 }
276
277 #[must_use]
279 pub fn poll_response(response: &PollResponse) -> Self {
280 let head_e = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
281 let mut builder = Self::new(KIND_POLL_RESPONSE, "");
282 builder = builder.tag(Tag::with(&head_e, [response.poll_id.to_hex()]));
283 for option_id in &response.response_ids {
284 builder = builder.tag(Tag::with(
285 &TagKind::from_wire(RESPONSE_TAG),
286 [option_id.clone()],
287 ));
288 }
289 for tag in &response.extra_tags {
290 builder = builder.tag(tag.clone());
291 }
292 builder
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::Keys;
300
301 fn keys() -> Keys {
302 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
303 }
304
305 #[test]
306 fn poll_round_trip() {
307 let poll = Poll {
308 label: "Pineapple on pizza".into(),
309 options: vec![
310 PollOption {
311 id: "yay".into(),
312 label: "Yay".into(),
313 },
314 PollOption {
315 id: "nay".into(),
316 label: "Nay".into(),
317 },
318 ],
319 relays: vec![RelayUrl::parse("wss://relay.example/").unwrap()],
320 poll_type: Some(PollType::SingleChoice),
321 ends_at: Some(Timestamp::from_secs(1_700_000_000)),
322 extra_tags: Vec::new(),
323 };
324 let event = EventBuilder::poll(&poll).sign_with_keys(&keys()).unwrap();
325 let parsed = Poll::from_event(&event).unwrap();
326 assert_eq!(parsed, poll);
327 }
328
329 #[test]
330 fn poll_response_round_trip() {
331 let response = PollResponse {
332 poll_id: EventId::from_byte_array([0x77; 32]),
333 response_ids: vec!["yay".into(), "nay".into()],
334 extra_tags: Vec::new(),
335 };
336 let event = EventBuilder::poll_response(&response)
337 .sign_with_keys(&keys())
338 .unwrap();
339 let parsed = PollResponse::from_event(&event).unwrap();
340 assert_eq!(parsed, response);
341 }
342
343 #[test]
344 fn missing_poll_kind_is_rejected() {
345 let event = EventBuilder::text_note("nope")
346 .sign_with_keys(&keys())
347 .unwrap();
348 assert!(matches!(
349 Poll::from_event(&event),
350 Err(PollError::WrongKind(_))
351 ));
352 }
353
354 #[test]
355 fn poll_default_type() {
356 let poll = Poll::new("q", Vec::new());
357 assert_eq!(poll.effective_type(), PollType::SingleChoice);
358 }
359}