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