1use thiserror::Error;
33
34use crate::event::{
35 Alphabet, Coordinate, Event, EventBuilder, EventId, Kind, SingleLetterTag, Tag, TagKind,
36};
37use crate::key::PublicKey;
38use crate::types::Timestamp;
39
40pub const KIND_USER_STATUS: Kind = Kind::new(30_315);
42
43pub const STATUS_TYPE_GENERAL: &str = "general";
45
46pub const STATUS_TYPE_MUSIC: &str = "music";
48
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55#[non_exhaustive]
56pub enum StatusType {
57 General,
59 Music,
62 Custom(String),
64}
65
66impl StatusType {
67 #[must_use]
69 pub fn parse(identifier: &str) -> Self {
70 match identifier {
71 STATUS_TYPE_GENERAL => Self::General,
72 STATUS_TYPE_MUSIC => Self::Music,
73 other => Self::Custom(other.to_owned()),
74 }
75 }
76
77 #[must_use]
79 pub const fn as_str(&self) -> &str {
80 match self {
81 Self::General => STATUS_TYPE_GENERAL,
82 Self::Music => STATUS_TYPE_MUSIC,
83 Self::Custom(s) => s.as_str(),
84 }
85 }
86}
87
88impl std::fmt::Display for StatusType {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.write_str(self.as_str())
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum StatusLink {
102 Web(String),
108 Profile(PublicKey),
110 Event(EventId),
112 Addressable(Coordinate),
114}
115
116impl StatusLink {
117 #[must_use]
119 pub fn to_tag(&self) -> Tag {
120 match self {
121 Self::Web(uri) => {
122 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R));
123 Tag::with(&head, [uri.clone()])
124 }
125 Self::Profile(pk) => Tag::p(*pk),
126 Self::Event(id) => Tag::e(*id),
127 Self::Addressable(coord) => {
128 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
129 Tag::with(&head, [coord.to_wire()])
130 }
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct UserStatus {
138 pub status_type: StatusType,
140 pub content: String,
142 pub link: Option<StatusLink>,
144 pub expires_at: Option<Timestamp>,
146}
147
148impl UserStatus {
149 #[must_use]
151 pub fn new(status_type: StatusType, content: impl Into<String>) -> Self {
152 Self {
153 status_type,
154 content: content.into(),
155 link: None,
156 expires_at: None,
157 }
158 }
159
160 #[must_use]
163 pub fn with_link(mut self, link: StatusLink) -> Self {
164 self.link = Some(link);
165 self
166 }
167
168 #[must_use]
170 pub const fn with_expiration(mut self, ts: Timestamp) -> Self {
171 self.expires_at = Some(ts);
172 self
173 }
174
175 #[must_use]
178 pub const fn is_clear(&self) -> bool {
179 self.content.is_empty()
180 }
181
182 pub fn from_event(event: &Event) -> Result<Self, UserStatusError> {
191 if event.kind != KIND_USER_STATUS {
192 return Err(UserStatusError::WrongKind(event.kind));
193 }
194 let d = find_d_tag(event).ok_or(UserStatusError::MissingDTag)?;
195 let status_type = StatusType::parse(d);
196 let link = parse_link(event);
197 let expires_at = event.expiration().ok().flatten();
198 Ok(Self {
199 status_type,
200 content: event.content.clone(),
201 link,
202 expires_at,
203 })
204 }
205}
206
207#[derive(Debug, Error)]
209#[non_exhaustive]
210pub enum UserStatusError {
211 #[error("expected kind 30315 (user status), got kind {}", .0.as_u16())]
213 WrongKind(Kind),
214 #[error("NIP-38 event must carry exactly one `d` tag")]
216 MissingDTag,
217}
218
219fn find_d_tag(event: &Event) -> Option<&str> {
220 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
221 event.tags.find_first(&head).and_then(|tag| tag.get(1))
222}
223
224fn parse_link(event: &Event) -> Option<StatusLink> {
225 for tag in &event.tags {
226 let TagKind::SingleLetter(letter) = tag.kind() else {
227 continue;
228 };
229 if letter.uppercase {
230 continue;
231 }
232 let Some(value) = tag.get(1) else {
233 continue;
234 };
235 let link = match letter.character {
236 Alphabet::R => Some(StatusLink::Web(value.to_owned())),
237 Alphabet::P => PublicKey::parse(value).ok().map(StatusLink::Profile),
238 Alphabet::E => EventId::parse(value).ok().map(StatusLink::Event),
239 Alphabet::A => Coordinate::parse(value).ok().map(StatusLink::Addressable),
240 _ => None,
241 };
242 if let Some(link) = link {
243 return Some(link);
244 }
245 }
246 None
247}
248
249impl EventBuilder {
250 #[must_use]
260 pub fn user_status(status: UserStatus) -> Self {
261 let mut builder =
262 Self::new(KIND_USER_STATUS, status.content).tag(Tag::d(status.status_type.as_str()));
263 if let Some(link) = status.link {
264 builder = builder.tag(link.to_tag());
265 }
266 if let Some(ts) = status.expires_at {
267 builder = builder.expiration(ts);
268 }
269 builder
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::Keys;
277
278 fn keys() -> Keys {
279 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
280 }
281
282 #[test]
283 fn status_type_round_trips_through_parse_and_as_str() {
284 for (s, v) in [
285 ("general", StatusType::General),
286 ("music", StatusType::Music),
287 ("lunar-phase", StatusType::Custom("lunar-phase".into())),
288 ] {
289 let parsed = StatusType::parse(s);
290 assert_eq!(parsed, v);
291 assert_eq!(parsed.as_str(), s);
292 }
293 }
294
295 #[test]
296 fn builder_emits_kind_and_d_tag() {
297 let status = UserStatus::new(StatusType::General, "Working");
298 let event = EventBuilder::user_status(status)
299 .sign_with_keys(&keys())
300 .unwrap();
301 assert_eq!(event.kind, KIND_USER_STATUS);
302 assert_eq!(event.content, "Working");
303 let d = find_d_tag(&event).unwrap();
304 assert_eq!(d, STATUS_TYPE_GENERAL);
305 }
306
307 #[test]
308 fn builder_attaches_web_link_and_expiration() {
309 let uri = "spotify:search:Intergalatic".to_owned();
310 let status = UserStatus::new(StatusType::Music, "Intergalatic - Beastie Boys")
311 .with_link(StatusLink::Web(uri.clone()))
312 .with_expiration(Timestamp::from_secs(1_692_845_589));
313 let event = EventBuilder::user_status(status)
314 .sign_with_keys(&keys())
315 .unwrap();
316
317 let parsed = UserStatus::from_event(&event).unwrap();
318 assert_eq!(parsed.status_type, StatusType::Music);
319 assert_eq!(parsed.content, "Intergalatic - Beastie Boys");
320 assert_eq!(parsed.link, Some(StatusLink::Web(uri)));
321 assert_eq!(parsed.expires_at, Some(Timestamp::from_secs(1_692_845_589)));
322 }
323
324 #[test]
325 fn from_event_round_trips_profile_link() {
326 let pk = *keys().public_key();
327 let status =
328 UserStatus::new(StatusType::General, "mentoring").with_link(StatusLink::Profile(pk));
329 let event = EventBuilder::user_status(status)
330 .sign_with_keys(&keys())
331 .unwrap();
332 let parsed = UserStatus::from_event(&event).unwrap();
333 assert_eq!(parsed.link, Some(StatusLink::Profile(pk)));
334 }
335
336 #[test]
337 fn from_event_rejects_wrong_kind() {
338 let event = EventBuilder::text_note("not a status")
339 .sign_with_keys(&keys())
340 .unwrap();
341 assert!(matches!(
342 UserStatus::from_event(&event),
343 Err(UserStatusError::WrongKind(_))
344 ));
345 }
346
347 #[test]
348 fn empty_content_signals_a_clear() {
349 let status = UserStatus::new(StatusType::General, "");
350 assert!(status.is_clear());
351 }
352
353 #[test]
354 fn custom_status_type_round_trips_on_d_tag() {
355 let status = UserStatus::new(StatusType::Custom("focus".into()), "heads-down");
356 let event = EventBuilder::user_status(status)
357 .sign_with_keys(&keys())
358 .unwrap();
359 let parsed = UserStatus::from_event(&event).unwrap();
360 assert_eq!(parsed.status_type, StatusType::Custom("focus".into()));
361 }
362}