use serde::{Deserialize, Serialize};
use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
use crate::event::{EventBuilder, Kind};
use crate::types::Url;
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Metadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub website: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub picture: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub banner: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nip05: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lud06: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lud16: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bot: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub birthday: Option<Birthday>,
#[serde(flatten)]
pub custom: JsonMap<String, JsonValue>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Birthday {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub year: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub month: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub day: Option<u8>,
}
impl Birthday {
#[must_use]
pub const fn new(year: u16, month: u8, day: u8) -> Self {
Self {
year: Some(year),
month: Some(month),
day: Some(day),
}
}
#[must_use]
pub const fn month_day(month: u8, day: u8) -> Self {
Self {
year: None,
month: Some(month),
day: Some(day),
}
}
}
impl Metadata {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_name<S: Into<String>>(mut self, name: S) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn with_display_name<S: Into<String>>(mut self, name: S) -> Self {
self.display_name = Some(name.into());
self
}
#[must_use]
pub fn with_about<S: Into<String>>(mut self, about: S) -> Self {
self.about = Some(about.into());
self
}
#[must_use]
pub fn with_website(mut self, website: Url) -> Self {
self.website = Some(website);
self
}
#[must_use]
pub fn with_picture(mut self, picture: Url) -> Self {
self.picture = Some(picture);
self
}
#[must_use]
pub fn with_banner(mut self, banner: Url) -> Self {
self.banner = Some(banner);
self
}
#[must_use]
pub fn with_nip05<S: Into<String>>(mut self, nip05: S) -> Self {
self.nip05 = Some(nip05.into());
self
}
#[must_use]
pub fn with_lud06<S: Into<String>>(mut self, lud06: S) -> Self {
self.lud06 = Some(lud06.into());
self
}
#[must_use]
pub fn with_lud16<S: Into<String>>(mut self, lud16: S) -> Self {
self.lud16 = Some(lud16.into());
self
}
#[must_use]
pub const fn with_bot(mut self, bot: bool) -> Self {
self.bot = Some(bot);
self
}
#[must_use]
pub const fn with_birthday(mut self, birthday: Birthday) -> Self {
self.birthday = Some(birthday);
self
}
#[must_use]
pub fn legacy_display_name(&self) -> Option<&str> {
self.custom.get("displayName").and_then(JsonValue::as_str)
}
#[must_use]
pub fn legacy_username(&self) -> Option<&str> {
self.custom.get("username").and_then(JsonValue::as_str)
}
#[must_use]
pub fn with_custom<S, V>(mut self, key: S, value: V) -> Self
where
S: Into<String>,
V: Into<JsonValue>,
{
self.custom.insert(key.into(), value.into());
self
}
pub fn to_event_content(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn from_event_content(content: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(content)
}
}
impl EventBuilder {
pub fn metadata(metadata: &Metadata) -> Result<Self, serde_json::Error> {
let content = metadata.to_event_content()?;
Ok(Self::new(Kind::METADATA, content))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
#[test]
fn empty_round_trip() {
let meta = Metadata::default();
let json = meta.to_event_content().unwrap();
assert_eq!(json, "{}");
let parsed = Metadata::from_event_content(&json).unwrap();
assert_eq!(parsed, meta);
}
#[test]
fn populated_round_trip() {
let meta = Metadata::new()
.with_name("alice")
.with_display_name("Alice")
.with_about("Cypherpunk.")
.with_website(Url::parse("https://alice.example").unwrap())
.with_picture(Url::parse("https://alice.example/pfp.png").unwrap())
.with_banner(Url::parse("https://alice.example/banner.png").unwrap())
.with_nip05("alice@alice.example")
.with_lud06("LNURL1...")
.with_lud16("alice@getalby.com");
let json = meta.to_event_content().unwrap();
let parsed = Metadata::from_event_content(&json).unwrap();
assert_eq!(parsed, meta);
}
#[test]
fn custom_fields_survive_round_trip() {
let meta = Metadata::new()
.with_name("alice")
.with_custom("custom_handle", "@alice")
.with_custom("x_internal_id", 7);
let json = meta.to_event_content().unwrap();
let parsed = Metadata::from_event_content(&json).unwrap();
assert_eq!(parsed, meta);
assert_eq!(
parsed.custom.get("custom_handle"),
Some(&JsonValue::String("@alice".into()))
);
assert_eq!(
parsed.custom.get("x_internal_id"),
Some(&JsonValue::Number(7.into()))
);
assert!(parsed.custom.get("bot").is_none());
}
#[test]
fn unknown_fields_remain_in_custom() {
let json = r#"{"name":"alice","future_field":42}"#;
let meta = Metadata::from_event_content(json).unwrap();
assert_eq!(meta.name.as_deref(), Some("alice"));
assert_eq!(
meta.custom.get("future_field"),
Some(&JsonValue::Number(42.into()))
);
}
#[test]
fn event_builder_metadata_helper_signs_kind_zero() {
let meta = Metadata::new()
.with_name("alice")
.with_about("Hello, Nostr.");
let event = EventBuilder::metadata(&meta)
.unwrap()
.sign_with_keys(&keys())
.unwrap();
assert_eq!(event.kind, Kind::METADATA);
let parsed = Metadata::from_event_content(&event.content).unwrap();
assert_eq!(parsed, meta);
event.verify().unwrap();
}
mod nip24_fixtures {
use super::*;
#[test]
fn nip01_minimal_round_trip() {
let json = r#"{"name":"alice","about":"cypherpunk","picture":"https://alice.example/pfp.png"}"#;
let parsed = Metadata::from_event_content(json).unwrap();
assert_eq!(parsed.name.as_deref(), Some("alice"));
assert_eq!(parsed.about.as_deref(), Some("cypherpunk"));
assert_eq!(
parsed.picture.as_ref().map(Url::as_str),
Some("https://alice.example/pfp.png"),
);
let again = Metadata::from_event_content(&parsed.to_event_content().unwrap()).unwrap();
assert_eq!(again, parsed);
}
#[test]
fn nip24_full_payload_round_trip() {
let meta = Metadata::new()
.with_name("alice")
.with_display_name("Alice the Cypherpunk")
.with_about("Building on Nostr.")
.with_website(Url::parse("https://alice.example").unwrap())
.with_picture(Url::parse("https://alice.example/pfp.png").unwrap())
.with_banner(Url::parse("https://alice.example/banner.png").unwrap())
.with_nip05("alice@alice.example")
.with_lud06("LNURL1DP68GURN8GHJ7AMPD3KX2AR0VEEKZAR0WD5XJTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHKZURF9AKXUATJD3CZ7CT9XGEK2ATWXSHHQH4UQAQE")
.with_lud16("alice@getalby.com")
.with_bot(false)
.with_birthday(Birthday::new(1990, 6, 15));
let json = meta.to_event_content().unwrap();
for needle in [
r#""name":"alice""#,
r#""display_name":"Alice the Cypherpunk""#,
r#""website":"https://alice.example/""#,
r#""banner":"https://alice.example/banner.png""#,
r#""nip05":"alice@alice.example""#,
r#""lud16":"alice@getalby.com""#,
r#""bot":false"#,
r#""day":15"#,
r#""month":6"#,
r#""year":1990"#,
] {
assert!(
json.contains(needle),
"missing `{needle}` in serialized metadata: {json}",
);
}
assert_eq!(Metadata::from_event_content(&json).unwrap(), meta);
}
#[test]
fn forward_compat_unknown_fields_round_trip() {
let json = r#"{"name":"bob","damus_donation_v2":21,"website":"https://b.example/"}"#;
let parsed = Metadata::from_event_content(json).unwrap();
assert_eq!(parsed.name.as_deref(), Some("bob"));
assert_eq!(
parsed.custom.get("damus_donation_v2"),
Some(&serde_json::Value::Number(21.into()))
);
let again = parsed.to_event_content().unwrap();
assert!(again.contains(r#""damus_donation_v2":21"#));
}
#[test]
fn partial_birthday_round_trip() {
let meta = Metadata::new()
.with_name("mallory")
.with_birthday(Birthday::month_day(4, 1));
let json = meta.to_event_content().unwrap();
assert!(json.contains(r#""birthday":{"month":4,"day":1}"#));
assert!(
!json.contains("\"year\""),
"omitted year must not appear in the payload: {json}"
);
assert_eq!(Metadata::from_event_content(&json).unwrap(), meta);
}
#[test]
fn deprecated_fields_are_accessible_via_legacy_getters() {
let json = r#"{"displayName":"Dave","username":"davey","name":"dave","display_name":"Dave (new)"}"#;
let meta = Metadata::from_event_content(json).unwrap();
assert_eq!(meta.name.as_deref(), Some("dave"));
assert_eq!(meta.display_name.as_deref(), Some("Dave (new)"));
assert_eq!(meta.legacy_username(), Some("davey"));
assert_eq!(meta.legacy_display_name(), Some("Dave"));
let again = meta.to_event_content().unwrap();
assert!(again.contains(r#""displayName":"Dave""#));
assert!(again.contains(r#""username":"davey""#));
}
}
}