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 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
134pub(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
142fn bell_inbox(cx: &Cx) -> Option<Inbox> {
144 current_realm(cx).filter(|realm| realm.notifications)?;
145 Inbox::of(cx)
146}
147
148#[derive(Clone)]
150struct Snapshot {
151 unread: u64,
152 rows: Vec<StoredNotification>,
154 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 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#[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
182pub(crate) async fn prefetch(cx: &Cx) {
184 let _ = first_page(cx).await;
185}
186
187async fn next_change(cx: &Cx, wakes: &mut Subscription) -> Result<bool> {
190 Ok(wakes.changed().await && crate::auth::still_signed_in(cx).await?)
191}
192
193fn unread_badge(cx: &Cx, inbox: Inbox, mut wakes: Subscription, initial: Snapshot) -> BoxView<'_> {
196 live! {
197 cx =>
198 let db = crate::db::db(cx)?;
199 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 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
260fn 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
467pub(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
483fn 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
494fn changed(cx: &Cx, inbox: &Inbox) {
496 bus(cx).wake(&inbox.panel, &inbox.user_id);
497}
498
499fn parse_id(id: &str) -> Result<Uuid> {
501 Uuid::parse_str(id).map_err(|_| not_found().into())
502}
503
504#[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#[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#[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#[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}