use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(u8)]
pub enum NotifyLevel {
All = 0,
Mentions = 1,
Nothing = 2,
}
impl NotifyLevel {
pub fn as_u8(self) -> u8 {
self as u8
}
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::All),
1 => Some(Self::Mentions),
2 => Some(Self::Nothing),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::All => "all",
Self::Mentions => "mentions",
Self::Nothing => "nothing",
}
}
pub fn from_label(s: &str) -> Option<Self> {
match s {
"all" => Some(Self::All),
"mentions" => Some(Self::Mentions),
"nothing" | "none" => Some(Self::Nothing),
_ => None,
}
}
pub fn quieter_of(self, other: Self) -> Self {
if self >= other { self } else { other }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageClass {
Everyone,
Mention,
Normal,
}
pub fn passes(level: NotifyLevel, class: MessageClass) -> bool {
match level {
NotifyLevel::All => true,
NotifyLevel::Mentions => !matches!(class, MessageClass::Normal),
NotifyLevel::Nothing => false,
}
}
pub const MUTE_FOREVER: i64 = -1;
pub const MUTE_OFF: i64 = 0;
pub fn is_muted_at(mute_until: i64, now_ms: i64) -> bool {
mute_until == MUTE_FOREVER || mute_until > now_ms
}
pub fn now_ms() -> i64 {
web_time::SystemTime::now()
.duration_since(web_time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ScopePrefs {
pub level: Option<NotifyLevel>,
pub mute_until: i64,
pub suppress_everyone: Option<bool>,
}
impl ScopePrefs {
pub fn is_default(&self) -> bool {
self.level.is_none() && self.mute_until == MUTE_OFF && self.suppress_everyone.is_none()
}
pub fn muted_at(&self, now_ms: i64) -> bool {
is_muted_at(self.mute_until, now_ms)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Resolved {
pub level: NotifyLevel,
pub muted: bool,
pub ring: NotifyLevel,
}
#[derive(Default)]
struct Store {
loaded: bool,
scopes: HashMap<String, ScopePrefs>,
live_channels: Option<std::collections::HashSet<String>>,
}
struct NotifyStore;
fn store() -> Arc<RwLock<Store>> {
crate::db::current_session().scoped::<NotifyStore, _>()
}
pub fn warm() {
let cell = store();
if cell.read().map(|g| g.loaded).unwrap_or(false) {
return;
}
let rows = match crate::db::notify::load_all() {
Ok(rows) => rows,
Err(_) => return,
};
if let Ok(mut g) = cell.write() {
g.scopes = rows;
g.loaded = true;
};
}
pub fn is_loaded() -> bool {
store().read().map(|g| g.loaded).unwrap_or(false)
}
pub fn invalidate() {
if let Ok(mut g) = store().write() {
g.loaded = false;
g.scopes.clear();
g.live_channels = None;
}
}
pub fn invalidate_live_channels() {
if let Ok(mut g) = store().write() {
g.live_channels = None;
}
}
pub fn channel_is_listed(channel_id: &str) -> bool {
let cell = store();
if let Ok(g) = cell.read() {
if let Some(set) = g.live_channels.as_ref() {
return set.contains(channel_id);
}
}
let ids = match crate::db::notify::live_channel_ids() {
Ok(ids) => ids,
Err(_) => return true,
};
let listed = ids.contains(channel_id);
if let Ok(mut g) = cell.write() {
g.live_channels = Some(ids);
}
listed
}
pub fn prefs(scope_id: &str) -> ScopePrefs {
store()
.read()
.ok()
.and_then(|g| g.scopes.get(scope_id).copied())
.unwrap_or_default()
}
pub fn all_prefs() -> HashMap<String, ScopePrefs> {
store().read().map(|g| g.scopes.clone()).unwrap_or_default()
}
pub fn set_prefs(scope_id: &str, next: ScopePrefs) -> Result<(), String> {
warm();
crate::db::notify::save(scope_id, &next)?;
if let Ok(mut g) = store().write() {
if next.is_default() {
g.scopes.remove(scope_id);
} else {
g.scopes.insert(scope_id.to_string(), next);
}
}
Ok(())
}
pub fn set_level(scope_id: &str, level: Option<NotifyLevel>) -> Result<(), String> {
let mut next = prefs(scope_id);
next.level = level;
set_prefs(scope_id, next)
}
pub fn set_mute(scope_id: &str, mute_until: i64) -> Result<(), String> {
let mut next = prefs(scope_id);
next.mute_until = mute_until;
set_prefs(scope_id, next)
}
pub fn set_suppress_everyone(scope_id: &str, suppress: Option<bool>) -> Result<(), String> {
let mut next = prefs(scope_id);
next.suppress_everyone = suppress;
set_prefs(scope_id, next)
}
pub fn everyone_allowed(community_id: Option<&str>) -> bool {
let globally_muted = crate::db::settings::get_sql_setting("notif_mute_everyone".to_string())
.ok()
.flatten()
.is_some_and(|v| v == "true");
if globally_muted {
return false;
}
!community_id.is_some_and(|cid| prefs(cid).suppress_everyone.unwrap_or(false))
}
pub fn resolve(scope_id: &str, community_id: Option<&str>, now_ms: i64) -> Resolved {
let own = prefs(scope_id);
let parent = community_id
.filter(|cid| *cid != scope_id)
.map(prefs)
.unwrap_or_default();
resolve_from(own, parent, now_ms)
}
pub fn resolve_from(own: ScopePrefs, parent: ScopePrefs, now_ms: i64) -> Resolved {
let level = own.level.or(parent.level).unwrap_or(NotifyLevel::All);
let muted = own.muted_at(now_ms) || parent.muted_at(now_ms);
let ring = level.quieter_of(if muted { NotifyLevel::Mentions } else { NotifyLevel::All });
Resolved { level, muted, ring }
}
fn community_of(chat: &crate::chat::Chat) -> Option<&str> {
chat.metadata.custom_fields.get("community_id").map(|s| s.as_str())
}
pub fn resolve_chat(chat: &crate::chat::Chat) -> Resolved {
let mut resolved = resolve(&chat.id, community_of(chat), now_ms());
if chat.muted && !resolved.muted {
resolved.muted = true;
resolved.ring = resolved.level.quieter_of(NotifyLevel::Mentions);
}
resolved
}
pub fn ring_for_chat(chat: &crate::chat::Chat) -> NotifyLevel {
resolve_chat(chat).ring
}
pub fn muted_for_chat(chat: &crate::chat::Chat) -> bool {
resolve_chat(chat).muted
}
pub fn community_muted_for_chat(chat: &crate::chat::Chat) -> bool {
community_of(chat).is_some_and(|id| prefs(id).muted_at(now_ms()))
}
pub fn in_legacy_mute_list(own: ScopePrefs, parent: ScopePrefs, now_ms: i64) -> bool {
resolve_from(own, parent, now_ms).muted
&& (own.mute_until == MUTE_FOREVER || parent.mute_until == MUTE_FOREVER)
}
pub fn legacy_muted_for_chat(chat: &crate::chat::Chat) -> bool {
let own = prefs(&chat.id);
let parent = community_of(chat)
.filter(|cid| *cid != chat.id)
.map(prefs)
.unwrap_or_default();
in_legacy_mute_list(own, parent, now_ms())
|| (chat.muted && !resolve_from(own, parent, now_ms()).muted)
}
pub fn everyone_pings_for_chat(chat: &crate::chat::Chat) -> bool {
!community_of(chat)
.map(prefs)
.and_then(|p| p.suppress_everyone)
.unwrap_or(false)
}
pub fn next_expiry(now_ms: i64) -> Option<i64> {
store().read().ok().and_then(|g| {
g.scopes
.values()
.map(|p| p.mute_until)
.filter(|&u| u > now_ms)
.min()
})
}
pub fn sweep_expired(now_ms: i64) -> Vec<String> {
warm();
let cell = store();
let lapsed: Vec<String> = match cell.read() {
Ok(g) => g
.scopes
.iter()
.filter(|(_, p)| p.mute_until > MUTE_OFF && p.mute_until <= now_ms)
.map(|(id, _)| id.clone())
.collect(),
Err(_) => return Vec::new(),
};
if lapsed.is_empty() {
return lapsed;
}
if crate::db::notify::clear_expired_mutes(now_ms).is_err() {
return Vec::new();
}
if let Ok(mut g) = cell.write() {
for id in &lapsed {
let drop_row = match g.scopes.get_mut(id) {
Some(p) => {
p.mute_until = MUTE_OFF;
p.is_default()
}
None => false,
};
if drop_row {
g.scopes.remove(id);
}
}
}
lapsed
}
impl ScopePrefs {
pub fn to_wire(self) -> crate::synced_prefs::NotifyEntry {
crate::synced_prefs::NotifyEntry {
level: self.level.map(|l| l.as_str().to_string()),
mute_until: self.mute_until,
suppress_everyone: self.suppress_everyone,
}
}
pub fn from_wire(entry: &crate::synced_prefs::NotifyEntry) -> Self {
Self {
level: entry.level.as_deref().and_then(NotifyLevel::from_label),
mute_until: entry.mute_until,
suppress_everyone: entry.suppress_everyone,
}
}
}
pub fn to_wire() -> crate::synced_prefs::NotifyMap {
let mut map = crate::synced_prefs::NotifyMap::default();
for (scope_id, prefs) in all_prefs() {
let _ = map.set(&scope_id, prefs.to_wire());
}
map
}
pub fn apply_wire(map: &crate::synced_prefs::NotifyMap) -> Result<Vec<String>, String> {
warm();
if !is_loaded() {
return Err("notification preferences not read yet".to_string());
}
let next: HashMap<String, ScopePrefs> = map
.scopes
.iter()
.map(|(id, entry)| (id.clone(), ScopePrefs::from_wire(entry)))
.filter(|(_, p)| !p.is_default())
.collect();
let before = all_prefs();
crate::db::notify::replace_all(&next)?;
if let Ok(mut g) = store().write() {
g.scopes = next.clone();
g.loaded = true;
}
let mut moved: Vec<String> = next
.iter()
.filter(|(id, p)| before.get(*id) != Some(p))
.map(|(id, _)| id.clone())
.collect();
moved.extend(before.keys().filter(|id| !next.contains_key(*id)).cloned());
Ok(moved)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mute_clamps_the_level_to_mentions() {
assert_eq!(NotifyLevel::All.quieter_of(NotifyLevel::Mentions), NotifyLevel::Mentions);
assert_eq!(NotifyLevel::Nothing.quieter_of(NotifyLevel::Mentions), NotifyLevel::Nothing);
assert_eq!(NotifyLevel::Mentions.quieter_of(NotifyLevel::All), NotifyLevel::Mentions);
}
#[test]
fn level_decides_which_classes_ring() {
for class in [MessageClass::Everyone, MessageClass::Mention, MessageClass::Normal] {
assert!(passes(NotifyLevel::All, class));
assert!(!passes(NotifyLevel::Nothing, class));
}
assert!(passes(NotifyLevel::Mentions, MessageClass::Mention));
assert!(passes(NotifyLevel::Mentions, MessageClass::Everyone));
assert!(!passes(NotifyLevel::Mentions, MessageClass::Normal));
}
#[test]
fn mute_sentinels() {
assert!(is_muted_at(MUTE_FOREVER, 10_000));
assert!(!is_muted_at(MUTE_OFF, 10_000));
assert!(is_muted_at(10_001, 10_000));
assert!(!is_muted_at(10_000, 10_000), "an expiry that has arrived is over");
}
#[test]
fn a_scope_at_every_default_stores_nothing() {
assert!(ScopePrefs::default().is_default());
assert!(!ScopePrefs { mute_until: MUTE_FOREVER, ..Default::default() }.is_default());
assert!(!ScopePrefs { level: Some(NotifyLevel::Nothing), ..Default::default() }.is_default());
}
fn at(level: Option<NotifyLevel>, mute_until: i64) -> ScopePrefs {
ScopePrefs { level, mute_until, suppress_everyone: None }
}
#[test]
fn a_channel_opinion_beats_its_community() {
let r = resolve_from(at(Some(NotifyLevel::All), 0), at(Some(NotifyLevel::Nothing), 0), 0);
assert_eq!(r.level, NotifyLevel::All);
assert_eq!(r.ring, NotifyLevel::All);
}
#[test]
fn a_channel_with_no_opinion_takes_its_community() {
let r = resolve_from(at(None, 0), at(Some(NotifyLevel::Mentions), 0), 0);
assert_eq!(r.level, NotifyLevel::Mentions);
}
#[test]
fn nothing_stored_anywhere_rings_for_everything() {
let r = resolve_from(ScopePrefs::default(), ScopePrefs::default(), 0);
assert_eq!(r.level, NotifyLevel::All);
assert!(!r.muted);
}
#[test]
fn a_community_mute_reaches_a_channel_that_set_none_itself() {
let r = resolve_from(at(Some(NotifyLevel::All), MUTE_OFF), at(None, MUTE_FOREVER), 0);
assert!(r.muted);
assert_eq!(r.level, NotifyLevel::All, "the level underneath is untouched");
assert_eq!(r.ring, NotifyLevel::Mentions, "but the mute clamps what rings");
}
#[test]
fn a_mute_never_makes_a_quiet_channel_louder() {
let r = resolve_from(at(Some(NotifyLevel::Nothing), MUTE_FOREVER), ScopePrefs::default(), 0);
assert_eq!(r.ring, NotifyLevel::Nothing);
}
#[test]
fn a_lapsed_timer_stops_muting_without_anything_being_written() {
let prefs = at(None, 5_000);
assert!(resolve_from(prefs, ScopePrefs::default(), 4_999).muted);
assert!(!resolve_from(prefs, ScopePrefs::default(), 5_000).muted);
}
#[test]
fn the_legacy_list_names_a_channel_its_community_muted() {
let channel = in_legacy_mute_list(ScopePrefs::default(), at(None, MUTE_FOREVER), 0);
assert!(channel);
}
#[test]
fn publishing_and_applying_the_legacy_list_agree() {
let community = at(None, MUTE_FOREVER);
let channel = ScopePrefs::default();
let published = in_legacy_mute_list(channel, community, 0);
let on_apply = in_legacy_mute_list(channel, community, 0);
assert_eq!(published, on_apply, "the applier must read what the publisher wrote");
assert!(published);
}
#[test]
fn a_timed_mute_never_enters_the_legacy_list() {
assert!(!in_legacy_mute_list(at(None, 9_000), ScopePrefs::default(), 0));
assert!(!in_legacy_mute_list(ScopePrefs::default(), at(None, 9_000), 0));
assert!(in_legacy_mute_list(at(None, MUTE_FOREVER), ScopePrefs::default(), 0));
}
#[test]
fn a_lapsed_indefinite_free_scope_leaves_the_legacy_list() {
let expired = at(None, 5_000);
assert!(!in_legacy_mute_list(expired, ScopePrefs::default(), 6_000));
}
#[test]
fn sweep_picks_exactly_the_timers_that_have_arrived() {
let lapsed = |until: i64, now: i64| until > MUTE_OFF && until <= now;
assert!(!lapsed(MUTE_FOREVER, 10_000), "indefinite never lapses");
assert!(!lapsed(MUTE_OFF, 10_000), "unmuted has no timer");
assert!(lapsed(10_000, 10_000), "an expiry that has arrived is over");
assert!(!lapsed(10_001, 10_000));
}
#[test]
fn scope_prefs_round_trip_through_the_wire_shape() {
let prefs = ScopePrefs {
level: Some(NotifyLevel::Mentions),
mute_until: 1_700_000_000_000,
suppress_everyone: Some(true),
};
assert_eq!(ScopePrefs::from_wire(&prefs.to_wire()), prefs);
assert!(ScopePrefs::from_wire(&crate::synced_prefs::NotifyEntry::default()).is_default());
}
#[test]
fn level_round_trips_through_both_wire_forms() {
for level in [NotifyLevel::All, NotifyLevel::Mentions, NotifyLevel::Nothing] {
assert_eq!(NotifyLevel::from_u8(level.as_u8()), Some(level));
assert_eq!(NotifyLevel::from_label(level.as_str()), Some(level));
}
assert_eq!(NotifyLevel::from_label("none"), Some(NotifyLevel::Nothing));
assert_eq!(NotifyLevel::from_label("whatever"), None);
assert_eq!(NotifyLevel::from_u8(9), None);
}
}