1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
29pub enum SyncPriority {
30 Critical, High, Medium, Low, }
35
36impl SyncPriority {
37 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 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 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#[derive(Debug, Clone)]
73pub(crate) struct QueueEntry {
74 npub: String,
75 added_at: Instant,
76 gen: u64,
79}
80
81pub struct ProfileSyncQueue {
87 critical_queue: VecDeque<QueueEntry>,
88 high_queue: VecDeque<QueueEntry>,
89 medium_queue: VecDeque<QueueEntry>,
90 low_queue: VecDeque<QueueEntry>,
91 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 pub fn add(&mut self, npub: String, priority: SyncPriority, force_refresh: bool) {
121 if self.processing.contains(&npub) {
122 return;
123 }
124
125 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 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 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 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 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
228struct ProfileSyncQueueKey;
236
237fn profile_sync_queue() -> Arc<Mutex<ProfileSyncQueue>> {
238 crate::db::current_session().scoped::<ProfileSyncQueueKey, _>()
239}
240
241pub trait ProfileSyncHandler: Send + Sync {
252 fn on_profile_fetched(&self, _slim: &crate::SlimProfile, _avatar_url: &str, _banner_url: &str) {}
256}
257
258pub struct NoOpProfileSyncHandler;
260impl ProfileSyncHandler for NoOpProfileSyncHandler {}
261
262pub 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 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 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 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 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 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 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 let metadata_changed = profile.from_metadata(meta);
397
398 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 emit_event("profile_update", &slim);
422 handler.on_profile_fetched(&slim, &avatar_url, &banner_url);
424 }
425 true
426 } else {
427 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
444pub 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
459pub 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 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 let profile = state.get_profile(&npub).cloned().unwrap_or_default();
493
494 let mut meta = Metadata::new().name(if name.is_empty() {
496 &*profile.name
497 } else {
498 name.as_str()
499 });
500
501 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 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 if !profile.display_name.is_empty() {
527 meta = meta.display_name(&*profile.display_name);
528 }
529
530 meta = meta.about(if about.is_empty() {
532 &*profile.about
533 } else {
534 about.as_str()
535 });
536
537 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 }; let meta = if is_bot { meta.custom_field("bot", true) } else { meta };
558
559 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 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 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
602pub const STATUS_MAX_CHARS: usize = 120;
612
613fn 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
622pub 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 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 let _ = crate::db::profiles::set_profile(&slim);
676 emit_event("profile_update", &slim);
677 true
678 }
679 Err(_) => false,
680 }
681}
682
683pub async fn block_user(npub: String, handler: &dyn ProfileSyncHandler) -> bool {
692 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 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
724pub 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
746pub 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
755pub 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
780pub 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 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 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 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 {
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
866pub 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
877pub 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
926pub 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
933pub 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#[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 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 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 queue.mark_done("npub1carol");
1066
1067 queue.add("npub1carol".to_string(), SyncPriority::Low, false);
1069 assert!(queue.low_queue.is_empty(), "should skip within cache window");
1070
1071 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 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 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 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 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}