Skip to main content

vector_core/profile/
mod.rs

1//! Profile types and sync — compact internal representation + relay fetching.
2//!
3//! The `id` field is a u16 interner handle — the canonical npub string lives
4//! in `NpubInterner` (single source of truth). Use `SlimProfile` at
5//! serialization boundaries (frontend, DB).
6//!
7//! The `sync` submodule has the priority queue, background processor,
8//! and `load_profile` relay fetch logic.
9
10pub mod sync;
11
12pub use sync::{SyncPriority, ProfileSyncHandler, NoOpProfileSyncHandler};
13
14use nostr_sdk::prelude::Metadata;
15
16use crate::compact::NO_NPUB;
17
18// ============================================================================
19// ProfileFlags — 3 bools packed into 1 byte
20// ============================================================================
21
22#[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// ============================================================================
40// Profile — compact internal representation
41// ============================================================================
42
43/// Internal profile with u16 interner handle. All string fields use `Box<str>`
44/// (16B) instead of `String` (24B) — profile strings are write-once from metadata.
45#[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    /// Rarely-set fields (lightning, nip05, website, local nickname, live status),
58    /// boxed out of the hot struct: most Vector profiles set none of these, so the
59    /// common case pays one null pointer instead of eight inline `Box<str>` headers
60    /// (the struct shrinks 248B -> 128B, and empty seen-but-unfetched placeholders
61    /// with it since empty `Box<str>` fields carry no heap).
62    pub extras: Option<Box<ProfileExtras>>,
63}
64
65/// The cold tail of `Profile`. Present only when at least one field is set.
66#[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    /// NIP-30 custom-emoji tags from the status event, so `:shortcode:` in the
77    /// status text can render as an image. Memory-only: refreshed on every
78    /// profile sync, not persisted (a pre-sync render shows the literal
79    /// shortcode, which is what an absent mapping shows anyway).
80    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    // Cold-field getters — the empty string when the extras box is absent.
107    #[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    /// Materialize the extras box for writing a cold field (allocates on first set).
118    #[inline]
119    pub fn extras_mut(&mut self) -> &mut ProfileExtras {
120        self.extras.get_or_insert_with(|| Box::new(ProfileExtras::default()))
121    }
122
123    /// Merge Nostr Metadata into this Profile. Returns `true` if any fields changed.
124    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// ============================================================================
178// SlimProfile — serialization boundary (frontend, DB)
179// ============================================================================
180
181/// Profile with npub string instead of interner handle. Used for:
182/// - Sending to frontend (JSON serializable)
183/// - Persisting to database
184/// - IPC between processes
185#[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    /// Convert from internal Profile, resolving interner handle to npub.
209    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    /// Convert to internal Profile (for loading from DB).
238    pub fn to_profile(&self) -> Profile {
239        // Only allocate the extras box when a cold field is actually set — the whole
240        // point of the split is that most profiles skip it.
241        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    /// NIP-30 tags from the status event; absent in DB rows (memory-only).
284    #[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        // Cold fields (lightning, nip05, website, nickname, live status) live behind
300        // Option<Box<ProfileExtras>>. The hot struct is 128B post-split (was 248B); this
301        // bound trips if someone re-inlines even one cold field (+16B for a Box<str>),
302        // which would bloat every cached profile incl. empty seen-but-unfetched placeholders.
303        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        // The whole win rests on this: a profile with no cold field set must keep
311        // extras == None (a null pointer), never an allocated all-empty box.
312        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}