Skip to main content

vector_core/profile/
sync.rs

1//! Profile sync — priority queue, background processor, and relay fetching.
2//!
3//! The sync queue batches profile fetches by priority (Critical → High → Medium → Low),
4//! with cache windows to avoid hammering relays. The background processor
5//! drains the queue and calls `load_profile` for each entry.
6//!
7//! Platform-specific work (DB persistence, image caching) is handled by the
8//! `ProfileSyncHandler` trait — src-tauri provides `TauriProfileSyncHandler`,
9//! CLI provides a no-op or logging implementation.
10
11use std::collections::{HashMap, HashSet, VecDeque};
12use std::sync::{Arc, Mutex};
13use std::time::Duration;
14use web_time::Instant;
15
16use nostr_sdk::prelude::*;
17
18use crate::compact::secs_to_compact;
19use crate::profile::Profile;
20use crate::state::{nostr_client, my_public_key, STATE};
21use crate::traits::emit_event;
22
23// ============================================================================
24// SyncPriority
25// ============================================================================
26
27/// Priority levels for profile syncing.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
29pub enum SyncPriority {
30    Critical,  // No metadata OR user clicked — fetch immediately
31    High,      // Active chats — fetch soon
32    Medium,    // Recent chats — fetch eventually
33    Low,       // Old chats with metadata — passive refresh
34}
35
36impl SyncPriority {
37    /// Cache window duration — how long before a profile can be re-fetched.
38    pub fn cache_window(&self) -> Duration {
39        match self {
40            SyncPriority::Critical => Duration::from_secs(0),
41            SyncPriority::High => Duration::from_secs(5 * 60),
42            SyncPriority::Medium => Duration::from_secs(30 * 60),
43            SyncPriority::Low => Duration::from_secs(24 * 60 * 60),
44        }
45    }
46
47    /// Processing delay — how long after queuing before fetching.
48    pub fn processing_delay(&self) -> Duration {
49        match self {
50            SyncPriority::Critical => Duration::from_secs(0),
51            SyncPriority::High => Duration::from_secs(5),
52            SyncPriority::Medium => Duration::from_secs(30),
53            SyncPriority::Low => Duration::from_secs(5 * 60),
54        }
55    }
56
57    /// Maximum batch size for this priority.
58    pub fn batch_size(&self) -> usize {
59        match self {
60            SyncPriority::Critical => 10,
61            SyncPriority::High => 20,
62            SyncPriority::Medium => 30,
63            SyncPriority::Low => 50,
64        }
65    }
66}
67
68// ============================================================================
69// QueueEntry
70// ============================================================================
71
72#[derive(Debug, Clone)]
73pub(crate) struct QueueEntry {
74    npub: String,
75    added_at: Instant,
76    /// Matches `ProfileSyncQueue::queued` while this is the npub's live entry; a re-add
77    /// supersedes it in place rather than sweeping the lanes for it.
78    gen: u64,
79}
80
81// ============================================================================
82// ProfileSyncQueue
83// ============================================================================
84
85/// Profile sync queue manager with four priority lanes.
86pub struct ProfileSyncQueue {
87    critical_queue: VecDeque<QueueEntry>,
88    high_queue: VecDeque<QueueEntry>,
89    medium_queue: VecDeque<QueueEntry>,
90    low_queue: VecDeque<QueueEntry>,
91    /// Each waiting npub's live entry generation. Superseded entries stay in their lane
92    /// and are dropped when they reach its front.
93    queued: HashMap<String, u64>,
94    next_gen: u64,
95    processing: HashSet<String>,
96    last_fetched: HashMap<String, Instant>,
97    is_processing: bool,
98}
99
100impl Default for ProfileSyncQueue {
101    fn default() -> Self { Self::new() }
102}
103
104impl ProfileSyncQueue {
105    pub fn new() -> Self {
106        Self {
107            critical_queue: VecDeque::new(),
108            high_queue: VecDeque::new(),
109            medium_queue: VecDeque::new(),
110            low_queue: VecDeque::new(),
111            queued: HashMap::new(),
112            next_gen: 0,
113            processing: HashSet::new(),
114            last_fetched: HashMap::new(),
115            is_processing: false,
116        }
117    }
118
119    /// Add a profile to the sync queue.
120    pub fn add(&mut self, npub: String, priority: SyncPriority, force_refresh: bool) {
121        if self.processing.contains(&npub) {
122            return;
123        }
124
125        // Check cache window (unless force_refresh)
126        if !force_refresh {
127            if let Some(last_fetch) = self.last_fetched.get(&npub) {
128                if last_fetch.elapsed() < priority.cache_window() {
129                    return;
130                }
131            }
132        }
133
134        self.next_gen += 1;
135        let gen = self.next_gen;
136        self.queued.insert(npub.clone(), gen);
137
138        let entry = QueueEntry { npub, added_at: Instant::now(), gen };
139        // Re-adds while the processor is stalled (offline) would otherwise grow the lanes
140        // without bound; one sweep once stale entries outnumber live ones keeps them linear.
141        let len = self.critical_queue.len() + self.high_queue.len() + self.medium_queue.len() + self.low_queue.len();
142        if len > 2 * self.queued.len() + 64 {
143            for lane in [&mut self.critical_queue, &mut self.high_queue, &mut self.medium_queue, &mut self.low_queue] {
144                lane.retain(|e| Self::is_live(&self.queued, e));
145            }
146        }
147        match priority {
148            SyncPriority::Critical => self.critical_queue.push_back(entry),
149            SyncPriority::High => self.high_queue.push_back(entry),
150            SyncPriority::Medium => self.medium_queue.push_back(entry),
151            SyncPriority::Low => self.low_queue.push_back(entry),
152        }
153    }
154
155    fn is_live(queued: &HashMap<String, u64>, e: &QueueEntry) -> bool {
156        queued.get(&e.npub) == Some(&e.gen)
157    }
158
159    /// Drop superseded entries from the front of every lane, so a non-empty lane has a
160    /// live entry first.
161    fn prune_fronts(&mut self) {
162        for lane in [&mut self.critical_queue, &mut self.high_queue, &mut self.medium_queue, &mut self.low_queue] {
163            while lane.front().is_some_and(|e| !Self::is_live(&self.queued, e)) {
164                lane.pop_front();
165            }
166        }
167    }
168
169    /// Drop every queued + in-flight entry. Used by `reset_session()` so a
170    /// post-reset processor doesn't keep fetching the prior account's contacts.
171    pub fn clear(&mut self) {
172        self.critical_queue.clear();
173        self.high_queue.clear();
174        self.medium_queue.clear();
175        self.low_queue.clear();
176        self.queued.clear();
177        self.processing.clear();
178        self.last_fetched.clear();
179    }
180
181    /// Get the next batch of profiles ready to process (highest priority first).
182    pub(crate) fn get_next_batch(&mut self) -> Vec<QueueEntry> {
183        let mut batch = Vec::new();
184        self.prune_fronts();
185
186        let (queue, priority) = if !self.critical_queue.is_empty() {
187            (&mut self.critical_queue, SyncPriority::Critical)
188        } else if !self.high_queue.is_empty() {
189            (&mut self.high_queue, SyncPriority::High)
190        } else if !self.medium_queue.is_empty() {
191            (&mut self.medium_queue, SyncPriority::Medium)
192        } else if !self.low_queue.is_empty() {
193            (&mut self.low_queue, SyncPriority::Low)
194        } else {
195            return batch;
196        };
197
198        let batch_size = priority.batch_size();
199        let processing_delay = priority.processing_delay();
200
201        while batch.len() < batch_size {
202            let Some(entry) = queue.front() else { break };
203            if !Self::is_live(&self.queued, entry) {
204                queue.pop_front();
205                continue;
206            }
207            if entry.added_at.elapsed() < processing_delay {
208                break;
209            }
210            let entry = queue.pop_front().unwrap();
211            self.queued.remove(&entry.npub);
212            batch.push(entry);
213        }
214
215        batch
216    }
217
218    pub fn mark_processing(&mut self, npub: &str) {
219        self.processing.insert(npub.to_string());
220    }
221
222    pub fn mark_done(&mut self, npub: &str) {
223        self.processing.remove(npub);
224        self.last_fetched.insert(npub.to_string(), Instant::now());
225    }
226}
227
228// ============================================================================
229// Global queue
230// ============================================================================
231
232/// Queued work for THIS account's contacts. The processor loop is
233/// process-lifetime and services whichever queue the live session holds, so a
234/// swap leaves the prior account's entries behind rather than fetching them.
235struct ProfileSyncQueueKey;
236
237fn profile_sync_queue() -> Arc<Mutex<ProfileSyncQueue>> {
238    crate::db::current_session().scoped::<ProfileSyncQueueKey, _>()
239}
240
241// ============================================================================
242// ProfileSyncHandler — platform-specific callbacks
243// ============================================================================
244
245/// Callback trait for platform-specific profile sync work.
246///
247/// The core `load_profile` handles relay fetching, STATE updates, and
248/// EventEmitter notifications. This trait covers what differs per platform:
249/// - **DB persistence** (SQLite upsert)
250/// - **Image caching** (avatar/banner download + disk cache)
251pub trait ProfileSyncHandler: Send + Sync {
252    /// Called after a profile is fetched from relays and updated in STATE.
253    /// `slim` is ready for DB persistence. `avatar_url`/`banner_url` are
254    /// for image caching (may be empty).
255    fn on_profile_fetched(&self, _slim: &crate::SlimProfile, _avatar_url: &str, _banner_url: &str) {}
256}
257
258/// No-op handler for CLI/tests.
259pub struct NoOpProfileSyncHandler;
260impl ProfileSyncHandler for NoOpProfileSyncHandler {}
261
262// ============================================================================
263// load_profile — core relay fetch + STATE update
264// ============================================================================
265
266/// Fetch a profile's metadata and status from relays, update STATE, and
267/// notify via EventEmitter + handler callback.
268///
269/// Returns `true` if the fetch succeeded (even if nothing changed).
270pub async fn load_profile(npub: String, handler: &dyn ProfileSyncHandler) -> bool {
271    let client = match nostr_client() {
272        Some(c) => c,
273        None => return false,
274    };
275
276    // Session captured for the whole load_profile lifecycle. Relay
277    // fetches can sleep multi-second; we re-check before writing back
278    // to STATE / DB so a mid-fetch swap doesn't land account A's
279    // profile in account B's storage.
280
281    let profile_pubkey = match PublicKey::from_bech32(npub.as_str()) {
282        Ok(pk) => pk,
283        Err(_) => return false,
284    };
285
286    let my_public_key = match my_public_key() {
287        Some(pk) => pk,
288        None => return false,
289    };
290
291    // Grab old status (or create profile if missing)
292    let (old_status_title, old_status_purpose, old_status_url): (String, String, String);
293    let old_status_emoji_tags: Vec<crate::types::EmojiTag>;
294    {
295        let mut state = STATE.lock().await;
296        match state.get_profile(&npub) {
297            Some(p) => {
298                old_status_title = p.status_title().to_string();
299                old_status_purpose = p.status_purpose().to_string();
300                old_status_url = p.status_url().to_string();
301                old_status_emoji_tags = p.status_emoji_tags().to_vec();
302            }
303            None => {
304                state.insert_or_replace_profile(&npub, Profile::new());
305                old_status_title = String::new();
306                old_status_purpose = String::new();
307                old_status_url = String::new();
308                old_status_emoji_tags = Vec::new();
309            }
310        }
311    }
312
313    // Fetch status (kind 30315) from relays
314    let status_filter = Filter::new()
315        .author(profile_pubkey)
316        .kind(Kind::from_u16(30315))
317        .limit(1);
318
319    let (status_title, status_purpose, status_url, status_emoji_tags) = match client
320        .fetch_events(status_filter).timeout(Duration::from_secs(15))
321        .await
322    {
323        Ok(res) => {
324            if !res.is_empty() {
325                let status_event = res.first().unwrap();
326                (
327                    clamp_status(status_event.content.clone()),
328                    status_event.tags.first()
329                        .and_then(|t| t.content())
330                        .unwrap_or_default()
331                        .to_string(),
332                    String::new(),
333                    crate::types::EmojiTag::extract_from_tags(status_event.tags.iter()),
334                )
335            } else {
336                (old_status_title, old_status_purpose, old_status_url, old_status_emoji_tags)
337            }
338        }
339        Err(_) => (old_status_title, old_status_purpose, old_status_url, old_status_emoji_tags),
340    };
341
342    // Fetch metadata from relays
343    // `Client::fetch_metadata` is gone: fetch the newest kind-0 and parse it.
344    let fetch_result = client
345        .fetch_events(
346            Filter::new()
347                .author(profile_pubkey)
348                .kind(Kind::Metadata)
349                .limit(1),
350        )
351        .timeout(Duration::from_secs(15))
352        .await
353        .map(|events| {
354            events
355                .into_iter()
356                .max_by_key(|e| e.created_at)
357                .and_then(|e| Metadata::from_json(&e.content).ok())
358        });
359
360
361    match fetch_result {
362        Ok(meta) => {
363            if let Some(meta) = meta {
364                let save_data = {
365                    let mut state = STATE.lock().await;
366                    let id = match state.interner.lookup(&npub) {
367                        Some(id) => id,
368                        None => return false,
369                    };
370                    let (changed, avatar_url, banner_url) = {
371                        let profile = match state.get_profile_mut_by_id(id) {
372                            Some(p) => p,
373                            None => return false,
374                        };
375                        profile.flags.set_mine(my_public_key == profile_pubkey);
376
377                        // Update status
378                        let status_changed = profile.status_title() != status_title.as_str()
379                            || profile.status_purpose() != status_purpose.as_str()
380                            || profile.status_url() != status_url.as_str()
381                            || profile.status_emoji_tags() != status_emoji_tags.as_slice();
382                        // Only touch the extras box when there's a real status to store or one
383                        // already exists to clear — never materialize an empty box on the common
384                        // status-less profile (that would make it larger than before the split).
385                        let has_status = !status_title.is_empty()
386                            || !status_purpose.is_empty() || !status_url.is_empty();
387                        if profile.extras.is_some() || has_status {
388                            let ex = profile.extras_mut();
389                            ex.status_title = status_title.into_boxed_str();
390                            ex.status_purpose = status_purpose.into_boxed_str();
391                            ex.status_url = status_url.into_boxed_str();
392                            ex.status_emoji_tags = status_emoji_tags.into_boxed_slice();
393                        }
394
395                        // Update metadata
396                        let metadata_changed = profile.from_metadata(meta);
397
398                        // Update timestamp
399                        profile.last_updated = secs_to_compact(
400                            web_time::SystemTime::now()
401                                .duration_since(web_time::UNIX_EPOCH)
402                                .unwrap()
403                                .as_secs()
404                        );
405
406                        (status_changed || metadata_changed,
407                         profile.avatar.to_string(),
408                         profile.banner.to_string())
409                    };
410
411                    if changed {
412                        let slim = state.serialize_profile(id).unwrap();
413                        Some((slim, avatar_url, banner_url))
414                    } else {
415                        None
416                    }
417                };
418
419                if let Some((slim, avatar_url, banner_url)) = save_data {
420                    // Notify UI via EventEmitter
421                    emit_event("profile_update", &slim);
422                    // Platform-specific: DB persist + image caching
423                    handler.on_profile_fetched(&slim, &avatar_url, &banner_url);
424                }
425                true
426            } else {
427                // No metadata on relays — update timestamp so we don't keep retrying
428                let mut state = STATE.lock().await;
429                if let Some(profile) = state.get_profile_mut(&npub) {
430                    profile.last_updated = secs_to_compact(
431                        web_time::SystemTime::now()
432                            .duration_since(web_time::UNIX_EPOCH)
433                            .unwrap()
434                            .as_secs()
435                    );
436                }
437                true
438            }
439        }
440        Err(_) => false,
441    }
442}
443
444// ============================================================================
445// update_profile — publish metadata to relays
446// ============================================================================
447
448/// Update the current user's profile metadata and broadcast to relays.
449///
450/// Merges the provided fields with the existing profile (empty = keep existing).
451/// After successful broadcast, updates STATE and notifies via EventEmitter + handler.
452pub async fn update_profile(
453    name: String, avatar: String, banner: String, about: String,
454    handler: &dyn ProfileSyncHandler,
455) -> bool {
456    update_profile_inner(name, avatar, banner, about, false, handler).await
457}
458
459/// Publish the current user's profile and mark it as a bot (`bot: true` in the metadata). The SDK
460/// uses this so every bot it builds is tagged; human clients use [`update_profile`].
461pub async fn update_bot_profile(
462    name: String, avatar: String, banner: String, about: String,
463    handler: &dyn ProfileSyncHandler,
464) -> bool {
465    update_profile_inner(name, avatar, banner, about, true, handler).await
466}
467
468async fn update_profile_inner(
469    name: String, avatar: String, banner: String, about: String,
470    is_bot: bool,
471    handler: &dyn ProfileSyncHandler,
472) -> bool {
473    let client = match nostr_client() {
474        Some(c) => c,
475        None => return false,
476    };
477
478    let my_public_key = match my_public_key() {
479        Some(pk) => pk,
480        None => return false,
481    };
482
483    // Build metadata from current profile, then drop the lock before network I/O
484    let meta = {
485        let state = STATE.lock().await;
486        let npub = match my_public_key.to_bech32() {
487            Ok(n) => n,
488            Err(_) => return false,
489        };
490        // Start from the existing profile if we have one, else a blank profile so a first-time
491        // update (e.g. a freshly-created bot that has never published a kind-0) still works.
492        let profile = state.get_profile(&npub).cloned().unwrap_or_default();
493
494        // Merge: use new value if provided, else carry existing
495        let mut meta = Metadata::new().name(if name.is_empty() {
496            &*profile.name
497        } else {
498            name.as_str()
499        });
500
501        // Avatar
502        let avatar_url_str: &str = if avatar.is_empty() {
503            &profile.avatar
504        } else {
505            avatar.as_str()
506        };
507        if !avatar_url_str.is_empty() {
508            if let Ok(url) = Url::parse(avatar_url_str) {
509                meta = meta.picture(url);
510            }
511        }
512
513        // Banner
514        let banner_url_str: &str = if banner.is_empty() {
515            &profile.banner
516        } else {
517            banner.as_str()
518        };
519        if !banner_url_str.is_empty() {
520            if let Ok(url) = Url::parse(banner_url_str) {
521                meta = meta.banner(url);
522            }
523        }
524
525        // Carry forward display_name
526        if !profile.display_name.is_empty() {
527            meta = meta.display_name(&*profile.display_name);
528        }
529
530        // About
531        meta = meta.about(if about.is_empty() {
532            &*profile.about
533        } else {
534            about.as_str()
535        });
536
537        // Carry forward remaining fields
538        if !profile.website().is_empty() {
539            if let Ok(url) = Url::parse(profile.website()) {
540                meta = meta.website(url);
541            }
542        }
543        if !profile.nip05().is_empty() {
544            meta = meta.nip05(profile.nip05());
545        }
546        if !profile.lud06().is_empty() {
547            meta = meta.lud06(profile.lud06());
548        }
549        if !profile.lud16().is_empty() {
550            meta = meta.lud16(profile.lud16());
551        }
552
553        meta
554    }; // STATE lock dropped before network I/O
555
556    // SDK-built bots carry `bot: true` so clients can badge them; human clients never set it.
557    let meta = if is_bot { meta.custom_field("bot", true) } else { meta };
558
559    // Build and sign Kind 0 metadata event
560    let metadata_json = serde_json::to_string(&meta).unwrap();
561    let metadata_event = EventBuilder::new(Kind::Metadata, metadata_json)
562        .tag(Tag::custom("client", vec!["vector"]));
563
564    let Ok(event) = crate::sign_builder(metadata_event).await else {
565        return false;
566    };
567
568    // Broadcast — first-ACK so UI updates as soon as the fastest relay responds
569    match crate::inbox_relays::send_event_pool_first_ok(&client, &event).await {
570        Ok(_) => {
571            let npub = match my_public_key.to_bech32() {
572                Ok(n) => n,
573                Err(_) => return false,
574            };
575            let save_data = {
576                let mut state = STATE.lock().await;
577                // Apply the published metadata to our own profile, creating the entry if this
578                // identity had none yet (a freshly-created account is interned here on first set).
579                let mut profile = state.get_profile(&npub).cloned().unwrap_or_default();
580                profile.from_metadata(meta);
581                let (avatar_url, banner_url) = (profile.avatar.to_string(), profile.banner.to_string());
582                state.insert_or_replace_profile(&npub, profile);
583                let slim = match state.interner.lookup(&npub).and_then(|id| state.serialize_profile(id)) {
584                    Some(s) => s,
585                    None => return false,
586                };
587                (slim, avatar_url, banner_url)
588            };
589
590            let (slim, avatar_url, banner_url) = save_data;
591            emit_event("profile_update", &slim);
592            handler.on_profile_fetched(&slim, &avatar_url, &banner_url);
593            true
594        }
595        Err(e) => {
596            crate::log_warn!("[update_profile] relay broadcast failed: {e}");
597            false
598        }
599    }
600}
601
602// ============================================================================
603// update_status — publish status to relays
604// ============================================================================
605
606/// Update the current user's status (kind 30315) and broadcast to relays.
607///
608/// Status length cap in Unicode scalar characters, enforced on BOTH sides:
609/// our own publishes and every stored inbound status. Characters, not bytes —
610/// a byte cap would chop emoji-heavy statuses to a third of what text gets.
611pub const STATUS_MAX_CHARS: usize = 120;
612
613/// Truncate a status to [`STATUS_MAX_CHARS`] on a character boundary.
614fn clamp_status(s: String) -> String {
615    if s.chars().count() <= STATUS_MAX_CHARS {
616        s
617    } else {
618        s.chars().take(STATUS_MAX_CHARS).collect()
619    }
620}
621
622/// Status is ephemeral — updated in STATE + frontend but not persisted to DB.
623/// (Re-fetched from relays on next `load_profile` call.)
624pub async fn update_status(status: String) -> bool {
625    let status = clamp_status(status);
626    let client = match nostr_client() {
627        Some(c) => c,
628        None => return false,
629    };
630
631    let my_public_key = match my_public_key() {
632        Some(pk) => pk,
633        None => return false,
634    };
635
636    // Build and sign kind 30315 status event. `:shortcode:`s from the user's
637    // equipped packs ride along as NIP-30 tags so other clients render them.
638    let emoji_tags = crate::emoji_packs::resolve_outbound_emoji_tags(&status);
639    let mut status_builder = EventBuilder::new(Kind::from_u16(30315), status.as_str())
640        .tag(Tag::custom("d", vec!["general"]));
641    for et in &emoji_tags {
642        status_builder = status_builder.tag(Tag::custom("emoji", [et.shortcode.clone(), et.url.clone()]));
643    }
644
645    let Ok(event) = crate::sign_builder(status_builder).await else {
646        return false;
647    };
648
649    match crate::inbox_relays::send_event_pool_first_ok(&client, &event).await {
650        Ok(_) => {
651            let mut state = STATE.lock().await;
652            let npub = match my_public_key.to_bech32() {
653                Ok(n) => n,
654                Err(_) => return false,
655            };
656            let id = match state.interner.lookup(&npub) {
657                Some(id) => id,
658                None => return false,
659            };
660            {
661                let profile = match state.get_profile_mut_by_id(id) {
662                    Some(p) => p,
663                    None => return false,
664                };
665                let ex = profile.extras_mut();
666                ex.status_purpose = "general".into();
667                ex.status_title = status.into_boxed_str();
668                ex.status_emoji_tags = emoji_tags.into_boxed_slice();
669            }
670
671            let slim = state.serialize_profile(id).unwrap();
672            // Persist NOW: without this the new status (and its emoji tags)
673            // survives a reboot only if a self-profile sync happens to run
674            // before the app closes.
675            let _ = crate::db::profiles::set_profile(&slim);
676            emit_event("profile_update", &slim);
677            true
678        }
679        Err(_) => false,
680    }
681}
682
683// ============================================================================
684// block / unblock / nickname / blocked list
685// ============================================================================
686
687/// Block a user by npub. DM events from blocked users are dropped after decryption.
688/// Group messages are stored but filtered in the UI.
689///
690/// Returns `false` if trying to block yourself or if the profile can't be found.
691pub async fn block_user(npub: String, handler: &dyn ProfileSyncHandler) -> bool {
692    // Prevent blocking yourself
693    if let Some(my_pk) = my_public_key() {
694        if my_pk.to_bech32().ok().as_deref() == Some(npub.as_str()) {
695            return false;
696        }
697    }
698
699    let mut state = STATE.lock().await;
700
701    // Create profile if it doesn't exist (can block someone with no prior contact)
702    if state.interner.lookup(&npub).is_none() {
703        state.insert_or_replace_profile(&npub, Profile::new());
704    }
705
706    if let Some(id) = state.interner.lookup(&npub) {
707        {
708            let profile = match state.get_profile_mut_by_id(id) {
709                Some(p) => p,
710                None => return false,
711            };
712            profile.flags.set_blocked(true);
713        }
714        let slim = state.serialize_profile(id).unwrap();
715        drop(state);
716        emit_event("profile_update", &slim);
717        handler.on_profile_fetched(&slim, "", "");
718        true
719    } else {
720        false
721    }
722}
723
724/// Unblock a user by npub.
725pub async fn unblock_user(npub: String, handler: &dyn ProfileSyncHandler) -> bool {
726    let mut state = STATE.lock().await;
727
728    if let Some(id) = state.interner.lookup(&npub) {
729        {
730            let profile = match state.get_profile_mut_by_id(id) {
731                Some(p) => p,
732                None => return false,
733            };
734            profile.flags.set_blocked(false);
735        }
736        let slim = state.serialize_profile(id).unwrap();
737        drop(state);
738        emit_event("profile_update", &slim);
739        handler.on_profile_fetched(&slim, "", "");
740        true
741    } else {
742        false
743    }
744}
745
746/// Get all blocked profiles.
747pub async fn get_blocked_users() -> Vec<crate::SlimProfile> {
748    let state = STATE.lock().await;
749    state.profiles.iter()
750        .filter(|p| p.flags.is_blocked())
751        .filter_map(|p| state.serialize_profile(p.id))
752        .collect()
753}
754
755/// Set a nickname for a profile.
756pub async fn set_nickname(npub: String, nickname: String, handler: &dyn ProfileSyncHandler) -> bool {
757    let mut state = STATE.lock().await;
758
759    if let Some(id) = state.interner.lookup(&npub) {
760        {
761            let profile = match state.get_profile_mut_by_id(id) {
762                Some(p) => p,
763                None => return false,
764            };
765            profile.extras_mut().nickname = nickname.into_boxed_str();
766        }
767        let slim = state.serialize_profile(id).unwrap();
768        drop(state);
769        emit_event("profile_nick_changed", &serde_json::json!({
770            "profile_id": &npub,
771            "value": &slim.nickname
772        }));
773        handler.on_profile_fetched(&slim, "", "");
774        true
775    } else {
776        false
777    }
778}
779
780// ============================================================================
781// Background processor
782// ============================================================================
783
784/// Background processor that continuously drains the profile sync queue.
785///
786/// Spawned once at startup. Processes batches in priority order, calling
787/// `load_profile` for each entry with the provided handler.
788pub async fn start_profile_sync_processor(handler: Arc<dyn ProfileSyncHandler>) {
789    let mut last_own_profile_sync = Instant::now();
790    let own_profile_sync_interval = Duration::from_secs(5 * 60);
791
792    loop {
793        // Periodically queue our own profile to detect changes from other Nostr apps
794        if last_own_profile_sync.elapsed() >= own_profile_sync_interval {
795            let state = STATE.lock().await;
796            if let Some(own_profile) = state.profiles.iter().find(|p| p.flags.is_mine()) {
797                let npub = state.interner.resolve(own_profile.id).unwrap_or("").to_string();
798                drop(state);
799
800                let owner = profile_sync_queue();
801                let mut queue = owner.lock().unwrap();
802                queue.add(npub, SyncPriority::Low, false);
803            }
804            last_own_profile_sync = Instant::now();
805        }
806
807        // Get next batch (lock scoped)
808        let (should_wait, batch) = {
809            let owner = profile_sync_queue();
810            let mut queue = owner.lock().unwrap();
811
812            if queue.is_processing {
813                (true, vec![])
814            } else {
815                queue.is_processing = true;
816                let batch = queue.get_next_batch();
817                for entry in &batch {
818                    queue.mark_processing(&entry.npub);
819                }
820                (false, batch)
821            }
822        };
823
824        if should_wait {
825            crate::rt::time::sleep(Duration::from_secs(1)).await;
826            continue;
827        }
828
829        if batch.is_empty() {
830            {
831                let owner = profile_sync_queue();
832                let mut queue = owner.lock().unwrap();
833                queue.is_processing = false;
834            }
835            crate::rt::time::sleep(Duration::from_secs(1)).await;
836            continue;
837        }
838
839        // Session captured per-batch so a swap aborts the loop before
840        // account A's queue work lands in account B's DB. The next
841        // outer-loop iteration picks up the new session's queue cleanly.
842
843        for entry in &batch {
844            load_profile(entry.npub.clone(), handler.as_ref()).await;
845
846            {
847                let owner = profile_sync_queue();
848                let mut queue = owner.lock().unwrap();
849                queue.mark_done(&entry.npub);
850            }
851
852            crate::rt::time::sleep(Duration::from_millis(100)).await;
853        }
854
855        // Release processing lock
856        {
857            let owner = profile_sync_queue();
858            let mut queue = owner.lock().unwrap();
859            queue.is_processing = false;
860        }
861
862        crate::rt::time::sleep(Duration::from_millis(500)).await;
863    }
864}
865
866// ============================================================================
867// Public API
868// ============================================================================
869
870/// Queue a single profile for syncing.
871pub fn queue_profile_sync(npub: String, priority: SyncPriority, force_refresh: bool) {
872    let owner = profile_sync_queue();
873    let mut queue = owner.lock().unwrap();
874    queue.add(npub, priority, force_refresh);
875}
876
877/// Queue all profiles for a chat.
878pub async fn queue_chat_profiles(chat_id: String, is_opening: bool) {
879    let state = STATE.lock().await;
880
881    let chat = match state.get_chat(&chat_id) {
882        Some(c) => c,
883        None => return,
884    };
885
886    let base_priority = if is_opening {
887        SyncPriority::High
888    } else {
889        SyncPriority::Medium
890    };
891
892    let mut profiles_to_queue = Vec::new();
893
894    for &handle in chat.participants() {
895        let member_npub = match state.interner.resolve(handle) {
896            Some(s) => s.to_string(),
897            None => continue,
898        };
899
900        let has_metadata = state.get_profile_by_id(handle)
901            .map(|p| {
902                let has_data = !p.name.is_empty() || !p.display_name.is_empty() || !p.avatar.is_empty();
903                let was_fetched = p.last_updated > 0;
904                has_data || was_fetched
905            })
906            .unwrap_or(false);
907
908        let priority = if !has_metadata {
909            SyncPriority::Critical
910        } else {
911            base_priority
912        };
913
914        profiles_to_queue.push((member_npub, priority));
915    }
916
917    drop(state);
918
919    let owner = profile_sync_queue();
920    let mut queue = owner.lock().unwrap();
921    for (npub, priority) in profiles_to_queue {
922        queue.add(npub, priority, false);
923    }
924}
925
926/// Force immediate refresh of a profile (for user clicks).
927pub fn refresh_profile_now(npub: String) {
928    let owner = profile_sync_queue();
929    let mut queue = owner.lock().unwrap();
930    queue.add(npub, SyncPriority::Critical, true);
931}
932
933/// Sync all profiles in the system.
934pub async fn sync_all_profiles() {
935    let state = STATE.lock().await;
936
937    let mut profiles_to_queue = Vec::new();
938
939    for profile in &state.profiles {
940        let npub = match state.interner.resolve(profile.id) {
941            Some(s) => s.to_string(),
942            None => continue,
943        };
944
945        let has_metadata = !profile.name.is_empty() || !profile.display_name.is_empty() || !profile.avatar.is_empty();
946        let was_fetched = profile.last_updated > 0;
947
948        let priority = if !has_metadata && !was_fetched {
949            SyncPriority::Critical
950        } else {
951            SyncPriority::Low
952        };
953
954        profiles_to_queue.push((npub, priority));
955    }
956
957    drop(state);
958
959    let owner = profile_sync_queue();
960    let mut queue = owner.lock().unwrap();
961    for (npub, priority) in profiles_to_queue {
962        queue.add(npub, priority, false);
963    }
964}
965
966// ============================================================================
967// Tests
968// ============================================================================
969
970#[cfg(test)]
971mod status_clamp_tests {
972    use super::*;
973
974    #[test]
975    fn a_status_clamps_at_120_characters_not_bytes() {
976        assert_eq!(clamp_status("hi".to_string()), "hi");
977        let exact: String = "a".repeat(STATUS_MAX_CHARS);
978        assert_eq!(clamp_status(exact.clone()), exact, "at the cap is untouched");
979        let long = "b".repeat(10_000);
980        assert_eq!(clamp_status(long).chars().count(), STATUS_MAX_CHARS);
981        // Characters, not bytes: 120 four-byte emoji survive whole.
982        let emoji: String = "\u{1F980}".repeat(STATUS_MAX_CHARS);
983        let clamped = clamp_status(format!("{emoji}overflow"));
984        assert_eq!(clamped.chars().count(), STATUS_MAX_CHARS);
985        assert_eq!(clamped, emoji, "truncation lands on a character boundary");
986    }
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992
993    #[test]
994    fn sync_priority_cache_windows() {
995        assert_eq!(SyncPriority::Critical.cache_window(), Duration::from_secs(0));
996        assert_eq!(SyncPriority::High.cache_window(), Duration::from_secs(300));
997        assert_eq!(SyncPriority::Medium.cache_window(), Duration::from_secs(1800));
998        assert_eq!(SyncPriority::Low.cache_window(), Duration::from_secs(86400));
999    }
1000
1001    #[test]
1002    fn sync_priority_batch_sizes() {
1003        assert_eq!(SyncPriority::Critical.batch_size(), 10);
1004        assert_eq!(SyncPriority::High.batch_size(), 20);
1005        assert_eq!(SyncPriority::Medium.batch_size(), 30);
1006        assert_eq!(SyncPriority::Low.batch_size(), 50);
1007    }
1008
1009    impl ProfileSyncQueue {
1010        fn push_raw(&mut self, priority: SyncPriority, npub: &str, added_at: Instant) {
1011            self.next_gen += 1;
1012            self.queued.insert(npub.to_string(), self.next_gen);
1013            let entry = QueueEntry { npub: npub.to_string(), added_at, gen: self.next_gen };
1014            match priority {
1015                SyncPriority::Critical => self.critical_queue.push_back(entry),
1016                SyncPriority::High => self.high_queue.push_back(entry),
1017                SyncPriority::Medium => self.medium_queue.push_back(entry),
1018                SyncPriority::Low => self.low_queue.push_back(entry),
1019            }
1020        }
1021
1022        fn live(&self, lane: &VecDeque<QueueEntry>) -> Vec<String> {
1023            lane.iter().filter(|e| Self::is_live(&self.queued, e)).map(|e| e.npub.clone()).collect()
1024        }
1025    }
1026
1027    #[test]
1028    fn re_adds_while_stalled_stay_bounded() {
1029        let mut queue = ProfileSyncQueue::new();
1030        for _ in 0..100 {
1031            for i in 0..50 {
1032                queue.add(format!("npub1{i}"), SyncPriority::Low, true);
1033            }
1034        }
1035        assert_eq!(queue.live(&queue.low_queue).len(), 50);
1036        assert!(queue.low_queue.len() <= 2 * 50 + 64 + 1, "{} entries for 50 profiles", queue.low_queue.len());
1037    }
1038
1039    #[test]
1040    fn queue_add_and_dedup() {
1041        let mut queue = ProfileSyncQueue::new();
1042
1043        queue.add("npub1alice".to_string(), SyncPriority::Low, false);
1044        queue.add("npub1alice".to_string(), SyncPriority::High, false);
1045
1046        // Should be in High queue only (deduped from Low)
1047        assert!(queue.live(&queue.low_queue).is_empty());
1048        assert_eq!(queue.live(&queue.high_queue), ["npub1alice"]);
1049    }
1050
1051    #[test]
1052    fn queue_skips_processing() {
1053        let mut queue = ProfileSyncQueue::new();
1054        queue.mark_processing("npub1bob");
1055
1056        queue.add("npub1bob".to_string(), SyncPriority::Critical, false);
1057        assert!(queue.critical_queue.is_empty(), "should skip profiles being processed");
1058    }
1059
1060    #[test]
1061    fn queue_cache_window_skips() {
1062        let mut queue = ProfileSyncQueue::new();
1063
1064        // Mark as recently fetched
1065        queue.mark_done("npub1carol");
1066
1067        // Try to add with Low priority (24h cache window) — should skip
1068        queue.add("npub1carol".to_string(), SyncPriority::Low, false);
1069        assert!(queue.low_queue.is_empty(), "should skip within cache window");
1070
1071        // Force refresh should bypass cache
1072        queue.add("npub1carol".to_string(), SyncPriority::Low, true);
1073        assert_eq!(queue.low_queue.len(), 1, "force_refresh should bypass cache");
1074    }
1075
1076    #[test]
1077    fn queue_critical_skips_cache() {
1078        let mut queue = ProfileSyncQueue::new();
1079
1080        // Critical has 0s cache window — always fetches
1081        queue.mark_done("npub1dave");
1082        queue.add("npub1dave".to_string(), SyncPriority::Critical, false);
1083        assert_eq!(queue.critical_queue.len(), 1, "Critical should always fetch");
1084    }
1085
1086    #[test]
1087    fn re_adding_moves_an_entry_between_lanes() {
1088        let mut queue = ProfileSyncQueue::new();
1089        queue.add("npub1a".into(), SyncPriority::Low, true);
1090        queue.add("npub1b".into(), SyncPriority::Low, true);
1091        queue.add("npub1a".into(), SyncPriority::Critical, true);
1092        assert_eq!(queue.live(&queue.low_queue), ["npub1b"]);
1093        assert_eq!(queue.live(&queue.critical_queue), ["npub1a"]);
1094        let batch = queue.get_next_batch();
1095        assert_eq!(batch.len(), 1);
1096        assert_eq!(batch[0].npub, "npub1a");
1097        // Its superseded Low entry never surfaces, even once the Low lane's delay passes.
1098        queue.critical_queue.clear();
1099        queue.add("npub1c".into(), SyncPriority::Low, true);
1100        assert_eq!(queue.live(&queue.low_queue), ["npub1b", "npub1c"]);
1101    }
1102
1103    #[test]
1104    fn get_next_batch_priority_order() {
1105        let mut queue = ProfileSyncQueue::new();
1106
1107        // Add to Low and Critical queues
1108        queue.push_raw(SyncPriority::Low, "npub1low", Instant::now() - Duration::from_secs(600));
1109        queue.push_raw(SyncPriority::Critical, "npub1critical", Instant::now());
1110
1111        let batch = queue.get_next_batch();
1112        assert_eq!(batch.len(), 1);
1113        assert_eq!(batch[0].npub, "npub1critical", "Critical should process before Low");
1114    }
1115
1116    #[test]
1117    fn get_next_batch_respects_delay() {
1118        let mut queue = ProfileSyncQueue::new();
1119
1120        // Add a High priority entry just now (5s delay required)
1121        queue.push_raw(SyncPriority::High, "npub1new", Instant::now());
1122
1123        let batch = queue.get_next_batch();
1124        assert!(batch.is_empty(), "should not process before delay elapses");
1125    }
1126
1127    #[test]
1128    fn mark_done_updates_last_fetched() {
1129        let mut queue = ProfileSyncQueue::new();
1130        queue.mark_processing("npub1eve");
1131        assert!(queue.processing.contains("npub1eve"));
1132
1133        queue.mark_done("npub1eve");
1134        assert!(!queue.processing.contains("npub1eve"));
1135        assert!(queue.last_fetched.contains_key("npub1eve"));
1136    }
1137
1138    #[test]
1139    fn noop_handler_compiles() {
1140        let handler = NoOpProfileSyncHandler;
1141        let slim = crate::SlimProfile::default();
1142        handler.on_profile_fetched(&slim, "", "");
1143    }
1144}