Skip to main content

teksilo_widgets/
notification.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Persistent notification archive — the storage and data-model layer
5//! backing [`NotificationLog`], [`NotificationCenterButton`], and
6//! [`NotificationLogDialog`].
7//!
8//! Every toast presented through the toast registry is mirrored into a
9//! [`NotificationArchiveModel`] when archiving is enabled via
10//! `ToastInstallOptions::archive`. The model is a
11//! [`ListModel<NotificationEntry>`](teksilo_data::ListModel) plus an
12//! unread-count signal — shaped for one-line binding to the notification
13//! UI family. Two storage variants are available: an in-memory session-only
14//! ring buffer ([`NotificationArchive::InMemory`]) and a file-backed
15//! persistent store ([`NotificationArchive::Persistent`]) that survives app
16//! restarts. Action callbacks attached via raw closures are lost on
17//! archival; actions that should remain re-invokable from the log carry an
18//! `intent_name` that the log replays through `ctx.send_intent(...)`.
19//!
20//! ## When to use
21//!
22//! - Pair with `TeksiloAppBuilder::install_toast_default()` to get the full
23//!   bell-button + log + persistence stack for free.
24//! - Construct [`NotificationArchiveModel::in_memory`] directly in tests or
25//!   custom toast setups.
26//!
27//! ```ignore
28//! // In app boot, after install_toast:
29//! let archive = ctx.app_state::<Rc<RefCell<NotificationArchiveModel>>>().unwrap();
30//! let log = NotificationLog::new(archive.clone());
31//! ```
32
33pub mod archive;
34pub mod center_button;
35pub mod log;
36pub mod log_dialog;
37
38#[cfg(test)]
39mod reader_tests;
40
41use serde::{Deserialize, Serialize};
42use teksilo_core::styles::{BannerSeverity, ToastPriority};
43use teksilo_settings::Keyed;
44
45use crate::toast::ToastRoute;
46
47pub use archive::{
48    ARCHIVE_FILE_NAME, DEFAULT_ARCHIVE_LIMIT, NotificationArchive, NotificationArchiveError,
49    NotificationArchiveModel, UPDATE_HISTORY_LIMIT,
50};
51pub use center_button::NotificationCenterButton;
52pub use log::NotificationLog;
53pub use log_dialog::NotificationLogDialog;
54
55/// A single archived notification entry rendered by [`NotificationLog`] and
56/// persisted under `NotificationArchive::Persistent`. Carries plain owned
57/// fields only — no closures, no `Rc<dyn Fn>` — so it is `Serialize`-friendly.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59pub struct NotificationEntry {
60    /// Stable per-archive id (separate from the runtime `Toast::id`
61    /// dedup key — that one lives in `dedup_id` below). Assigned by
62    /// the archive on first push; never reused.
63    pub id: u64,
64    /// Severity of the notice as it now stands: the latest in-place
65    /// update's, else the original push's. Drives the log's row glyph +
66    /// severity-chip filter.
67    pub severity: BannerSeverity,
68    pub priority: ToastPriority,
69    /// Resolved title (`LocalizedString::resolve_now()` snapshot).
70    pub title: String,
71    pub body: Option<String>,
72    /// The actions the notice offers as it now stands. An in-place update
73    /// replaces them; one that offers none leaves none.
74    pub actions: Vec<ArchivedAction>,
75    /// Wall-clock time the notice was last raised: its first push, or the
76    /// latest time a notice with the same `dedup_id` was raised again after
77    /// the first had left the screen. In-place updates of a notice still on
78    /// screen leave it alone. The log's day-bucket computation runs against
79    /// this in the user's local timezone.
80    pub timestamp: jiff::Timestamp,
81    /// Optional grouping key for the log's visual section headers.
82    pub group: Option<String>,
83    /// Optional originating-feature tag (e.g. `"build"`, `"sync"`).
84    /// Surfaced as a chip in the log row.
85    pub source: Option<String>,
86    /// Flipped when the user opens the log popover. Drives the bell
87    /// badge's `unread_count` signal.
88    pub read: bool,
89    /// `Toast::id(...)` value, if any — used for update-in-place
90    /// merge logic. New entries with a matching `dedup_id` merge into
91    /// the existing row rather than creating a separate one (see
92    /// [`NotificationArchiveModel::push`](archive::NotificationArchiveModel::push)
93    /// and [`push_update`](archive::NotificationArchiveModel::push_update)).
94    pub dedup_id: Option<String>,
95    /// Later presents of the same `Toast::id(...)`: one per in-place
96    /// update that changed what the row shows, and one per time the
97    /// notice was raised again. Holds the most recent
98    /// [`UPDATE_HISTORY_LIMIT`]. Empty on a
99    /// freshly-pushed entry.
100    pub updates: Vec<NotificationUpdate>,
101    /// Mirrored from the originating `LiveEntry::route` (see
102    /// `ToastRegistry::entry_to_archive`) — drives which bell(s) show
103    /// this entry and which bell's "mark all read" / "clear" affects
104    /// it. Defaults to [`ToastRoute::Broadcast`] on deserialization
105    /// when the field is absent (a `notifications.toml` written before
106    /// this feature existed): treating pre-upgrade history as
107    /// broadcast keeps it visible in every window's bell, rather than
108    /// it silently vanishing from all of them the moment routing scopes
109    /// are introduced.
110    #[serde(default = "default_notification_route")]
111    pub route: ToastRoute,
112}
113
114fn default_notification_route() -> ToastRoute {
115    ToastRoute::Broadcast
116}
117
118/// Keyed by the stable, never-reused archive `id` (not the transient
119/// `dedup_id`, which is only used to *find* the row to merge into — see
120/// [`NotificationArchiveModel::push`](archive::NotificationArchiveModel::push)).
121/// This is what lets [`PersistedListModel`](teksilo_settings::PersistedListModel)
122/// merge a peer process's concurrent archive write by row identity
123/// instead of by whole-document snapshot.
124impl Keyed for NotificationEntry {
125    type Key = u64;
126
127    fn key(&self) -> u64 {
128        self.id
129    }
130}
131
132/// One in-place mutation applied when a `Toast` with the same `id` as an
133/// existing entry is presented again. The archive merges these onto the
134/// existing row — the "Uploading 3 of 7 → Upload complete" pattern.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct NotificationUpdate {
137    pub timestamp: jiff::Timestamp,
138    pub title: Option<String>,
139    pub body: Option<String>,
140    pub progress: Option<f32>,
141}
142
143/// Visual presentation of an archived action button. Maps one-to-one to
144/// `ToastActionStyle`; re-declared as a self-contained `Serialize`-friendly
145/// enum so the archive type does not depend on `ButtonVariant`.
146#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
147pub enum ArchivedActionStyle {
148    /// JetBrains-style hyperlink in the body row.
149    Link,
150    /// Filled (primary CTA).
151    PrimaryButton,
152    /// Plain (secondary).
153    SecondaryButton,
154    /// Destructive (red-tinted).
155    Destructive,
156}
157
158/// A single action stored alongside an archived notification entry. Only
159/// re-invokable from [`NotificationLog`] when `intent_name` is set — actions
160/// whose live closure has torn down render as inert descriptive labels.
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
162pub struct ArchivedAction {
163    /// Resolved label snapshot.
164    pub label: String,
165    /// Intent name for archive replay through `ctx.send_intent(...)`.
166    /// `None` for closure-only actions; the log renders these as
167    /// non-clickable past-action tags.
168    pub intent_name: Option<String>,
169    pub style: ArchivedActionStyle,
170    /// Mirrors the live action's `closes_toast` flag. The log uses
171    /// it informationally only — a replayed Intent fires and the
172    /// archive row itself doesn't dismiss (since it's not a live
173    /// toast).
174    pub closes_on_invoke: bool,
175}
176
177/// Whether an entry carrying `route` should be visible to a bell/log
178/// scoped to `scope`. `scope: None` means "unscoped" — the legacy
179/// "see everything" behaviour, so an existing single-window app that
180/// never calls `NotificationCenterButton::for_window` /
181/// `for_audience` (or the matching `NotificationLog` methods) keeps
182/// showing every entry exactly as before this feature existed.
183/// `Broadcast` entries are always visible regardless of `scope` —
184/// that's the entire point of a genuinely app-wide message.
185pub(crate) fn route_visible(route: ToastRoute, scope: Option<ToastRoute>) -> bool {
186    match scope {
187        None => true,
188        Some(scope) => route == scope || matches!(route, ToastRoute::Broadcast),
189    }
190}