From 35b7485e13f912ad283cbb4ffb26484a469b6d19 Mon Sep 17 00:00:00 2001 From: Joshua Yuen Date: Sat, 12 Sep 2026 13:47:23 -0400 Subject: [PATCH] Add AGS notification support --- modules/home/ags/app/app.ts | 2 + modules/home/ags/app/notif-config.ts | 1 + modules/home/ags/app/style.scss | 63 +++++++++ modules/home/ags/app/widget/Notifications.tsx | 128 ++++++++++++++++++ modules/home/ags/default.nix | 6 + modules/options/ags.nix | 9 ++ 6 files changed, 209 insertions(+) create mode 100644 modules/home/ags/app/notif-config.ts create mode 100644 modules/home/ags/app/widget/Notifications.tsx diff --git a/modules/home/ags/app/app.ts b/modules/home/ags/app/app.ts index 83217ef..ee918e0 100644 --- a/modules/home/ags/app/app.ts +++ b/modules/home/ags/app/app.ts @@ -1,10 +1,12 @@ import { App } from "astal/gtk3" import style from "./style.scss" import Bar from "./widget/Bar" +import Notifications from "./widget/Notifications" App.start({ css: style, main() { App.get_monitors().map(Bar) + Notifications(App.get_monitors()[0]) }, }) diff --git a/modules/home/ags/app/notif-config.ts b/modules/home/ags/app/notif-config.ts new file mode 100644 index 0000000..0e93fea --- /dev/null +++ b/modules/home/ags/app/notif-config.ts @@ -0,0 +1 @@ +export const MAX_TIMEOUT = 10000 diff --git a/modules/home/ags/app/style.scss b/modules/home/ags/app/style.scss index cdee348..f8ced17 100644 --- a/modules/home/ags/app/style.scss +++ b/modules/home/ags/app/style.scss @@ -193,3 +193,66 @@ .power-row:hover { background: transparentize($base05, 0.88); } + +.notifications { + .notif { + background: $base01; + border-radius: 10px; + padding: 8px 12px; + color: $base05; + + &:hover { + background: transparentize($base05, 0.88); + } + + &.critical { + border: 1px solid $base08; + } + } + + .notif-header { + margin-bottom: 4px; + + .notif-app-name { + font-weight: bold; + margin: 0 6px; + } + + .notif-time { + font-size: 0.85em; + opacity: 0.7; + margin-right: 4px; + } + + .notif-close { + border-radius: 6px; + padding: 0 2px; + } + } + + .notif-content { + .notif-image { + margin-right: 8px; + } + + .notif-summary { + font-weight: bold; + } + + .notif-text { + font-size: 0.9em; + opacity: 0.85; + } + } + + .notif-action { + background: $base02; + border-radius: 6px; + margin-top: 6px; + padding: 2px 8px; + + &:hover { + background: $base03; + } + } +} diff --git a/modules/home/ags/app/widget/Notifications.tsx b/modules/home/ags/app/widget/Notifications.tsx new file mode 100644 index 0000000..a60281c --- /dev/null +++ b/modules/home/ags/app/widget/Notifications.tsx @@ -0,0 +1,128 @@ +import GLib from "gi://GLib" +import { App, Astal, Gdk, Gtk } from "astal/gtk3" +import Notifd from "gi://AstalNotifd" +import { bind } from "astal" +import { MAX_TIMEOUT } from "../notif-config" + +const notifd = Notifd.get_default() + +notifd.default_timeout = MAX_TIMEOUT + +// notifications restored from the daemon's cache on startup are stale +for (const n of notifd.notifications) { + n.dismiss() +} + +// the daemon honors app-requested timeouts, so enforce our own cap for +// notifications that ask for more (or none at all). critical notifications +// are exempt and stay until dismissed. +notifd.connect("notified", (_, id: number) => { + const n = notifd.get_notification(id) + if (!n || n.urgency === Notifd.Urgency.CRITICAL) return + + if (n.expire_timeout <= 0 || n.expire_timeout > MAX_TIMEOUT) { + GLib.timeout_add(GLib.PRIORITY_DEFAULT, MAX_TIMEOUT, () => { + if (notifd.notifications.includes(n)) n.dismiss() + return GLib.SOURCE_REMOVE + }) + } +}) + +function formatTime(n: Notifd.Notification) { + return GLib.DateTime.new_from_unix_local(n.time).format("%H:%M")! +} + +function NotificationCard(n: Notifd.Notification) { + const critical = n.urgency === Notifd.Urgency.CRITICAL + + 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)} + > + + {bind(notifd, "notifications").as((ns) => + [...ns] + .sort((a, b) => b.time - a.time) + .map((n) => NotificationCard(n)), + )} + + + ) +} diff --git a/modules/home/ags/default.nix b/modules/home/ags/default.nix index bebe858..dced158 100644 --- a/modules/home/ags/default.nix +++ b/modules/home/ags/default.nix @@ -39,6 +39,10 @@ let ) ); + notifConfig = pkgs.writeText "notif-config.ts" '' + export const MAX_TIMEOUT = ${toString cfg.timeout}; + ''; + astalDeps = with pkgs.astal; [ @@ -48,6 +52,7 @@ let network battery wireplumber + notifd ] ++ [ pkgs.networkmanager ]; @@ -59,6 +64,7 @@ let dependencies = astalDeps; postUnpack = '' cp ${palette} "$sourceRoot/palette.scss" + cp ${notifConfig} "$sourceRoot/notif-config.ts" ''; }; in diff --git a/modules/options/ags.nix b/modules/options/ags.nix index 5e74ccd..fc119cf 100644 --- a/modules/options/ags.nix +++ b/modules/options/ags.nix @@ -9,5 +9,14 @@ with lib; default = false; description = "Enable the AGS status bar"; }; + + timeout = mkOption { + type = types.ints.positive; + default = 10000; + description = '' + Maximum duration notification popups stay on screen, in milliseconds. + Critical notifications are exempt and stay until dismissed. + ''; + }; }; }