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 nickname: Box<str>,
51    pub lud06: Box<str>,
52    pub lud16: Box<str>,
53    pub banner: Box<str>,
54    pub avatar: Box<str>,
55    pub about: Box<str>,
56    pub website: Box<str>,
57    pub nip05: Box<str>,
58    pub status_title: Box<str>,
59    pub status_purpose: Box<str>,
60    pub status_url: Box<str>,
61    pub last_updated: u32,
62    pub flags: ProfileFlags,
63    pub avatar_cached: Box<str>,
64    pub banner_cached: Box<str>,
65}
66
67impl Default for Profile {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Profile {
74    pub fn new() -> Self {
75        Self {
76            id: NO_NPUB,
77            name: Box::<str>::default(),
78            display_name: Box::<str>::default(),
79            nickname: Box::<str>::default(),
80            lud06: Box::<str>::default(),
81            lud16: Box::<str>::default(),
82            banner: Box::<str>::default(),
83            avatar: Box::<str>::default(),
84            about: Box::<str>::default(),
85            website: Box::<str>::default(),
86            nip05: Box::<str>::default(),
87            status_title: Box::<str>::default(),
88            status_purpose: Box::<str>::default(),
89            status_url: Box::<str>::default(),
90            last_updated: 0,
91            flags: ProfileFlags::default(),
92            avatar_cached: Box::<str>::default(),
93            banner_cached: Box::<str>::default(),
94        }
95    }
96
97    /// Merge Nostr Metadata into this Profile. Returns `true` if any fields changed.
98    pub fn from_metadata(&mut self, meta: Metadata) -> bool {
99        let mut changed = false;
100
101        if let Some(name) = meta.name {
102            if *self.name != *name { self.name = name.into_boxed_str(); changed = true; }
103        }
104        if let Some(name) = meta.display_name {
105            if *self.display_name != *name { self.display_name = name.into_boxed_str(); changed = true; }
106        }
107        if let Some(lud06) = meta.lud06 {
108            if *self.lud06 != *lud06 { self.lud06 = lud06.into_boxed_str(); changed = true; }
109        }
110        if let Some(lud16) = meta.lud16 {
111            if *self.lud16 != *lud16 { self.lud16 = lud16.into_boxed_str(); changed = true; }
112        }
113        if let Some(banner) = meta.banner {
114            if *self.banner != *banner {
115                self.banner = banner.into_boxed_str();
116                self.banner_cached = Box::<str>::default();
117                changed = true;
118            }
119        }
120        if let Some(picture) = meta.picture {
121            if *self.avatar != *picture {
122                self.avatar = picture.into_boxed_str();
123                self.avatar_cached = Box::<str>::default();
124                changed = true;
125            }
126        }
127        if let Some(about) = meta.about {
128            if *self.about != *about { self.about = about.into_boxed_str(); changed = true; }
129        }
130        if let Some(website) = meta.website {
131            if *self.website != *website { self.website = website.into_boxed_str(); changed = true; }
132        }
133        if let Some(nip05) = meta.nip05 {
134            if *self.nip05 != *nip05 { self.nip05 = nip05.into_boxed_str(); changed = true; }
135        }
136        if let Some(custom) = meta.custom.get("bot") {
137            let bot_value = match custom.as_bool() {
138                Some(b) => b,
139                None => custom.as_str().map(|s| s.to_lowercase() == "true").unwrap_or(false),
140            };
141            if self.flags.is_bot() != bot_value {
142                self.flags.set_bot(bot_value);
143                changed = true;
144            }
145        }
146
147        changed
148    }
149}
150
151// ============================================================================
152// SlimProfile — serialization boundary (frontend, DB)
153// ============================================================================
154
155/// Profile with npub string instead of interner handle. Used for:
156/// - Sending to frontend (JSON serializable)
157/// - Persisting to database
158/// - IPC between processes
159#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Default)]
160pub struct SlimProfile {
161    pub id: String,
162    pub name: String,
163    pub display_name: String,
164    pub nickname: String,
165    pub lud06: String,
166    pub lud16: String,
167    pub banner: String,
168    pub avatar: String,
169    pub about: String,
170    pub website: String,
171    pub nip05: String,
172    pub status: Status,
173    pub last_updated: u64,
174    pub mine: bool,
175    pub bot: bool,
176    pub is_blocked: bool,
177    pub avatar_cached: String,
178    pub banner_cached: String,
179}
180
181impl SlimProfile {
182    /// Convert from internal Profile, resolving interner handle to npub.
183    pub fn from_profile(profile: &Profile, interner: &crate::compact::NpubInterner) -> Self {
184        Self {
185            id: interner.resolve(profile.id).unwrap_or("").to_string(),
186            name: profile.name.to_string(),
187            display_name: profile.display_name.to_string(),
188            nickname: profile.nickname.to_string(),
189            lud06: profile.lud06.to_string(),
190            lud16: profile.lud16.to_string(),
191            banner: profile.banner.to_string(),
192            avatar: profile.avatar.to_string(),
193            about: profile.about.to_string(),
194            website: profile.website.to_string(),
195            nip05: profile.nip05.to_string(),
196            status: Status {
197                title: profile.status_title.to_string(),
198                purpose: profile.status_purpose.to_string(),
199                url: profile.status_url.to_string(),
200            },
201            last_updated: crate::compact::secs_from_compact(profile.last_updated),
202            mine: profile.flags.is_mine(),
203            bot: profile.flags.is_bot(),
204            is_blocked: profile.flags.is_blocked(),
205            avatar_cached: profile.avatar_cached.to_string(),
206            banner_cached: profile.banner_cached.to_string(),
207        }
208    }
209
210    /// Convert to internal Profile (for loading from DB).
211    pub fn to_profile(&self) -> Profile {
212        Profile {
213            id: NO_NPUB,
214            name: self.name.clone().into_boxed_str(),
215            display_name: self.display_name.clone().into_boxed_str(),
216            nickname: self.nickname.clone().into_boxed_str(),
217            lud06: self.lud06.clone().into_boxed_str(),
218            lud16: self.lud16.clone().into_boxed_str(),
219            banner: self.banner.clone().into_boxed_str(),
220            avatar: self.avatar.clone().into_boxed_str(),
221            about: self.about.clone().into_boxed_str(),
222            website: self.website.clone().into_boxed_str(),
223            nip05: self.nip05.clone().into_boxed_str(),
224            status_title: self.status.title.clone().into_boxed_str(),
225            status_purpose: self.status.purpose.clone().into_boxed_str(),
226            status_url: self.status.url.clone().into_boxed_str(),
227            last_updated: crate::compact::secs_to_compact(self.last_updated),
228            flags: {
229                let mut f = ProfileFlags::default();
230                f.set_mine(self.mine);
231                f.set_bot(self.bot);
232                f.set_blocked(self.is_blocked);
233                f
234            },
235            avatar_cached: self.avatar_cached.clone().into_boxed_str(),
236            banner_cached: self.banner_cached.clone().into_boxed_str(),
237        }
238    }
239}
240
241#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)]
242pub struct Status {
243    pub title: String,
244    pub purpose: String,
245    pub url: String,
246}
247
248impl Status {
249    pub fn new() -> Self {
250        Self { title: String::new(), purpose: String::new(), url: String::new() }
251    }
252}
253
254impl Default for Status {
255    fn default() -> Self {
256        Self::new()
257    }
258}