egui_charts/ui/widget_bar/panels/
notifications.rs1use crate::localization as l10n;
4use std::collections::VecDeque;
5use std::sync::Arc;
6
7use egui::{RichText, Ui};
8
9use crate::data::inbox::Inbox;
10use crate::icons::icons;
11use crate::services::{Listener, Notification, NotificationTarget, Notifications, ProfileId};
12use crate::theme::ThemeExt;
13use crate::ui_kit::buttons::Button;
14use crate::ui_kit::{EmptyState, PanelHeader};
15
16const KEPT: usize = 1_000;
18
19#[derive(Clone, Debug, PartialEq, Default)]
21#[non_exhaustive]
22pub enum NotificationsAction {
23 #[default]
25 None,
26 SelectSymbol(String),
28 OpenProfile(ProfileId),
30 OpenRoom(String),
32}
33
34#[derive(Default)]
41pub struct NotificationsPanel {
42 service: Option<Arc<dyn Notifications>>,
43 inbox: Arc<Inbox>,
44 listener: Option<Listener>,
45 generation: u64,
46 log: VecDeque<Notification>,
47 unread: usize,
48}
49
50impl NotificationsPanel {
51 pub fn new() -> Self {
53 Self::default()
54 }
55
56 pub fn set_service(&mut self, service: Arc<dyn Notifications>) {
58 self.service = Some(service);
59 self.watch();
60 }
61
62 fn watch(&mut self) {
63 self.log.clear();
64 self.unread = 0;
65 self.inbox = Arc::default();
66 self.generation += 1;
67 let (listener, events) = Listener::new(&self.inbox, self.generation);
68 self.listener = Some(listener);
69 if let Some(service) = &self.service {
70 service.watch(events);
71 }
72 }
73
74 pub fn unread(&self) -> usize {
76 self.unread
77 }
78
79 pub fn push(&mut self, entry: Notification) {
81 self.log.push_back(entry);
82 if self.log.len() > KEPT {
83 self.log.pop_front();
84 }
85 self.unread = (self.unread + 1).min(self.log.len());
86 }
87
88 pub fn poll(&mut self, ctx: &egui::Context) -> Vec<Notification> {
91 if self.listener.as_ref().is_some_and(|l| !l.is_active()) {
92 self.watch();
93 }
94 let repaint = ctx.clone();
95 self.inbox.set_waker(move || repaint.request_repaint());
96 let mut received = Vec::new();
97 for (_, answer) in self.inbox.drain().answers {
98 if let Ok(entry) = answer.downcast::<Notification>() {
99 self.push((*entry).clone());
100 received.push(*entry);
101 }
102 }
103 received
104 }
105
106 pub fn show(&mut self, ui: &mut Ui) -> NotificationsAction {
108 let mut action = NotificationsAction::None;
109 let theme = ui.active_theme();
110 self.unread = 0;
111 PanelHeader::new("Notifications").show_plain(ui);
112 if self.log.is_empty() {
113 EmptyState::new("No notifications yet")
114 .icon(&icons::WIDGET_BAR_NOTIFICATIONS)
115 .fill_height()
116 .show(ui);
117 return action;
118 }
119 egui::ScrollArea::vertical()
120 .auto_shrink([false, false])
121 .show(ui, |ui| {
122 for entry in self.log.iter().rev() {
123 ui.scope_builder(egui::UiBuilder::new().sense(egui::Sense::hover()), |ui| {
124 let hovered = ui.response().hovered();
125 egui::Frame::new()
126 .fill(if hovered {
127 theme.ui.btn_bg_hover
128 } else {
129 egui::Color32::TRANSPARENT
130 })
131 .inner_margin(egui::Margin::symmetric(8, 0))
132 .show(ui, |ui| {
133 ui.set_width(ui.available_width());
134 ui.set_min_height(26.0);
135 ui.label(
136 RichText::new(l10n::text(
137 ui.ctx(),
138 entry.time.format("%b %-d, %H:%M:%S UTC").to_string(),
139 ))
140 .size(theme.text.small)
141 .color(theme.ui.text_secondary),
142 );
143 ui.label(
144 RichText::new(l10n::text(ui.ctx(), &entry.title)).strong(),
145 );
146 if !entry.body.is_empty() {
147 ui.label(l10n::text(ui.ctx(), &entry.body));
148 }
149 if let Some(symbol) = &entry.symbol
150 && Button::new(symbol).small().show(ui).clicked()
151 {
152 action = NotificationsAction::SelectSymbol(symbol.clone());
153 }
154 if let Some(target) = &entry.target {
155 let (label, open) = match target {
156 NotificationTarget::Profile(id) => (
157 "Open profile",
158 NotificationsAction::OpenProfile(id.clone()),
159 ),
160 NotificationTarget::Room(room) => (
161 "Open room",
162 NotificationsAction::OpenRoom(room.clone()),
163 ),
164 };
165 if Button::new(l10n::text(ui.ctx(), label))
166 .small()
167 .show(ui)
168 .clicked()
169 {
170 action = open;
171 }
172 }
173 });
174 });
175 ui.separator();
176 }
177 });
178 action
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::services::Events;
186 use std::sync::Mutex;
187
188 #[derive(Default)]
189 struct Host(Mutex<Option<Events<Notification>>>);
190
191 impl Notifications for Host {
192 fn watch(&self, events: Events<Notification>) {
193 *self.0.lock().unwrap() = Some(events);
194 }
195 }
196
197 #[test]
201 fn a_lagged_stream_starts_the_log_over() {
202 let host = Arc::new(Host::default());
203 let mut panel = NotificationsPanel::new();
204 panel.set_service(host.clone());
205 let ctx = egui::Context::default();
206 let entry =
207 |n: i64| Notification::new(chrono::DateTime::from_timestamp(n, 0).unwrap(), "x");
208 let events = host.0.lock().unwrap().clone().unwrap();
209 events.push(entry(1));
210 panel.poll(&ctx);
211 assert_eq!((panel.log.len(), panel.unread()), (1, 1));
212 for n in 0..=10_000 {
213 events.push(entry(n));
214 }
215 assert!(!events.is_active());
216 panel.poll(&ctx);
217 let renewed = host.0.lock().unwrap().clone().unwrap();
218 renewed.push(entry(1));
219 panel.poll(&ctx);
220 assert_eq!(panel.log, [entry(1)]);
221 assert_eq!(panel.unread(), 1);
222 }
223}