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
38use serde::{Deserialize, Serialize};
39use teksilo_core::styles::{BannerSeverity, ToastPriority};
40use teksilo_settings::Keyed;
41
42use crate::toast::ToastRoute;
43
44pub use archive::{
45    ARCHIVE_FILE_NAME, DEFAULT_ARCHIVE_LIMIT, NotificationArchive, NotificationArchiveError,
46    NotificationArchiveModel,
47};
48pub use center_button::NotificationCenterButton;
49pub use log::NotificationLog;
50pub use log_dialog::NotificationLogDialog;
51
52/// A single archived notification entry rendered by [`NotificationLog`] and
53/// persisted under `NotificationArchive::Persistent`. Carries plain owned
54/// fields only — no closures, no `Rc<dyn Fn>` — so it is `Serialize`-friendly.
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct NotificationEntry {
57    /// Stable per-archive id (separate from the runtime `Toast::id`
58    /// dedup key — that one lives in `dedup_id` below). Assigned by
59    /// the archive on first push; never reused.
60    pub id: u64,
61    /// Severity at the time of the original push. Drives the log's
62    /// row glyph + severity-chip filter.
63    pub severity: BannerSeverity,
64    pub priority: ToastPriority,
65    /// Resolved title (`LocalizedString::resolve_now()` snapshot).
66    pub title: String,
67    pub body: Option<String>,
68    pub actions: Vec<ArchivedAction>,
69    /// Wall-clock timestamp at first push. The log's day-bucket
70    /// computation runs against this in the user's local timezone.
71    pub timestamp: jiff::Timestamp,
72    /// Optional grouping key for the log's visual section headers.
73    pub group: Option<String>,
74    /// Optional originating-feature tag (e.g. `"build"`, `"sync"`).
75    /// Surfaced as a chip in the log row.
76    pub source: Option<String>,
77    /// Flipped when the user opens the log popover. Drives the bell
78    /// badge's `unread_count` signal.
79    pub read: bool,
80    /// `Toast::id(...)` value, if any — used for update-in-place
81    /// merge logic. New entries with a matching `dedup_id` append
82    /// to the existing entry's `updates` list rather than creating
83    /// a separate row.
84    pub dedup_id: Option<String>,
85    /// In-place updates from subsequent `Toast::id(...)` presents.
86    /// Empty on a freshly-pushed entry.
87    pub updates: Vec<NotificationUpdate>,
88    /// Mirrored from the originating `LiveEntry::route` (see
89    /// `ToastRegistry::entry_to_archive`) — drives which bell(s) show
90    /// this entry and which bell's "mark all read" / "clear" affects
91    /// it. Defaults to [`ToastRoute::Broadcast`] on deserialization
92    /// when the field is absent (a `notifications.toml` written before
93    /// this feature existed): treating pre-upgrade history as
94    /// broadcast keeps it visible in every window's bell, rather than
95    /// it silently vanishing from all of them the moment routing scopes
96    /// are introduced.
97    #[serde(default = "default_notification_route")]
98    pub route: ToastRoute,
99}
100
101fn default_notification_route() -> ToastRoute {
102    ToastRoute::Broadcast
103}
104
105/// Keyed by the stable, never-reused archive `id` (not the transient
106/// `dedup_id`, which is only used to *find* the row to merge into — see
107/// [`NotificationArchiveModel::push`](archive::NotificationArchiveModel::push)).
108/// This is what lets [`PersistedListModel`](teksilo_settings::PersistedListModel)
109/// merge a peer process's concurrent archive write by row identity
110/// instead of by whole-document snapshot.
111impl Keyed for NotificationEntry {
112    type Key = u64;
113
114    fn key(&self) -> u64 {
115        self.id
116    }
117}
118
119/// One in-place mutation applied when a `Toast` with the same `id` as an
120/// existing entry is presented again. The archive merges these onto the
121/// existing row — the "Uploading 3 of 7 → Upload complete" pattern.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
123pub struct NotificationUpdate {
124    pub timestamp: jiff::Timestamp,
125    pub title: Option<String>,
126    pub body: Option<String>,
127    pub progress: Option<f32>,
128}
129
130/// Visual presentation of an archived action button. Maps one-to-one to
131/// `ToastActionStyle`; re-declared as a self-contained `Serialize`-friendly
132/// enum so the archive type does not depend on `ButtonVariant`.
133#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
134pub enum ArchivedActionStyle {
135    /// JetBrains-style hyperlink in the body row.
136    Link,
137    /// Filled (primary CTA).
138    PrimaryButton,
139    /// Plain (secondary).
140    SecondaryButton,
141    /// Destructive (red-tinted).
142    Destructive,
143}
144
145/// A single action stored alongside an archived notification entry. Only
146/// re-invokable from [`NotificationLog`] when `intent_name` is set — actions
147/// whose live closure has torn down render as inert descriptive labels.
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
149pub struct ArchivedAction {
150    /// Resolved label snapshot.
151    pub label: String,
152    /// Intent name for archive replay through `ctx.send_intent(...)`.
153    /// `None` for closure-only actions; the log renders these as
154    /// non-clickable past-action tags.
155    pub intent_name: Option<String>,
156    pub style: ArchivedActionStyle,
157    /// Mirrors the live action's `closes_toast` flag. The log uses
158    /// it informationally only — a replayed Intent fires and the
159    /// archive row itself doesn't dismiss (since it's not a live
160    /// toast).
161    pub closes_on_invoke: bool,
162}
163
164/// Whether an entry carrying `route` should be visible to a bell/log
165/// scoped to `scope`. `scope: None` means "unscoped" — the legacy
166/// "see everything" behaviour, so an existing single-window app that
167/// never calls `NotificationCenterButton::for_window` /
168/// `for_audience` (or the matching `NotificationLog` methods) keeps
169/// showing every entry exactly as before this feature existed.
170/// `Broadcast` entries are always visible regardless of `scope` —
171/// that's the entire point of a genuinely app-wide message.
172pub(crate) fn route_visible(route: ToastRoute, scope: Option<ToastRoute>) -> bool {
173    match scope {
174        None => true,
175        Some(scope) => route == scope || matches!(route, ToastRoute::Broadcast),
176    }
177}