1pub mod sync;
11
12pub use sync::{SyncPriority, ProfileSyncHandler, NoOpProfileSyncHandler};
13
14use nostr_sdk::prelude::Metadata;
15
16use crate::compact::NO_NPUB;
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub struct ProfileFlags(u8);
24
25impl ProfileFlags {
26 const MINE: u8 = 0b001;
27 const BLOCKED: u8 = 0b010;
28 const BOT: u8 = 0b100;
29
30 #[inline] pub fn is_mine(self) -> bool { self.0 & Self::MINE != 0 }
31 #[inline] pub fn is_blocked(self) -> bool { self.0 & Self::BLOCKED != 0 }
32 #[inline] pub fn is_bot(self) -> bool { self.0 & Self::BOT != 0 }
33
34 #[inline] pub fn set_mine(&mut self, v: bool) { if v { self.0 |= Self::MINE } else { self.0 &= !Self::MINE } }
35 #[inline] pub fn set_blocked(&mut self, v: bool) { if v { self.0 |= Self::BLOCKED } else { self.0 &= !Self::BLOCKED } }
36 #[inline] pub fn set_bot(&mut self, v: bool) { if v { self.0 |= Self::BOT } else { self.0 &= !Self::BOT } }
37}
38
39#[derive(Clone, Debug, PartialEq)]
46pub struct Profile {
47 pub id: u16,
48 pub name: Box<str>,
49 pub display_name: Box<str>,
50 pub banner: Box<str>,
51 pub avatar: Box<str>,
52 pub about: Box<str>,
53 pub last_updated: u32,
54 pub flags: ProfileFlags,
55 pub avatar_cached: Box<str>,
56 pub banner_cached: Box<str>,
57 pub extras: Option<Box<ProfileExtras>>,
63}
64
65#[derive(Default, Clone, Debug, PartialEq)]
67pub struct ProfileExtras {
68 pub nickname: Box<str>,
69 pub lud06: Box<str>,
70 pub lud16: Box<str>,
71 pub nip05: Box<str>,
72 pub website: Box<str>,
73 pub status_title: Box<str>,
74 pub status_purpose: Box<str>,
75 pub status_url: Box<str>,
76 pub status_emoji_tags: Box<[crate::types::EmojiTag]>,
81}
82
83impl Default for Profile {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89impl Profile {
90 pub fn new() -> Self {
91 Self {
92 id: NO_NPUB,
93 name: Box::<str>::default(),
94 display_name: Box::<str>::default(),
95 banner: Box::<str>::default(),
96 avatar: Box::<str>::default(),
97 about: Box::<str>::default(),
98 last_updated: 0,
99 flags: ProfileFlags::default(),
100 avatar_cached: Box::<str>::default(),
101 banner_cached: Box::<str>::default(),
102 extras: None,
103 }
104 }
105
106 #[inline] pub fn nickname(&self) -> &str { self.extras.as_ref().map_or("", |e| &e.nickname) }
108 #[inline] pub fn lud06(&self) -> &str { self.extras.as_ref().map_or("", |e| &e.lud06) }
109 #[inline] pub fn lud16(&self) -> &str { self.extras.as_ref().map_or("", |e| &e.lud16) }
110 #[inline] pub fn nip05(&self) -> &str { self.extras.as_ref().map_or("", |e| &e.nip05) }
111 #[inline] pub fn website(&self) -> &str { self.extras.as_ref().map_or("", |e| &e.website) }
112 #[inline] pub fn status_title(&self) -> &str { self.extras.as_ref().map_or("", |e| &e.status_title) }
113 #[inline] pub fn status_purpose(&self) -> &str { self.extras.as_ref().map_or("", |e| &e.status_purpose) }
114 #[inline] pub fn status_url(&self) -> &str { self.extras.as_ref().map_or("", |e| &e.status_url) }
115 #[inline] pub fn status_emoji_tags(&self) -> &[crate::types::EmojiTag] { self.extras.as_ref().map_or(&[], |e| &e.status_emoji_tags) }
116
117 #[inline]
119 pub fn extras_mut(&mut self) -> &mut ProfileExtras {
120 self.extras.get_or_insert_with(|| Box::new(ProfileExtras::default()))
121 }
122
123 pub fn from_metadata(&mut self, meta: Metadata) -> bool {
125 let mut changed = false;
126
127 if let Some(name) = meta.name {
128 if *self.name != *name { self.name = name.into_boxed_str(); changed = true; }
129 }
130 if let Some(name) = meta.display_name {
131 if *self.display_name != *name { self.display_name = name.into_boxed_str(); changed = true; }
132 }
133 if let Some(lud06) = meta.lud06 {
134 if self.lud06() != lud06 { self.extras_mut().lud06 = lud06.into_boxed_str(); changed = true; }
135 }
136 if let Some(lud16) = meta.lud16 {
137 if self.lud16() != lud16 { self.extras_mut().lud16 = lud16.into_boxed_str(); changed = true; }
138 }
139 if let Some(banner) = meta.banner {
140 if *self.banner != *banner {
141 self.banner = banner.into_boxed_str();
142 self.banner_cached = Box::<str>::default();
143 changed = true;
144 }
145 }
146 if let Some(picture) = meta.picture {
147 if *self.avatar != *picture {
148 self.avatar = picture.into_boxed_str();
149 self.avatar_cached = Box::<str>::default();
150 changed = true;
151 }
152 }
153 if let Some(about) = meta.about {
154 if *self.about != *about { self.about = about.into_boxed_str(); changed = true; }
155 }
156 if let Some(website) = meta.website {
157 if self.website() != website { self.extras_mut().website = website.into_boxed_str(); changed = true; }
158 }
159 if let Some(nip05) = meta.nip05 {
160 if self.nip05() != nip05 { self.extras_mut().nip05 = nip05.into_boxed_str(); changed = true; }
161 }
162 if let Some(custom) = meta.custom.get("bot") {
163 let bot_value = match custom.as_bool() {
164 Some(b) => b,
165 None => custom.as_str().map(|s| s.to_lowercase() == "true").unwrap_or(false),
166 };
167 if self.flags.is_bot() != bot_value {
168 self.flags.set_bot(bot_value);
169 changed = true;
170 }
171 }
172
173 changed
174 }
175}
176
177#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Default)]
186pub struct SlimProfile {
187 pub id: String,
188 pub name: String,
189 pub display_name: String,
190 pub nickname: String,
191 pub lud06: String,
192 pub lud16: String,
193 pub banner: String,
194 pub avatar: String,
195 pub about: String,
196 pub website: String,
197 pub nip05: String,
198 pub status: Status,
199 pub last_updated: u64,
200 pub mine: bool,
201 pub bot: bool,
202 pub is_blocked: bool,
203 pub avatar_cached: String,
204 pub banner_cached: String,
205}
206
207impl SlimProfile {
208 pub fn from_profile(profile: &Profile, interner: &crate::compact::NpubInterner) -> Self {
210 Self {
211 id: interner.resolve(profile.id).unwrap_or("").to_string(),
212 name: profile.name.to_string(),
213 display_name: profile.display_name.to_string(),
214 nickname: profile.nickname().to_string(),
215 lud06: profile.lud06().to_string(),
216 lud16: profile.lud16().to_string(),
217 banner: profile.banner.to_string(),
218 avatar: profile.avatar.to_string(),
219 about: profile.about.to_string(),
220 website: profile.website().to_string(),
221 nip05: profile.nip05().to_string(),
222 status: Status {
223 title: profile.status_title().to_string(),
224 purpose: profile.status_purpose().to_string(),
225 url: profile.status_url().to_string(),
226 emoji_tags: profile.status_emoji_tags().to_vec(),
227 },
228 last_updated: crate::compact::secs_from_compact(profile.last_updated),
229 mine: profile.flags.is_mine(),
230 bot: profile.flags.is_bot(),
231 is_blocked: profile.flags.is_blocked(),
232 avatar_cached: profile.avatar_cached.to_string(),
233 banner_cached: profile.banner_cached.to_string(),
234 }
235 }
236
237 pub fn to_profile(&self) -> Profile {
239 let extras = (!self.nickname.is_empty() || !self.lud06.is_empty() || !self.lud16.is_empty()
242 || !self.nip05.is_empty() || !self.website.is_empty()
243 || !self.status.title.is_empty() || !self.status.purpose.is_empty() || !self.status.url.is_empty()
244 || !self.status.emoji_tags.is_empty())
245 .then(|| Box::new(ProfileExtras {
246 nickname: self.nickname.clone().into_boxed_str(),
247 lud06: self.lud06.clone().into_boxed_str(),
248 lud16: self.lud16.clone().into_boxed_str(),
249 nip05: self.nip05.clone().into_boxed_str(),
250 website: self.website.clone().into_boxed_str(),
251 status_title: self.status.title.clone().into_boxed_str(),
252 status_purpose: self.status.purpose.clone().into_boxed_str(),
253 status_url: self.status.url.clone().into_boxed_str(),
254 status_emoji_tags: self.status.emoji_tags.clone().into_boxed_slice(),
255 }));
256 Profile {
257 id: NO_NPUB,
258 name: self.name.clone().into_boxed_str(),
259 display_name: self.display_name.clone().into_boxed_str(),
260 banner: self.banner.clone().into_boxed_str(),
261 avatar: self.avatar.clone().into_boxed_str(),
262 about: self.about.clone().into_boxed_str(),
263 last_updated: crate::compact::secs_to_compact(self.last_updated),
264 flags: {
265 let mut f = ProfileFlags::default();
266 f.set_mine(self.mine);
267 f.set_bot(self.bot);
268 f.set_blocked(self.is_blocked);
269 f
270 },
271 avatar_cached: self.avatar_cached.clone().into_boxed_str(),
272 banner_cached: self.banner_cached.clone().into_boxed_str(),
273 extras,
274 }
275 }
276}
277
278#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)]
279pub struct Status {
280 pub title: String,
281 pub purpose: String,
282 pub url: String,
283 #[serde(default)]
285 pub emoji_tags: Vec<crate::types::EmojiTag>,
286}
287
288impl Status {
289 pub fn new() -> Self {
290 Self { title: String::new(), purpose: String::new(), url: String::new(), emoji_tags: Vec::new() }
291 }
292}
293
294#[cfg(test)]
295mod size_tests {
296 use super::*;
297 #[test]
298 fn hot_profile_stays_compact() {
299 let sz = std::mem::size_of::<Profile>();
304 println!("size_of::<Profile>() = {sz}");
305 assert!(sz <= 136, "Profile grew to {sz}B — a cold field was likely re-inlined");
306 }
307
308 #[test]
309 fn hot_only_metadata_never_allocates_extras() {
310 let mut p = Profile::new();
313 p.from_metadata(Metadata::new().name("alice").about("hi").display_name("Alice"));
314 assert!(p.extras.is_none(), "hot-only metadata must not allocate the extras box");
315
316 let mut q = Profile::new();
317 q.from_metadata(Metadata::new().name("bob").nip05("bob@example.com"));
318 assert!(q.extras.is_some(), "a cold field present must allocate the box");
319 assert_eq!(q.nip05(), "bob@example.com");
320 assert_eq!(q.lud16(), "", "unset cold fields still read as empty");
321 }
322}
323
324impl Default for Status {
325 fn default() -> Self {
326 Self::new()
327 }
328}