Skip to main content

vector_core/
notify.rs

1//! Notification levels and mutes, for communities, channels and DMs alike.
2//!
3//! Two settings per scope, doing different jobs. **Level** decides what rings:
4//! every message, only your name, or nothing. **Mute** greys the row, drops its
5//! unread weight, and clamps the level to at most `Mentions` — a volume knob,
6//! not an off switch, which is why `Nothing` is not a synonym for it.
7//!
8//! Level resolves down the chain Community → Channel, most specific opinion
9//! winning. Mute unions down it: muting a community mutes every channel in it.
10//! A scope with no stored row inherits everything, so an untouched account
11//! behaves exactly as it did before any of this existed.
12
13use std::collections::HashMap;
14use std::sync::{Arc, RwLock};
15
16/// What rings. Ordered loudest first, so the numeric rank orders "quieter".
17#[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    /// The quieter of the two, which is how a mute clamps a level.
57    pub fn quieter_of(self, other: Self) -> Self {
58        if self >= other { self } else { other }
59    }
60}
61
62/// What a message is, loudest first. Resolved once at classification so a
63/// suppressed @everyone is `Normal` everywhere downstream: notification,
64/// ping badge and muted-row count alike.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum MessageClass {
67    Everyone,
68    Mention,
69    Normal,
70}
71
72/// Whether a message of this class rings at this level.
73pub 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
81/// `mute_until` sentinel for a mute with no end.
82pub const MUTE_FOREVER: i64 = -1;
83/// `mute_until` sentinel for "not muted".
84pub 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/// One scope's stored settings. `None` means inherit; `suppress_everyone` is
98/// only ever set at community scope.
99#[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/// A scope's settings after the chain has been walked.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct Resolved {
118    /// The level in force before the mute clamp, which is what a menu ticks.
119    pub level: NotifyLevel,
120    pub muted: bool,
121    /// The level after the clamp, which is what actually rings and badges.
122    pub ring: NotifyLevel,
123}
124
125// ============================================================================
126// Per-account cache
127// ============================================================================
128
129#[derive(Default)]
130struct Store {
131    loaded: bool,
132    scopes: HashMap<String, ScopePrefs>,
133    /// Channel ids their community still lists. A tombstoned channel keeps its
134    /// chat row and its history, so "has a `community_id`" is not the same
135    /// question as "has a row you can open and clear".
136    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
145/// Read the table into memory. Called once when a database is opened, because
146/// every badge recount resolves every chat and a lazy load would put a SQLite
147/// round trip on that path.
148///
149/// A failed read leaves the cache unloaded rather than latching an empty map,
150/// and [`is_loaded`] is what tells a writer not to act on the emptiness.
151pub 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
166/// Whether the table has actually been read. An unloaded cache answers
167/// "default" for every scope, which is indistinguishable from "nothing is
168/// stored" — so anything that WRITES state derived from it must check this
169/// first, the same rule `synced_prefs::is_hydrated` applies to publishing.
170pub fn is_loaded() -> bool {
171    store().read().map(|g| g.loaded).unwrap_or(false)
172}
173
174/// Drop the cache so the next warm re-reads the table.
175pub 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
183/// Forget which channels are listed; their community's set was rewritten.
184pub fn invalidate_live_channels() {
185    if let Ok(mut g) = store().write() {
186        g.live_channels = None;
187    }
188}
189
190/// Whether `channel_id` is one its community still lists. A channel nobody
191/// lists has no row to open, so its unread can never be seen or cleared.
192pub 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    // Unknown beats silently dropping a room's unread.
200    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
223/// Write a scope through to the database and the cache.
224pub 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
255// ============================================================================
256// Resolution
257// ============================================================================
258
259/// Whether an authorized @everyone may ping, for a community. Two levers: the
260/// App Settings kill switch, which wins outright, and the community's own
261/// choice under it. A channel has no say, deliberately.
262///
263/// Separate from [`resolve`] because it reads a setting from the database and
264/// `resolve` runs once per chat on every badge recount.
265pub 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
276/// Walk the chain for one scope. `community_id` is the channel's community, or
277/// `None` for a DM or for a community resolving itself.
278pub 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
287/// The chain arithmetic on its own: level takes the most specific opinion,
288/// mute takes either, and the mute clamps the level it hands back.
289pub 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
296/// The community a chat belongs to, or `None` for a DM.
297fn community_of(chat: &crate::chat::Chat) -> Option<&str> {
298    chat.metadata.custom_fields.get("community_id").map(|s| s.as_str())
299}
300
301/// A chat's settings, with its stored `muted` flag folded in. That flag is the
302/// mirror this module keeps for the paths that predate it, and the landing
303/// point for a mute arriving over the older `vector/mutes` projection, so it
304/// counts as a mute in its own right rather than being assumed to agree.
305pub 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
314/// What a chat's row should badge with: full unread at `All`, pings at
315/// `Mentions`, nothing at `Nothing`.
316pub 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
324/// Whether a chat's COMMUNITY is itself silenced, rather than this one room inside it.
325/// A surface that stands for the whole space must not grey because one channel is quiet.
326pub 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
330/// The predicate the older `vector/mutes` projection is both WRITTEN and READ
331/// with. Publishing one shape and applying another is how an inherited mute
332/// ratchets into a permanent per-channel one.
333///
334/// Chain-resolved, so a client that predates levels silences the same rooms
335/// this one does. Indefinite-only, because that list cannot carry a deadline:
336/// a timed mute in it comes back as a mute with no end, and the expiry that
337/// should have corrected it happens at boot, where publishing is not allowed.
338pub 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
343/// [`in_legacy_mute_list`] for a chat, stored flag folded in like [`resolve_chat`].
344pub 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        // A mute that only exists as the legacy mirror is indefinite by nature.
352        || (chat.muted && !resolve_from(own, parent, now_ms()).muted)
353}
354
355/// Whether an @everyone in this chat still counts as someone calling your name.
356///
357/// Reads the community's own setting only, never the App Settings switch: this
358/// runs once per chat on every serialization, and that switch lives in the
359/// database. It has never affected badge counts anyway, only delivery.
360pub 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
367// ============================================================================
368// Expiry
369// ============================================================================
370
371/// The soonest mute expiry still ahead of `now_ms`, for arming one timer
372/// rather than one per mute.
373pub 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
383/// Clear every mute whose timer has run out, returning the scopes that just
384/// lapsed. Expiry is a write rather than a read: the callers of `muted` read a
385/// cached boolean, and making them all consult the clock instead would be the
386/// wrong fix.
387pub 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
422// ============================================================================
423// Cross-device projection
424// ============================================================================
425
426impl 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
444/// This device's table as the publishable projection.
445pub 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
453/// Adopt a sibling device's list wholesale. Returns the scopes whose settings
454/// actually moved, so the caller only has to repaint those.
455pub fn apply_wire(map: &crate::synced_prefs::NotifyMap) -> Result<Vec<String>, String> {
456    // Adopting a list REPLACES the table, so an unloaded cache would compare the
457    // incoming set against an emptiness that only means "could not read" and
458    // report nothing moved.
459    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        // Nothing is already quieter than the clamp, so a mute cannot make it louder.
492        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        // Mute unions downward, unlike level: the channel cannot opt out of it.
549        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        // Chain-resolved, so a client that predates levels goes quiet in the same rooms.
571        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        // The bug this guards: publish with one predicate and apply with another, and
578        // an inherited mute round-trips into a permanent per-channel one.
579        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        // It carries no deadline, so anything in it comes back indefinite, and the
590        // expiry that should correct that happens at boot where publishing is barred.
591        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        // Mirrors `db::notify::clear_expired_mutes`'s `mute_until > 0 AND <= ?1`.
605        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        // An entry that says nothing must come back as the default, or a sync
621        // would keep resurrecting rows the writer meant to drop.
622        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        // The sync payload predates the rename, so both spellings must read.
632        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}