1use std::collections::HashMap;
14use std::sync::{Arc, RwLock};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18#[repr(u8)]
19pub enum NotifyLevel {
20 All = 0,
21 Mentions = 1,
22 Nothing = 2,
23}
24
25impl NotifyLevel {
26 pub fn as_u8(self) -> u8 {
27 self as u8
28 }
29
30 pub fn from_u8(v: u8) -> Option<Self> {
31 match v {
32 0 => Some(Self::All),
33 1 => Some(Self::Mentions),
34 2 => Some(Self::Nothing),
35 _ => None,
36 }
37 }
38
39 pub fn as_str(self) -> &'static str {
40 match self {
41 Self::All => "all",
42 Self::Mentions => "mentions",
43 Self::Nothing => "nothing",
44 }
45 }
46
47 pub fn from_label(s: &str) -> Option<Self> {
48 match s {
49 "all" => Some(Self::All),
50 "mentions" => Some(Self::Mentions),
51 "nothing" | "none" => Some(Self::Nothing),
52 _ => None,
53 }
54 }
55
56 pub fn quieter_of(self, other: Self) -> Self {
58 if self >= other { self } else { other }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum MessageClass {
67 Everyone,
68 Mention,
69 Normal,
70}
71
72pub fn passes(level: NotifyLevel, class: MessageClass) -> bool {
74 match level {
75 NotifyLevel::All => true,
76 NotifyLevel::Mentions => !matches!(class, MessageClass::Normal),
77 NotifyLevel::Nothing => false,
78 }
79}
80
81pub const MUTE_FOREVER: i64 = -1;
83pub const MUTE_OFF: i64 = 0;
85
86pub fn is_muted_at(mute_until: i64, now_ms: i64) -> bool {
87 mute_until == MUTE_FOREVER || mute_until > now_ms
88}
89
90pub fn now_ms() -> i64 {
91 web_time::SystemTime::now()
92 .duration_since(web_time::UNIX_EPOCH)
93 .map(|d| d.as_millis() as i64)
94 .unwrap_or(0)
95}
96
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
100pub struct ScopePrefs {
101 pub level: Option<NotifyLevel>,
102 pub mute_until: i64,
103 pub suppress_everyone: Option<bool>,
104}
105
106impl ScopePrefs {
107 pub fn is_default(&self) -> bool {
108 self.level.is_none() && self.mute_until == MUTE_OFF && self.suppress_everyone.is_none()
109 }
110 pub fn muted_at(&self, now_ms: i64) -> bool {
111 is_muted_at(self.mute_until, now_ms)
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct Resolved {
118 pub level: NotifyLevel,
120 pub muted: bool,
121 pub ring: NotifyLevel,
123}
124
125#[derive(Default)]
130struct Store {
131 loaded: bool,
132 scopes: HashMap<String, ScopePrefs>,
133 live_channels: Option<std::collections::HashSet<String>>,
137}
138
139struct NotifyStore;
140
141fn store() -> Arc<RwLock<Store>> {
142 crate::db::current_session().scoped::<NotifyStore, _>()
143}
144
145pub fn warm() {
152 let cell = store();
153 if cell.read().map(|g| g.loaded).unwrap_or(false) {
154 return;
155 }
156 let rows = match crate::db::notify::load_all() {
157 Ok(rows) => rows,
158 Err(_) => return,
159 };
160 if let Ok(mut g) = cell.write() {
161 g.scopes = rows;
162 g.loaded = true;
163 };
164}
165
166pub fn is_loaded() -> bool {
171 store().read().map(|g| g.loaded).unwrap_or(false)
172}
173
174pub fn invalidate() {
176 if let Ok(mut g) = store().write() {
177 g.loaded = false;
178 g.scopes.clear();
179 g.live_channels = None;
180 }
181}
182
183pub fn invalidate_live_channels() {
185 if let Ok(mut g) = store().write() {
186 g.live_channels = None;
187 }
188}
189
190pub fn channel_is_listed(channel_id: &str) -> bool {
193 let cell = store();
194 if let Ok(g) = cell.read() {
195 if let Some(set) = g.live_channels.as_ref() {
196 return set.contains(channel_id);
197 }
198 }
199 let ids = match crate::db::notify::live_channel_ids() {
201 Ok(ids) => ids,
202 Err(_) => return true,
203 };
204 let listed = ids.contains(channel_id);
205 if let Ok(mut g) = cell.write() {
206 g.live_channels = Some(ids);
207 }
208 listed
209}
210
211pub fn prefs(scope_id: &str) -> ScopePrefs {
212 store()
213 .read()
214 .ok()
215 .and_then(|g| g.scopes.get(scope_id).copied())
216 .unwrap_or_default()
217}
218
219pub fn all_prefs() -> HashMap<String, ScopePrefs> {
220 store().read().map(|g| g.scopes.clone()).unwrap_or_default()
221}
222
223pub fn set_prefs(scope_id: &str, next: ScopePrefs) -> Result<(), String> {
225 warm();
226 crate::db::notify::save(scope_id, &next)?;
227 if let Ok(mut g) = store().write() {
228 if next.is_default() {
229 g.scopes.remove(scope_id);
230 } else {
231 g.scopes.insert(scope_id.to_string(), next);
232 }
233 }
234 Ok(())
235}
236
237pub fn set_level(scope_id: &str, level: Option<NotifyLevel>) -> Result<(), String> {
238 let mut next = prefs(scope_id);
239 next.level = level;
240 set_prefs(scope_id, next)
241}
242
243pub fn set_mute(scope_id: &str, mute_until: i64) -> Result<(), String> {
244 let mut next = prefs(scope_id);
245 next.mute_until = mute_until;
246 set_prefs(scope_id, next)
247}
248
249pub fn set_suppress_everyone(scope_id: &str, suppress: Option<bool>) -> Result<(), String> {
250 let mut next = prefs(scope_id);
251 next.suppress_everyone = suppress;
252 set_prefs(scope_id, next)
253}
254
255pub fn everyone_allowed(community_id: Option<&str>) -> bool {
266 let globally_muted = crate::db::settings::get_sql_setting("notif_mute_everyone".to_string())
267 .ok()
268 .flatten()
269 .is_some_and(|v| v == "true");
270 if globally_muted {
271 return false;
272 }
273 !community_id.is_some_and(|cid| prefs(cid).suppress_everyone.unwrap_or(false))
274}
275
276pub fn resolve(scope_id: &str, community_id: Option<&str>, now_ms: i64) -> Resolved {
279 let own = prefs(scope_id);
280 let parent = community_id
281 .filter(|cid| *cid != scope_id)
282 .map(prefs)
283 .unwrap_or_default();
284 resolve_from(own, parent, now_ms)
285}
286
287pub fn resolve_from(own: ScopePrefs, parent: ScopePrefs, now_ms: i64) -> Resolved {
290 let level = own.level.or(parent.level).unwrap_or(NotifyLevel::All);
291 let muted = own.muted_at(now_ms) || parent.muted_at(now_ms);
292 let ring = level.quieter_of(if muted { NotifyLevel::Mentions } else { NotifyLevel::All });
293 Resolved { level, muted, ring }
294}
295
296fn community_of(chat: &crate::chat::Chat) -> Option<&str> {
298 chat.metadata.custom_fields.get("community_id").map(|s| s.as_str())
299}
300
301pub fn resolve_chat(chat: &crate::chat::Chat) -> Resolved {
306 let mut resolved = resolve(&chat.id, community_of(chat), now_ms());
307 if chat.muted && !resolved.muted {
308 resolved.muted = true;
309 resolved.ring = resolved.level.quieter_of(NotifyLevel::Mentions);
310 }
311 resolved
312}
313
314pub fn ring_for_chat(chat: &crate::chat::Chat) -> NotifyLevel {
317 resolve_chat(chat).ring
318}
319
320pub fn muted_for_chat(chat: &crate::chat::Chat) -> bool {
321 resolve_chat(chat).muted
322}
323
324pub fn community_muted_for_chat(chat: &crate::chat::Chat) -> bool {
327 community_of(chat).is_some_and(|id| prefs(id).muted_at(now_ms()))
328}
329
330pub fn in_legacy_mute_list(own: ScopePrefs, parent: ScopePrefs, now_ms: i64) -> bool {
339 resolve_from(own, parent, now_ms).muted
340 && (own.mute_until == MUTE_FOREVER || parent.mute_until == MUTE_FOREVER)
341}
342
343pub fn legacy_muted_for_chat(chat: &crate::chat::Chat) -> bool {
345 let own = prefs(&chat.id);
346 let parent = community_of(chat)
347 .filter(|cid| *cid != chat.id)
348 .map(prefs)
349 .unwrap_or_default();
350 in_legacy_mute_list(own, parent, now_ms())
351 || (chat.muted && !resolve_from(own, parent, now_ms()).muted)
353}
354
355pub fn everyone_pings_for_chat(chat: &crate::chat::Chat) -> bool {
361 !community_of(chat)
362 .map(prefs)
363 .and_then(|p| p.suppress_everyone)
364 .unwrap_or(false)
365}
366
367pub fn next_expiry(now_ms: i64) -> Option<i64> {
374 store().read().ok().and_then(|g| {
375 g.scopes
376 .values()
377 .map(|p| p.mute_until)
378 .filter(|&u| u > now_ms)
379 .min()
380 })
381}
382
383pub fn sweep_expired(now_ms: i64) -> Vec<String> {
388 warm();
389 let cell = store();
390 let lapsed: Vec<String> = match cell.read() {
391 Ok(g) => g
392 .scopes
393 .iter()
394 .filter(|(_, p)| p.mute_until > MUTE_OFF && p.mute_until <= now_ms)
395 .map(|(id, _)| id.clone())
396 .collect(),
397 Err(_) => return Vec::new(),
398 };
399 if lapsed.is_empty() {
400 return lapsed;
401 }
402 if crate::db::notify::clear_expired_mutes(now_ms).is_err() {
403 return Vec::new();
404 }
405 if let Ok(mut g) = cell.write() {
406 for id in &lapsed {
407 let drop_row = match g.scopes.get_mut(id) {
408 Some(p) => {
409 p.mute_until = MUTE_OFF;
410 p.is_default()
411 }
412 None => false,
413 };
414 if drop_row {
415 g.scopes.remove(id);
416 }
417 }
418 }
419 lapsed
420}
421
422impl ScopePrefs {
427 pub fn to_wire(self) -> crate::synced_prefs::NotifyEntry {
428 crate::synced_prefs::NotifyEntry {
429 level: self.level.map(|l| l.as_str().to_string()),
430 mute_until: self.mute_until,
431 suppress_everyone: self.suppress_everyone,
432 }
433 }
434
435 pub fn from_wire(entry: &crate::synced_prefs::NotifyEntry) -> Self {
436 Self {
437 level: entry.level.as_deref().and_then(NotifyLevel::from_label),
438 mute_until: entry.mute_until,
439 suppress_everyone: entry.suppress_everyone,
440 }
441 }
442}
443
444pub fn to_wire() -> crate::synced_prefs::NotifyMap {
446 let mut map = crate::synced_prefs::NotifyMap::default();
447 for (scope_id, prefs) in all_prefs() {
448 let _ = map.set(&scope_id, prefs.to_wire());
449 }
450 map
451}
452
453pub fn apply_wire(map: &crate::synced_prefs::NotifyMap) -> Result<Vec<String>, String> {
456 warm();
460 if !is_loaded() {
461 return Err("notification preferences not read yet".to_string());
462 }
463 let next: HashMap<String, ScopePrefs> = map
464 .scopes
465 .iter()
466 .map(|(id, entry)| (id.clone(), ScopePrefs::from_wire(entry)))
467 .filter(|(_, p)| !p.is_default())
468 .collect();
469 let before = all_prefs();
470 crate::db::notify::replace_all(&next)?;
471 if let Ok(mut g) = store().write() {
472 g.scopes = next.clone();
473 g.loaded = true;
474 }
475 let mut moved: Vec<String> = next
476 .iter()
477 .filter(|(id, p)| before.get(*id) != Some(p))
478 .map(|(id, _)| id.clone())
479 .collect();
480 moved.extend(before.keys().filter(|id| !next.contains_key(*id)).cloned());
481 Ok(moved)
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn mute_clamps_the_level_to_mentions() {
490 assert_eq!(NotifyLevel::All.quieter_of(NotifyLevel::Mentions), NotifyLevel::Mentions);
491 assert_eq!(NotifyLevel::Nothing.quieter_of(NotifyLevel::Mentions), NotifyLevel::Nothing);
493 assert_eq!(NotifyLevel::Mentions.quieter_of(NotifyLevel::All), NotifyLevel::Mentions);
494 }
495
496 #[test]
497 fn level_decides_which_classes_ring() {
498 for class in [MessageClass::Everyone, MessageClass::Mention, MessageClass::Normal] {
499 assert!(passes(NotifyLevel::All, class));
500 assert!(!passes(NotifyLevel::Nothing, class));
501 }
502 assert!(passes(NotifyLevel::Mentions, MessageClass::Mention));
503 assert!(passes(NotifyLevel::Mentions, MessageClass::Everyone));
504 assert!(!passes(NotifyLevel::Mentions, MessageClass::Normal));
505 }
506
507 #[test]
508 fn mute_sentinels() {
509 assert!(is_muted_at(MUTE_FOREVER, 10_000));
510 assert!(!is_muted_at(MUTE_OFF, 10_000));
511 assert!(is_muted_at(10_001, 10_000));
512 assert!(!is_muted_at(10_000, 10_000), "an expiry that has arrived is over");
513 }
514
515 #[test]
516 fn a_scope_at_every_default_stores_nothing() {
517 assert!(ScopePrefs::default().is_default());
518 assert!(!ScopePrefs { mute_until: MUTE_FOREVER, ..Default::default() }.is_default());
519 assert!(!ScopePrefs { level: Some(NotifyLevel::Nothing), ..Default::default() }.is_default());
520 }
521
522 fn at(level: Option<NotifyLevel>, mute_until: i64) -> ScopePrefs {
523 ScopePrefs { level, mute_until, suppress_everyone: None }
524 }
525
526 #[test]
527 fn a_channel_opinion_beats_its_community() {
528 let r = resolve_from(at(Some(NotifyLevel::All), 0), at(Some(NotifyLevel::Nothing), 0), 0);
529 assert_eq!(r.level, NotifyLevel::All);
530 assert_eq!(r.ring, NotifyLevel::All);
531 }
532
533 #[test]
534 fn a_channel_with_no_opinion_takes_its_community() {
535 let r = resolve_from(at(None, 0), at(Some(NotifyLevel::Mentions), 0), 0);
536 assert_eq!(r.level, NotifyLevel::Mentions);
537 }
538
539 #[test]
540 fn nothing_stored_anywhere_rings_for_everything() {
541 let r = resolve_from(ScopePrefs::default(), ScopePrefs::default(), 0);
542 assert_eq!(r.level, NotifyLevel::All);
543 assert!(!r.muted);
544 }
545
546 #[test]
547 fn a_community_mute_reaches_a_channel_that_set_none_itself() {
548 let r = resolve_from(at(Some(NotifyLevel::All), MUTE_OFF), at(None, MUTE_FOREVER), 0);
550 assert!(r.muted);
551 assert_eq!(r.level, NotifyLevel::All, "the level underneath is untouched");
552 assert_eq!(r.ring, NotifyLevel::Mentions, "but the mute clamps what rings");
553 }
554
555 #[test]
556 fn a_mute_never_makes_a_quiet_channel_louder() {
557 let r = resolve_from(at(Some(NotifyLevel::Nothing), MUTE_FOREVER), ScopePrefs::default(), 0);
558 assert_eq!(r.ring, NotifyLevel::Nothing);
559 }
560
561 #[test]
562 fn a_lapsed_timer_stops_muting_without_anything_being_written() {
563 let prefs = at(None, 5_000);
564 assert!(resolve_from(prefs, ScopePrefs::default(), 4_999).muted);
565 assert!(!resolve_from(prefs, ScopePrefs::default(), 5_000).muted);
566 }
567
568 #[test]
569 fn the_legacy_list_names_a_channel_its_community_muted() {
570 let channel = in_legacy_mute_list(ScopePrefs::default(), at(None, MUTE_FOREVER), 0);
572 assert!(channel);
573 }
574
575 #[test]
576 fn publishing_and_applying_the_legacy_list_agree() {
577 let community = at(None, MUTE_FOREVER);
580 let channel = ScopePrefs::default();
581 let published = in_legacy_mute_list(channel, community, 0);
582 let on_apply = in_legacy_mute_list(channel, community, 0);
583 assert_eq!(published, on_apply, "the applier must read what the publisher wrote");
584 assert!(published);
585 }
586
587 #[test]
588 fn a_timed_mute_never_enters_the_legacy_list() {
589 assert!(!in_legacy_mute_list(at(None, 9_000), ScopePrefs::default(), 0));
592 assert!(!in_legacy_mute_list(ScopePrefs::default(), at(None, 9_000), 0));
593 assert!(in_legacy_mute_list(at(None, MUTE_FOREVER), ScopePrefs::default(), 0));
594 }
595
596 #[test]
597 fn a_lapsed_indefinite_free_scope_leaves_the_legacy_list() {
598 let expired = at(None, 5_000);
599 assert!(!in_legacy_mute_list(expired, ScopePrefs::default(), 6_000));
600 }
601
602 #[test]
603 fn sweep_picks_exactly_the_timers_that_have_arrived() {
604 let lapsed = |until: i64, now: i64| until > MUTE_OFF && until <= now;
606 assert!(!lapsed(MUTE_FOREVER, 10_000), "indefinite never lapses");
607 assert!(!lapsed(MUTE_OFF, 10_000), "unmuted has no timer");
608 assert!(lapsed(10_000, 10_000), "an expiry that has arrived is over");
609 assert!(!lapsed(10_001, 10_000));
610 }
611
612 #[test]
613 fn scope_prefs_round_trip_through_the_wire_shape() {
614 let prefs = ScopePrefs {
615 level: Some(NotifyLevel::Mentions),
616 mute_until: 1_700_000_000_000,
617 suppress_everyone: Some(true),
618 };
619 assert_eq!(ScopePrefs::from_wire(&prefs.to_wire()), prefs);
620 assert!(ScopePrefs::from_wire(&crate::synced_prefs::NotifyEntry::default()).is_default());
623 }
624
625 #[test]
626 fn level_round_trips_through_both_wire_forms() {
627 for level in [NotifyLevel::All, NotifyLevel::Mentions, NotifyLevel::Nothing] {
628 assert_eq!(NotifyLevel::from_u8(level.as_u8()), Some(level));
629 assert_eq!(NotifyLevel::from_label(level.as_str()), Some(level));
630 }
631 assert_eq!(NotifyLevel::from_label("none"), Some(NotifyLevel::Nothing));
633 assert_eq!(NotifyLevel::from_label("whatever"), None);
634 assert_eq!(NotifyLevel::from_u8(9), None);
635 }
636}