1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
28pub enum SyncPriority {
29 Critical, High, Medium, Low, }
34
35impl SyncPriority {
36 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 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 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#[derive(Debug, Clone)]
72pub(crate) struct QueueEntry {
73 npub: String,
74 added_at: Instant,
75}
76
77pub 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 pub fn add(&mut self, npub: String, priority: SyncPriority, force_refresh: bool) {
111 if self.processing.contains(&npub) {
112 return;
113 }
114
115 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 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 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
196struct ProfileSyncQueueKey;
204
205fn profile_sync_queue() -> Arc<Mutex<ProfileSyncQueue>> {
206 crate::db::current_session().scoped::<ProfileSyncQueueKey, _>()
207}
208
209pub trait ProfileSyncHandler: Send + Sync {
220 fn on_profile_fetched(&self, _slim: &crate::SlimProfile, _avatar_url: &str, _banner_url: &str) {}
224}
225
226pub struct NoOpProfileSyncHandler;
228impl ProfileSyncHandler for NoOpProfileSyncHandler {}
229
230pub 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 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 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 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 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 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 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 let metadata_changed = profile.from_metadata(meta.unwrap());
365
366 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 emit_event("profile_update", &slim);
390 handler.on_profile_fetched(&slim, &avatar_url, &banner_url);
392 }
393 true
394 } else {
395 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
412pub 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
427pub 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 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 let profile = state.get_profile(&npub).cloned().unwrap_or_default();
461
462 let mut meta = Metadata::new().name(if name.is_empty() {
464 &*profile.name
465 } else {
466 name.as_str()
467 });
468
469 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 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 if !profile.display_name.is_empty() {
495 meta = meta.display_name(&*profile.display_name);
496 }
497
498 meta = meta.about(if about.is_empty() {
500 &*profile.about
501 } else {
502 about.as_str()
503 });
504
505 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 }; let meta = if is_bot { meta.custom_field("bot", true) } else { meta };
526
527 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 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 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
570pub const STATUS_MAX_CHARS: usize = 120;
580
581fn 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
590pub 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 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 let _ = crate::db::profiles::set_profile(&slim);
644 emit_event("profile_update", &slim);
645 true
646 }
647 Err(_) => false,
648 }
649}
650
651pub async fn block_user(npub: String, handler: &dyn ProfileSyncHandler) -> bool {
660 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 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
692pub 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
714pub 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
723pub 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
748pub 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 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 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 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 {
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
834pub 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
845pub 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
894pub 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
901pub 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#[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 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 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 queue.mark_done("npub1carol");
1005
1006 queue.add("npub1carol".to_string(), SyncPriority::Low, false);
1008 assert!(queue.low_queue.is_empty(), "should skip within cache window");
1009
1010 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 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 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 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}