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}