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        cx =>
79        <div class="relative">
80            button(
81                variant: ButtonVariant::Ghost,
82                size: ButtonSize::Icon,
83                attrs: attributes! {
84                    type="button"
85                    aria-label=(t!(cx, "tablo-notifications"))
86                    aria-haspopup="dialog"
87                    aria-controls=(SHEET_ID)
88                    @click=$(|_e: Event| open.set(true))
89                },
90                icon(data: icons::BELL)
91            )
92            (unread_badge(cx, inbox.clone(), badge_wakes, snapshot.clone()))
93        </div>
94        sheet(
95            open: $(open.get()),
96            attrs: attributes! {
97                id=(SHEET_ID)
98                aria-label=(t!(cx, "tablo-notifications"))
99                @keydown=$(|e: Event| {
100                    if e.key == "Escape" {
101                        open.set(false);
102                    }
103                })
104                @click=$(|e: Event| {
105                    if e.target.id == SHEET_ID {
106                        open.set(false);
107                    }
108                })
109            },
110            sheet_content(
111                side: SheetSide::Right,
112                attrs: attributes! { class="gap-3" },
113                dialog_header(
114                    attrs: attributes! { class="flex-row items-center justify-between gap-2" },
115                    dialog_title((t!(cx, "tablo-notifications")))
116                    button(
117                        variant: ButtonVariant::Ghost,
118                        size: ButtonSize::Icon,
119                        attrs: attributes! {
120                            type="button"
121                            aria-label=(t!(cx, "tablo-notifications-close"))
122                            @click=$(|_e: Event| open.set(false))
123                        },
124                        icon(data: icons::X)
125                    )
126                )
127                (notification_list(cx, inbox, list_wakes, snapshot, limit, shown))
128            )
129        )
130    }
131    .boxed())
132}
133
134/// The bell at the end of the topbar, when the request's panel shows one to a signed-in user.
135pub(crate) fn topbar_bell(cx: &Cx) -> BoxView<'_> {
136    if bell_inbox(cx).is_none() {
137        return ().boxed();
138    }
139    view! { cx => notification_bell() }.boxed()
140}
141
142/// The signed-in user's inbox when the request's panel shows a bell.
143fn bell_inbox(cx: &Cx) -> Option<Inbox> {
144    current_realm(cx).filter(|realm| realm.notifications)?;
145    Inbox::of(cx)
146}
147
148/// What the bell shows of an inbox at one moment.
149#[derive(Clone)]
150struct Snapshot {
151    unread: u64,
152    /// Up to the limit, newest first.
153    rows: Vec<StoredNotification>,
154    /// Whether rows past the limit remain for "Show more".
155    more: bool,
156}
157
158impl Snapshot {
159    async fn read(cx: &Cx, inbox: &Inbox, limit: usize) -> Result<Self> {
160        let db = crate::db::db(cx)?;
161        // One row past the limit says whether "Show more" has more to show.
162        let mut rows = inbox.latest(&db, limit + 1).await?;
163        let more = rows.len() > limit && limit < MAX_SHOWN;
164        rows.truncate(limit);
165        Ok(Self {
166            unread: inbox.unread(&db).await?,
167            rows,
168            more,
169        })
170    }
171}
172
173/// The bell's first page for the request, read once: the layout calls it before it renders, as it
174/// counts the sidebar's badges, and the shard reuses what it read. `None` without a bell, or when
175/// the read failed, which logs.
176#[topcoat::context::memoize]
177async fn first_page(cx: &Cx) -> Option<Snapshot> {
178    let inbox = bell_inbox(cx)?;
179    Snapshot::read(cx, &inbox, PAGE).await.ok()
180}
181
182/// Reads the bell's first page ahead of the page's body, on a panel that shows a bell.
183pub(crate) async fn prefetch(cx: &Cx) {
184    let _ = first_page(cx).await;
185}
186
187/// Waits for the next change to `inbox` and checks the session again; `false` once the
188/// connected render must stop, because the session ended or the bus is gone.
189async fn next_change(cx: &Cx, wakes: &mut Subscription) -> Result<bool> {
190    Ok(wakes.changed().await && crate::auth::still_signed_in(cx).await?)
191}
192
193/// The unread count over the trigger, and a toast for each notification that arrives while the
194/// page is open.
195fn unread_badge(cx: &Cx, inbox: Inbox, mut wakes: Subscription, initial: Snapshot) -> BoxView<'_> {
196    live! {
197        cx =>
198        let db = crate::db::db(cx)?;
199        // When the newest row the page already showed was sent: only a later one toasts, so
200        // marking an older one unread does not.
201        let mut seen = initial.rows.first().map(|row| row.created_at);
202        let mut unread = initial.unread;
203        let mut arrival: Option<StoredNotification> = None;
204        loop {
205            let badge = badge_view(cx, unread, arrival.clone()).await?;
206            let token = emit! { (badge) }?;
207            if !connected(cx) || !next_change(cx, &mut wakes).await? {
208                break Ok::<EmitToken, topcoat::Error>(token);
209            }
210            unread = inbox.unread(&db).await?;
211            let newest = inbox.latest(&db, 1).await?.pop();
212            if let Some(newest) = newest.filter(|row| Some(row.created_at) > seen) {
213                seen = Some(newest.created_at);
214                // One already read elsewhere arrived, but has nothing to say.
215                if newest.read_at.is_none() {
216                    arrival = Some(newest);
217                }
218            }
219        }
220    }
221    .boxed()
222}
223
224async fn badge_view(
225    cx: &Cx,
226    unread: u64,
227    arrival: Option<StoredNotification>,
228) -> Result<BoxView<'_>> {
229    let label = match unread {
230        0 => String::new(),
231        1..=99 => unread.to_string(),
232        _ => "99+".to_owned(),
233    };
234    let toast = match arrival {
235        Some(row) => {
236            let id = format!("tablo-arrival-{}", row.id);
237            super::render_notification(cx, row.notification.0, attributes! { cx => id=(id) })
238                .await?
239        }
240        None => ().boxed(),
241    };
242    Ok(view! {
243        cx =>
244        if unread > 0 {
245            <span
246                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"
247                data-unread=(unread.to_string())
248            >
249                <span aria-hidden="true">(label)</span>
250                <span class="sr-only">
251                    (t!(cx, "tablo-notifications-unread", count = unread))
252                </span>
253            </span>
254        }
255        tablo_ui::toaster(label: t!(cx, "tablo-notifications"), (toast))
256    }
257    .boxed())
258}
259
260/// The sheet's body: the bulk controls, then up to `limit` notifications, newest first.
261fn notification_list(
262    cx: &Cx,
263    inbox: Inbox,
264    mut wakes: Subscription,
265    initial: Snapshot,
266    limit: usize,
267    shown: Signal<usize>,
268) -> BoxView<'_> {
269    live! {
270        cx =>
271        let mut snapshot = initial;
272        loop {
273            let Snapshot { unread, rows, more, .. } = snapshot;
274            let list = list_view(cx, rows, unread, more, shown.clone()).await?;
275            let token = emit! { (list) }?;
276            if !connected(cx) || !next_change(cx, &mut wakes).await? {
277                break Ok::<EmitToken, topcoat::Error>(token);
278            }
279            snapshot = Snapshot::read(cx, &inbox, limit).await?;
280        }
281    }
282    .boxed()
283}
284
285async fn list_view(
286    cx: &Cx,
287    rows: Vec<StoredNotification>,
288    unread: u64,
289    more: bool,
290    shown: Signal<usize>,
291) -> Result<BoxView<'_>> {
292    if rows.is_empty() {
293        return Ok(view! {
294            cx =>
295            empty_state(
296                title: t!(cx, "tablo-notifications-none"),
297                detail: t!(cx, "tablo-notifications-caught-up")
298            )
299        }
300        .boxed());
301    }
302    let now = Timestamp::now();
303    let mut items = Vec::with_capacity(rows.len());
304    for row in rows {
305        items.push(item_view(cx, row, now).await?);
306    }
307    Ok(view! {
308        cx =>
309        <div class="flex items-center justify-between gap-2 text-sm">
310            <span class="text-muted-foreground" data-unread-count=(unread.to_string())>
311                (t!(cx, "tablo-notifications-unread", count = unread))
312            </span>
313            <div class="flex gap-1">
314                if unread > 0 {
315                    button(
316                        variant: ButtonVariant::Ghost,
317                        size: ButtonSize::Sm,
318                        attrs: attributes! {
319                            type="button"
320                            data-notifications="read-all"
321                            @click=$(async |_e: Event| {
322                                mark_all_notifications_read().await;
323                            })
324                        },
325                        icon(data: icons::CHECK_CHECK)
326                        (t!(cx, "tablo-notifications-read-all"))
327                    )
328                }
329                button(
330                    variant: ButtonVariant::Ghost,
331                    size: ButtonSize::Sm,
332                    attrs: attributes! {
333                        type="button"
334                        data-notifications="clear"
335                        @click=$(async |_e: Event| {
336                            clear_notifications().await;
337                        })
338                    },
339                    icon(data: icons::TRASH)
340                    (t!(cx, "tablo-notifications-clear"))
341                )
342            </div>
343        </div>
344        <ul class="flex flex-col gap-2" data-notifications="list">
345            for item in items {
346                (item)
347            }
348        </ul>
349        if more {
350            button(
351                variant: ButtonVariant::Outline,
352                size: ButtonSize::Sm,
353                attrs: attributes! {
354                    type="button"
355                    class="self-center"
356                    @click=$(|_e: Event| shown.set(shown.get() + PAGE))
357                },
358                (t!(cx, "tablo-notifications-more"))
359            )
360        }
361    }
362    .boxed())
363}
364
365async fn item_view(cx: &Cx, row: StoredNotification, now: Timestamp) -> Result<BoxView<'_>> {
366    let id = row.id.to_string();
367    let read = row.read_at.is_some();
368    let when = ago(cx, row.created_at, now);
369    let stamp = row.created_at.to_string();
370    let super::Notification {
371        status,
372        title,
373        description,
374        actions,
375    } = row.notification.0;
376    let (icon_data, icon_class) = super::status_icon(&status);
377    let links: Vec<(String, String)> = actions
378        .iter()
379        .filter_map(|action| Some((action.label.clone(), action.safe_url()?.to_owned())))
380        .collect();
381    let surface = if read {
382        "border-border bg-background"
383    } else {
384        "border-primary/30 bg-muted/50"
385    };
386    let toggle_label = if read {
387        t!(cx, "tablo-notification-unread")
388    } else {
389        t!(cx, "tablo-notification-read")
390    };
391    let remove_label = t!(cx, "tablo-notification-remove");
392    let toggle_icon = if read { icons::MAIL } else { icons::CHECK };
393    let toggle_id = id.clone();
394    let remove_id = id.clone();
395    Ok(view! {
396        cx =>
397        <li
398            id=(format!("tablo-notification-{id}"))
399            class=(format!("flex gap-3 rounded-lg border p-3 text-sm {surface}"))
400            data-notification=(id)
401            data-read=(if read { "true" } else { "false" })
402            data-type=(status.as_str())
403        >
404            <div class="mt-0.5 shrink-0" aria-hidden="true">
405                icon(data: icon_data, attrs: attributes! { class=(icon_class) })
406            </div>
407            <div class="flex min-w-0 flex-1 flex-col gap-1">
408                <p class="font-medium leading-snug [overflow-wrap:anywhere]">(title)</p>
409                if let Some(description) = description {
410                    <p
411                        class="leading-snug text-muted-foreground [overflow-wrap:anywhere]"
412                    >
413                        (description)
414                    </p>
415                }
416                <time class="text-xs text-muted-foreground" datetime=(stamp)>
417                    (when)
418                </time>
419                if !links.is_empty() {
420                    <div class="flex flex-wrap gap-2 pt-1">
421                        for (label, url) in links {
422                            <a
423                                href=(url)
424                                class="text-sm font-medium text-primary underline-offset-4 hover:underline"
425                            >
426                                (label)
427                            </a>
428                        }
429                    </div>
430                }
431            </div>
432            <div class="flex shrink-0 flex-col gap-1">
433                button(
434                    variant: ButtonVariant::Ghost,
435                    size: ButtonSize::Icon,
436                    attrs: attributes! {
437                        type="button"
438                        class="size-7"
439                        aria-label=(toggle_label.clone())
440                        title=(toggle_label)
441                        @click=$(async |_e: Event| {
442                            mark_notification(toggle_id, !read).await;
443                        })
444                    },
445                    icon(data: toggle_icon)
446                )
447                button(
448                    variant: ButtonVariant::Ghost,
449                    size: ButtonSize::Icon,
450                    attrs: attributes! {
451                        type="button"
452                        class="size-7"
453                        aria-label=(remove_label.clone())
454                        title=(remove_label)
455                        @click=$(async |_e: Event| {
456                            remove_notification(remove_id).await;
457                        })
458                    },
459                    icon(data: icons::X)
460                )
461            </div>
462        </li>
463    }
464    .boxed())
465}
466
467/// How long ago `then` was, as the sheet prints it: "just now", "5 min ago", "3 h ago", then the
468/// date in the request's time zone, as `2026-10-23` reads in every locale.
469pub(crate) fn ago(cx: &Cx, then: Timestamp, now: Timestamp) -> String {
470    let seconds = now.duration_since(then).as_secs();
471    match seconds {
472        ..60 => t!(cx, "tablo-ago-now"),
473        60..3_600 => t!(cx, "tablo-ago-minutes", count = seconds / 60),
474        3_600..86_400 => t!(cx, "tablo-ago-hours", count = seconds / 3_600),
475        86_400..604_800 => t!(cx, "tablo-ago-days", count = seconds / 86_400),
476        _ => then
477            .to_zoned(crate::time_zone(cx))
478            .strftime("%Y-%m-%d")
479            .to_string(),
480    }
481}
482
483/// The signed-in user's inbox for a procedure, answering 401 without one and 404 where the
484/// request's panel shows no bell.
485fn procedure_inbox(cx: &Cx) -> Result<Inbox> {
486    crate::auth::guard(cx)?;
487    let inbox = Inbox::of(cx).ok_or_else(unauthorized)?;
488    current_realm(cx)
489        .filter(|realm| realm.notifications)
490        .ok_or_else(not_found)?;
491    Ok(inbox)
492}
493
494/// Wakes the user's bells, this one included, so each reads the change back.
495fn changed(cx: &Cx, inbox: &Inbox) {
496    bus(cx).wake(&inbox.panel, &inbox.user_id);
497}
498
499/// Parses the id a browser sent; one that is no id names no notification.
500fn parse_id(id: &str) -> Result<Uuid> {
501    Uuid::parse_str(id).map_err(|_| not_found().into())
502}
503
504/// Marks one of the user's notifications read, or unread with `read` false. An id the user's
505/// inbox does not hold changes nothing.
506#[procedure("/_topcoat/runtime/procedures/tablo-notifications/mark")]
507pub async fn mark_notification(cx: &Cx, id: String, read: bool) -> Result<()> {
508    let inbox = procedure_inbox(cx)?;
509    let id = parse_id(&id)?;
510    inbox.mark(&crate::db::db(cx)?, id, read).await?;
511    changed(cx, &inbox);
512    Ok(())
513}
514
515/// Removes one of the user's notifications. An id the user's inbox does not hold changes
516/// nothing.
517#[procedure("/_topcoat/runtime/procedures/tablo-notifications/remove")]
518pub async fn remove_notification(cx: &Cx, id: String) -> Result<()> {
519    let inbox = procedure_inbox(cx)?;
520    let id = parse_id(&id)?;
521    inbox.remove(&crate::db::db(cx)?, id).await?;
522    changed(cx, &inbox);
523    Ok(())
524}
525
526/// Marks every notification the bell lists read.
527#[procedure("/_topcoat/runtime/procedures/tablo-notifications/read-all")]
528pub async fn mark_all_notifications_read(cx: &Cx) -> Result<()> {
529    let inbox = procedure_inbox(cx)?;
530    inbox.mark_all_read(&crate::db::db(cx)?).await?;
531    changed(cx, &inbox);
532    Ok(())
533}
534
535/// Removes every notification the bell lists.
536#[procedure("/_topcoat/runtime/procedures/tablo-notifications/clear")]
537pub async fn clear_notifications(cx: &Cx) -> Result<()> {
538    let inbox = procedure_inbox(cx)?;
539    inbox.clear(&crate::db::db(cx)?).await?;
540    changed(cx, &inbox);
541    Ok(())
542}