Skip to main content

tablo_core/notification/
bell.rs

1//! The bell a panel with [`Panel::notifications`](crate::Panel::notifications) shows in its
2//! topbar: an unread count, a sheet listing the signed-in user's stored notifications, and the
3//! procedures that mark and remove them.
4//!
5//! The shard asks for a connection, so once the page loads its two live regions keep updating
6//! over Topcoat's runtime socket: each waits for a wake on the [`NotificationBus`], checks the
7//! session again, and reads back through the [`Inbox`]. A tab with no socket shows what the page
8//! loaded with.
9//!
10//! [`NotificationBus`]: crate::bus::NotificationBus
11
12use 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
34/// How many notifications the sheet lists at first, and how many more each "Show more" adds.
35const PAGE: usize = 20;
36
37/// The most the sheet lists, however often "Show more" is pressed.
38const MAX_SHOWN: usize = 200;
39
40/// The id of the sheet's `<dialog>`, which the bell's trigger controls.
41const SHEET_ID: &str = "tablo-notifications";
42
43/// The bell: its trigger with the unread count, and the sheet it opens.
44///
45/// A named path stays stable across builds (topcoat#441), and staying under `/_topcoat/runtime`
46/// keeps the panel's auth gate covering it.
47#[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    // The shard restates the panel gate: Topcoat serves it at its runtime path.
54    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    // A tracked read: "Show more" renders the shard again, with more rows.
61    let limit = shown.get().clamp(PAGE, MAX_SHOWN);
62    // Subscribed before the read, so a send between the read and a region's first wait still
63    // wakes it.
64    let badge_wakes = bus(cx).subscribe(&inbox.panel, &inbox.user_id);
65    let list_wakes = bus(cx).subscribe(&inbox.panel, &inbox.user_id);
66    // The layout read the first page before the page's body started streaming; a region's
67    // first emission then needs no connection, which that body may still hold.
68    let snapshot = if limit == PAGE {
69        first_page(cx).await.clone()
70    } else {
71        Snapshot::read(cx, &inbox, limit).await.ok()
72    };
73    // The read logged its failure: the page renders without its bell rather than failing.
74    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
133/// The bell at the end of the topbar, when the request's panel shows one to a signed-in user.
134pub(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
141/// The signed-in user's inbox when the request's panel shows a bell.
142fn bell_inbox(cx: &Cx) -> Option<Inbox> {
143    current_realm(cx).filter(|realm| realm.notifications)?;
144    Inbox::of(cx)
145}
146
147/// What the bell shows of an inbox at one moment.
148#[derive(Clone)]
149struct Snapshot {
150    unread: u64,
151    /// Up to the limit, newest first.
152    rows: Vec<StoredNotification>,
153    /// Whether rows past the limit remain for "Show more".
154    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        // One row past the limit says whether "Show more" has more to show.
161        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/// The bell's first page for the request, read once: the layout calls it before it renders, as it
173/// counts the sidebar's badges, and the shard reuses what it read. `None` without a bell, or when
174/// the read failed, which logs.
175#[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
181/// Reads the bell's first page ahead of the page's body, on a panel that shows a bell.
182pub(crate) async fn prefetch(cx: &Cx) {
183    let _ = first_page(cx).await;
184}
185
186/// Waits for the next change to `inbox` and checks the session again; `false` once the
187/// connected render must stop, because the session ended or the bus is gone.
188async fn next_change(cx: &Cx, wakes: &mut Subscription) -> Result<bool> {
189    Ok(wakes.changed().await && crate::auth::still_signed_in(cx).await?)
190}
191
192/// The unread count over the trigger, and a toast for each notification that arrives while the
193/// page is open.
194fn unread_badge(cx: &Cx, inbox: Inbox, mut wakes: Subscription, initial: Snapshot) -> BoxView<'_> {
195    live! {
196        let db = crate::db::db(cx)?;
197        // When the newest row the page already showed was sent: only a later one toasts, so
198        // marking an older one unread does not.
199        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                // One already read elsewhere arrived, but has nothing to say.
213                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
256/// The sheet's body: the bulk controls, then up to `limit` notifications, newest first.
257fn 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
459/// How long ago `then` was, as the sheet prints it: "just now", "5 min ago", "3 h ago", then the
460/// date in the request's time zone, as `2026-10-23` reads in every locale.
461pub(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
475/// The signed-in user's inbox for a procedure, answering 401 without one and 404 where the
476/// request's panel shows no bell.
477fn 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
486/// Wakes the user's bells, this one included, so each reads the change back.
487fn changed(cx: &Cx, inbox: &Inbox) {
488    bus(cx).wake(&inbox.panel, &inbox.user_id);
489}
490
491/// Parses the id a browser sent; one that is no id names no notification.
492fn parse_id(id: &str) -> Result<Uuid> {
493    Uuid::parse_str(id).map_err(|_| not_found().into())
494}
495
496/// Marks one of the user's notifications read, or unread with `read` false. An id the user's
497/// inbox does not hold changes nothing.
498#[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/// Removes one of the user's notifications. An id the user's inbox does not hold changes
508/// nothing.
509#[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/// Marks every notification the bell lists read.
519#[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/// Removes every notification the bell lists.
528#[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}