import GLib from "gi://GLib" import { App, Astal, Gdk, Gtk } from "astal/gtk3" import Notifd from "gi://AstalNotifd" import { Variable, bind } from "astal" import { MAX_TIMEOUT } from "../notif-config" const notifd = Notifd.get_default() export const dndIcon = Variable.derive([bind(notifd, "dontDisturb")], (dnd) => dnd ? "notifications-disabled-symbolic" : "preferences-system-notifications-symbolic", ) // every notification received this session is kept until it is explicitly // cleared, so popups that time out remain in the dropdown's list. only // notifications dismissed directly from their popup skip the list, and // transient status toasts never enter it at all. export const history = Variable([]) export const notifCount = bind(history).as((ns) => ns.length) // apps whose notifications are transient status updates: they surface as // toasts only and never enter the dropdown history or the bar count const EPHEMERAL_APPS = new Set(["dictation"]) // irregular ids the generic capitalization rule can't fix up const FRIENDLY_NAMES: Record = { "notify-send": "Notification", } // per-app icons for notifications that don't supply one const APP_ICONS: Record = { dictation: "audio-input-microphone-symbolic", } // apps like discord report a bare binary id; make words of those while // names with any uppercase ("Spotify") pass through untouched function displayName(n: Notifd.Notification) { const raw = n.app_name || "Notification" const mapped = FRIENDLY_NAMES[raw.toLowerCase()] if (mapped) return mapped if (raw === raw.toLowerCase() && /[a-z]/.test(raw)) { return raw .split(/[-_ ]+/) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" ") } return raw } export function DndToggle() { return ( ) } // popups are timed out entirely client-side: the daemon never resolves them // on its own, so a popup under the pointer can be kept alive indefinitely notifd.ignore_timeout = true notifd.default_timeout = -1 // notifications restored from the daemon's cache on startup are stale for (const n of notifd.notifications) { n.dismiss() } notifd.connect("notified", (_, id: number) => { const n = notifd.get_notification(id) if (!n) return if (EPHEMERAL_APPS.has(n.app_name.toLowerCase())) return // re-notified ids update the existing entry instead of adding a duplicate if (!history.get().some((m) => m.id === id)) { history.set([...history.get(), n]) } }) // ids of notifications whose popup is currently under the pointer const hovered = new Set() // enforce our own cap for popups, honoring shorter app-requested timeouts. // critical notifications are exempt and stay until dismissed, as are popups // being hovered. GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { const now = GLib.DateTime.new_now_local().to_unix() for (const n of [...notifd.notifications]) { if (n.urgency === Notifd.Urgency.CRITICAL || hovered.has(n.id)) continue const timeout = n.expire_timeout > 0 ? Math.min(n.expire_timeout, MAX_TIMEOUT) : MAX_TIMEOUT if (now - n.time >= timeout / 1000) n.dismiss() } return GLib.SOURCE_CONTINUE }) // drop a notification from the list and withdraw its popup; used when a // notification is dismissed directly instead of timing out export function dismissNotification(n: Notifd.Notification) { hovered.delete(n.id) history.set(history.get().filter((m) => m.id !== n.id)) notifd.get_notification(n.id)?.dismiss() } export function clearNotifications() { for (const n of [...notifd.notifications]) { dismissNotification(n) } history.set([]) } function formatTime(n: Notifd.Notification) { return GLib.DateTime.new_from_unix_local(n.time).format("%H:%M")! } export function NotificationCard(n: Notifd.Notification, toast = false) { const critical = n.urgency === Notifd.Urgency.CRITICAL const appName = displayName(n) const appIcon = n.app_icon || APP_ICONS[appName.toLowerCase()] || "preferences-system-notifications-symbolic" return ( {n.image !== "" && ( )} {n.actions.filter((a) => a.label).length > 0 && ( {n.actions .filter((a) => a.label) .map((a) => ( ))} )} ) } export default function Notifications(gdkmonitor: Gdk.Monitor) { const { TOP, RIGHT } = Astal.WindowAnchor return ( ns.length > 0)} onNotifyVisible={(self) => { if (!self.visible) hovered.clear() }} > {bind(notifd, "notifications").as((ns) => [...ns] .sort((a, b) => b.time - a.time) .map((n) => NotificationCard(n, true)), )} ) }