1use jiff::Timestamp;
13use tablo_ui::{
14 ButtonSize, ButtonVariant, SheetSide, button, dialog_header, dialog_title, empty_state, icons,
15 sheet, sheet_content,
16};
17use topcoat::{
18 Result,
19 context::Cx,
20 icon::icon,
21 router::error::{not_found, unauthorized},
22 runtime::{Event, Signal, connected, procedure, shard, signal},
23 view::{BoxView, EmitToken, View, ViewExt, attributes, emit, live, view},
24};
25use uuid::Uuid;
26
27use super::{StoredNotification, stored::Inbox};
28use crate::{
29 auth::current_realm,
30 bus::{Subscription, bus},
31 t,
32};
33
34const PAGE: usize = 20;
36
37const MAX_SHOWN: usize = 200;
39
40const SHEET_ID: &str = "tablo-notifications";
42
43#[shard("/_topcoat/runtime/shards/tablo-notifications")]
48pub async fn notification_bell(cx: &Cx) -> Result<impl View> {
49 render_bell(cx).await
50}
51
52async fn render_bell(cx: &Cx) -> Result<BoxView<'_>> {
53 crate::auth::guard(cx)?;
55 let Some(inbox) = bell_inbox(cx) else {
56 return Ok(().boxed());
57 };
58 let open = signal(cx, || false);
59 let shown = signal(cx, || PAGE);
60 let limit = shown.get().clamp(PAGE, MAX_SHOWN);
62 let badge_wakes = bus(cx).subscribe(&inbox.panel, &inbox.user_id);
65 let list_wakes = bus(cx).subscribe(&inbox.panel, &inbox.user_id);
66 let snapshot = if limit == PAGE {
69 first_page(cx).await.clone()
70 } else {
71 Snapshot::read(cx, &inbox, limit).await.ok()
72 };
73 let Some(snapshot) = snapshot else {
75 return Ok(().boxed());
76 };
77 Ok(view! {
78 <div class="relative">
79 button(
80 variant: ButtonVariant::Ghost,
81 size: ButtonSize::Icon,
82 attrs: attributes! {
83 type="button"
84 aria-label=(t!(cx, "tablo-notifications"))
85 aria-haspopup="dialog"
86 aria-controls=(SHEET_ID)
87 @click=$(|_e: Event| open.set(true))
88 },
89 icon(data: icons::BELL)
90 )
91 (unread_badge(cx, inbox.clone(), badge_wakes, snapshot.clone()))
92 </div>
93 sheet(
94 open: $(open.get()),
95 attrs: attributes! {
96 id=(SHEET_ID)
97 aria-label=(t!(cx, "tablo-notifications"))
98 @keydown=$(|e: Event| {
99 if e.key == "Escape" {
100 open.set(false);
101 }
102 })
103 @click=$(|e: Event| {
104 if e.target.id == SHEET_ID {
105 open.set(false);
106 }
107 })
108 },
109 sheet_content(
110 side: SheetSide::Right,
111 attrs: attributes! { class="gap-3" },
112 dialog_header(
113 attrs: attributes! { class="flex-row items-center justify-between gap-2" },
114 dialog_title((t!(cx, "tablo-notifications")))
115 button(
116 variant: ButtonVariant::Ghost,
117 size: ButtonSize::Icon,
118 attrs: attributes! {
119 type="button"
120 aria-label=(t!(cx, "tablo-notifications-close"))
121 @click=$(|_e: Event| open.set(false))
122 },
123 icon(data: icons::X)
124 )
125 )
126 (notification_list(cx, inbox, list_wakes, snapshot, limit, shown))
127 )
128 )
129 }
130 .boxed())
131}
132
133pub(crate) fn topbar_bell(cx: &Cx) -> BoxView<'_> {
135 if bell_inbox(cx).is_none() {
136 return ().boxed();
137 }
138 view! { notification_bell() }.boxed()
139}
140
141fn bell_inbox(cx: &Cx) -> Option<Inbox> {
143 current_realm(cx).filter(|realm| realm.notifications)?;
144 Inbox::of(cx)
145}
146
147#[derive(Clone)]
149struct Snapshot {
150 unread: u64,
151 rows: Vec<StoredNotification>,
153 more: bool,
155}
156
157impl Snapshot {
158 async fn read(cx: &Cx, inbox: &Inbox, limit: usize) -> Result<Self> {
159 let db = crate::db::db(cx)?;
160 let mut rows = inbox.latest(&db, limit + 1).await?;
162 let more = rows.len() > limit && limit < MAX_SHOWN;
163 rows.truncate(limit);
164 Ok(Self {
165 unread: inbox.unread(&db).await?,
166 rows,
167 more,
168 })
169 }
170}
171
172#[topcoat::context::memoize]
176async fn first_page(cx: &Cx) -> Option<Snapshot> {
177 let inbox = bell_inbox(cx)?;
178 Snapshot::read(cx, &inbox, PAGE).await.ok()
179}
180
181pub(crate) async fn prefetch(cx: &Cx) {
183 let _ = first_page(cx).await;
184}
185
186async fn next_change(cx: &Cx, wakes: &mut Subscription) -> Result<bool> {
189 Ok(wakes.changed().await && crate::auth::still_signed_in(cx).await?)
190}
191
192fn unread_badge(cx: &Cx, inbox: Inbox, mut wakes: Subscription, initial: Snapshot) -> BoxView<'_> {
195 live! {
196 let db = crate::db::db(cx)?;
197 let mut seen = initial.rows.first().map(|row| row.created_at);
200 let mut unread = initial.unread;
201 let mut arrival: Option<StoredNotification> = None;
202 loop {
203 let badge = badge_view(cx, unread, arrival.clone()).await?;
204 let token = emit! { (badge) }?;
205 if !connected(cx) || !next_change(cx, &mut wakes).await? {
206 break Ok::<EmitToken, topcoat::Error>(token);
207 }
208 unread = inbox.unread(&db).await?;
209 let newest = inbox.latest(&db, 1).await?.pop();
210 if let Some(newest) = newest.filter(|row| Some(row.created_at) > seen) {
211 seen = Some(newest.created_at);
212 if newest.read_at.is_none() {
214 arrival = Some(newest);
215 }
216 }
217 }
218 }
219 .boxed()
220}
221
222async fn badge_view(
223 cx: &Cx,
224 unread: u64,
225 arrival: Option<StoredNotification>,
226) -> Result<BoxView<'_>> {
227 let label = match unread {
228 0 => String::new(),
229 1..=99 => unread.to_string(),
230 _ => "99+".to_owned(),
231 };
232 let toast = match arrival {
233 Some(row) => {
234 let id = format!("tablo-arrival-{}", row.id);
235 super::render_notification(cx, row.notification.0, attributes! { id=(id) }).await?
236 }
237 None => ().boxed(),
238 };
239 Ok(view! {
240 if unread > 0 {
241 <span
242 class="pointer-events-none absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground tabular-nums"
243 data-unread=(unread.to_string())
244 >
245 <span aria-hidden="true">(label)</span>
246 <span class="sr-only">
247 (t!(cx, "tablo-notifications-unread", count = unread))
248 </span>
249 </span>
250 }
251 tablo_ui::toaster(label: t!(cx, "tablo-notifications"), (toast))
252 }
253 .boxed())
254}
255
256fn notification_list(
258 cx: &Cx,
259 inbox: Inbox,
260 mut wakes: Subscription,
261 initial: Snapshot,
262 limit: usize,
263 shown: Signal<usize>,
264) -> BoxView<'_> {
265 live! {
266 let mut snapshot = initial;
267 loop {
268 let Snapshot { unread, rows, more, .. } = snapshot;
269 let list = list_view(cx, rows, unread, more, shown.clone()).await?;
270 let token = emit! { (list) }?;
271 if !connected(cx) || !next_change(cx, &mut wakes).await? {
272 break Ok::<EmitToken, topcoat::Error>(token);
273 }
274 snapshot = Snapshot::read(cx, &inbox, limit).await?;
275 }
276 }
277 .boxed()
278}
279
280async fn list_view(
281 cx: &Cx,
282 rows: Vec<StoredNotification>,
283 unread: u64,
284 more: bool,
285 shown: Signal<usize>,
286) -> Result<BoxView<'_>> {
287 if rows.is_empty() {
288 return Ok(view! {
289 empty_state(
290 title: t!(cx, "tablo-notifications-none"),
291 detail: t!(cx, "tablo-notifications-caught-up")
292 )
293 }
294 .boxed());
295 }
296 let now = Timestamp::now();
297 let mut items = Vec::with_capacity(rows.len());
298 for row in rows {
299 items.push(item_view(cx, row, now).await?);
300 }
301 Ok(view! {
302 <div class="flex items-center justify-between gap-2 text-sm">
303 <span class="text-muted-foreground" data-unread-count=(unread.to_string())>
304 (t!(cx, "tablo-notifications-unread", count = unread))
305 </span>
306 <div class="flex gap-1">
307 if unread > 0 {
308 button(
309 variant: ButtonVariant::Ghost,
310 size: ButtonSize::Sm,
311 attrs: attributes! {
312 type="button"
313 data-notifications="read-all"
314 @click=$(async |_e: Event| {
315 mark_all_notifications_read().await;
316 })
317 },
318 icon(data: icons::CHECK_CHECK)
319 (t!(cx, "tablo-notifications-read-all"))
320 )
321 }
322 button(
323 variant: ButtonVariant::Ghost,
324 size: ButtonSize::Sm,
325 attrs: attributes! {
326 type="button"
327 data-notifications="clear"
328 @click=$(async |_e: Event| {
329 clear_notifications().await;
330 })
331 },
332 icon(data: icons::TRASH)
333 (t!(cx, "tablo-notifications-clear"))
334 )
335 </div>
336 </div>
337 <ul class="flex flex-col gap-2" data-notifications="list">
338 for item in items {
339 (item)
340 }
341 </ul>
342 if more {
343 button(
344 variant: ButtonVariant::Outline,
345 size: ButtonSize::Sm,
346 attrs: attributes! {
347 type="button"
348 class="self-center"
349 @click=$(|_e: Event| shown.set(shown.get() + PAGE))
350 },
351 (t!(cx, "tablo-notifications-more"))
352 )
353 }
354 }
355 .boxed())
356}
357
358async fn item_view(cx: &Cx, row: StoredNotification, now: Timestamp) -> Result<BoxView<'_>> {
359 let id = row.id.to_string();
360 let read = row.read_at.is_some();
361 let when = ago(cx, row.created_at, now);
362 let stamp = row.created_at.to_string();
363 let super::Notification {
364 status,
365 title,
366 description,
367 actions,
368 } = row.notification.0;
369 let (icon_data, icon_class) = super::status_icon(&status);
370 let links: Vec<(String, String)> = actions
371 .iter()
372 .filter_map(|action| Some((action.label.clone(), action.safe_url()?.to_owned())))
373 .collect();
374 let surface = if read {
375 "border-border bg-background"
376 } else {
377 "border-primary/30 bg-muted/50"
378 };
379 let toggle_label = if read {
380 t!(cx, "tablo-notification-unread")
381 } else {
382 t!(cx, "tablo-notification-read")
383 };
384 let remove_label = t!(cx, "tablo-notification-remove");
385 let toggle_icon = if read { icons::MAIL } else { icons::CHECK };
386 let toggle_id = id.clone();
387 let remove_id = id.clone();
388 Ok(view! {
389 <li
390 id=(format!("tablo-notification-{id}"))
391 class=(format!("flex gap-3 rounded-lg border p-3 text-sm {surface}"))
392 data-notification=(id)
393 data-read=(if read { "true" } else { "false" })
394 data-type=(status.as_str())
395 >
396 <div class="mt-0.5 shrink-0" aria-hidden="true">
397 icon(data: icon_data, attrs: attributes! { class=(icon_class) })
398 </div>
399 <div class="flex min-w-0 flex-1 flex-col gap-1">
400 <p class="font-medium leading-snug [overflow-wrap:anywhere]">(title)</p>
401 if let Some(description) = description {
402 <p
403 class="leading-snug text-muted-foreground [overflow-wrap:anywhere]"
404 >
405 (description)
406 </p>
407 }
408 <time class="text-xs text-muted-foreground" datetime=(stamp)>
409 (when)
410 </time>
411 if !links.is_empty() {
412 <div class="flex flex-wrap gap-2 pt-1">
413 for (label, url) in links {
414 <a
415 href=(url)
416 class="text-sm font-medium text-primary underline-offset-4 hover:underline"
417 >
418 (label)
419 </a>
420 }
421 </div>
422 }
423 </div>
424 <div class="flex shrink-0 flex-col gap-1">
425 button(
426 variant: ButtonVariant::Ghost,
427 size: ButtonSize::Icon,
428 attrs: attributes! {
429 type="button"
430 class="size-7"
431 aria-label=(toggle_label.clone())
432 title=(toggle_label)
433 @click=$(async |_e: Event| {
434 mark_notification(toggle_id, !read).await;
435 })
436 },
437 icon(data: toggle_icon)
438 )
439 button(
440 variant: ButtonVariant::Ghost,
441 size: ButtonSize::Icon,
442 attrs: attributes! {
443 type="button"
444 class="size-7"
445 aria-label=(remove_label.clone())
446 title=(remove_label)
447 @click=$(async |_e: Event| {
448 remove_notification(remove_id).await;
449 })
450 },
451 icon(data: icons::X)
452 )
453 </div>
454 </li>
455 }
456 .boxed())
457}
458
459pub(crate) fn ago(cx: &Cx, then: Timestamp, now: Timestamp) -> String {
462 let seconds = now.duration_since(then).as_secs();
463 match seconds {
464 ..60 => t!(cx, "tablo-ago-now"),
465 60..3_600 => t!(cx, "tablo-ago-minutes", count = seconds / 60),
466 3_600..86_400 => t!(cx, "tablo-ago-hours", count = seconds / 3_600),
467 86_400..604_800 => t!(cx, "tablo-ago-days", count = seconds / 86_400),
468 _ => then
469 .to_zoned(crate::time_zone(cx))
470 .strftime("%Y-%m-%d")
471 .to_string(),
472 }
473}
474
475fn procedure_inbox(cx: &Cx) -> Result<Inbox> {
478 crate::auth::guard(cx)?;
479 let inbox = Inbox::of(cx).ok_or_else(unauthorized)?;
480 current_realm(cx)
481 .filter(|realm| realm.notifications)
482 .ok_or_else(not_found)?;
483 Ok(inbox)
484}
485
486fn changed(cx: &Cx, inbox: &Inbox) {
488 bus(cx).wake(&inbox.panel, &inbox.user_id);
489}
490
491fn parse_id(id: &str) -> Result<Uuid> {
493 Uuid::parse_str(id).map_err(|_| not_found().into())
494}
495
496#[procedure("/_topcoat/runtime/procedures/tablo-notifications/mark")]
499pub async fn mark_notification(cx: &Cx, id: String, read: bool) -> Result<()> {
500 let inbox = procedure_inbox(cx)?;
501 let id = parse_id(&id)?;
502 inbox.mark(&crate::db::db(cx)?, id, read).await?;
503 changed(cx, &inbox);
504 Ok(())
505}
506
507#[procedure("/_topcoat/runtime/procedures/tablo-notifications/remove")]
510pub async fn remove_notification(cx: &Cx, id: String) -> Result<()> {
511 let inbox = procedure_inbox(cx)?;
512 let id = parse_id(&id)?;
513 inbox.remove(&crate::db::db(cx)?, id).await?;
514 changed(cx, &inbox);
515 Ok(())
516}
517
518#[procedure("/_topcoat/runtime/procedures/tablo-notifications/read-all")]
520pub async fn mark_all_notifications_read(cx: &Cx) -> Result<()> {
521 let inbox = procedure_inbox(cx)?;
522 inbox.mark_all_read(&crate::db::db(cx)?).await?;
523 changed(cx, &inbox);
524 Ok(())
525}
526
527#[procedure("/_topcoat/runtime/procedures/tablo-notifications/clear")]
529pub async fn clear_notifications(cx: &Cx) -> Result<()> {
530 let inbox = procedure_inbox(cx)?;
531 inbox.clear(&crate::db::db(cx)?).await?;
532 changed(cx, &inbox);
533 Ok(())
534}