import type { Session } from './api'; import { Attention, type AlertKind, type Mail } from './attention'; import { boards } from './boards.svelte'; import { layout } from './layout.svelte'; const KEY = 'albedo.notifications'; class Notifications { enabled = $state(localStorage.getItem(KEY) === 'on'); supported = typeof Notification !== 'undefined'; permission = $state(this.supported ? Notification.permission : 'denied'); requesting = $state(false); error = $state(''); #attention = new Attention(); #open = new Map(); #title = document.title; #count = -1; #icon = document.querySelector('link[rel="icon"]'); #originalIcon = this.#icon?.href ?? ''; start() { const clear = () => { if (document.hidden) return; this.#attention.clear(); for (const notification of this.#open.values()) notification.close(); this.#open.clear(); this.#render(); if (this.supported) this.permission = Notification.permission; }; window.addEventListener('focus', clear); document.addEventListener('visibilitychange', clear); } async toggle() { this.error = ''; if (this.enabled && this.permission === 'granted') { this.enabled = false; for (const notification of this.#open.values()) notification.close(); this.#open.clear(); } else if (this.supported) { this.requesting = true; try { this.permission = await Notification.requestPermission(); this.enabled = this.permission === 'granted'; } catch { this.error = 'the browser could not enable notifications. try again here.'; } finally { this.requesting = false; } } localStorage.setItem(KEY, this.enabled ? 'on' : 'off'); } watched(session: Session) { return !session.preferences.archived && (session.preferences.pinned || boards.hasSession(session.id) || (layout.phone && layout.session === session.id)); } changed(previous: Session | undefined, next: Session) { const kind = this.#attention.changed(previous, next, this.watched(next), document.hidden || !document.hasFocus()); if (kind) this.#notify(next, kind); this.#render(); } mail(mail: Mail, sessions: Session[]) { const session = sessions.find((item) => item.id === mail.receiver_session_id); const kind = this.#attention.mail(mail, !!session && this.watched(session), document.hidden || !document.hasFocus()); if (kind && session) this.#notify(session, kind); this.#render(); } prune(sessions: Session[]) { this.#attention.prune(new Set(sessions.filter((session) => this.watched(session)).map((session) => session.id))); this.#render(); } #render() { const count = this.#attention.unread.size; if (count === this.#count) return; this.#count = count; document.title = count ? `(${count}) ${this.#title}` : this.#title; if (this.#icon) this.#icon.href = count ? `data:image/svg+xml,${encodeURIComponent('')}` : this.#originalIcon; } #notify(session: Session, kind: AlertKind) { if (!document.hidden || !this.enabled || !this.supported || Notification.permission !== 'granted') return; try { const body = { finished: 'finished a turn', attention: 'needs your attention', mail: 'received agent mail' }[kind]; const notification = new Notification(session.name || 'albedo', { body, tag: `albedo:${session.id}` }); this.#open.set(session.id, notification); notification.onclose = () => this.#open.delete(session.id); notification.onclick = () => { window.focus(); boards.revealSession(session); notification.close(); }; } catch { this.error = 'this browser could not show a system notification. tab counts still work.'; } } } export const notifications = new Notifications();