nixos/modules/home/ags/app/widget/Notifications.tsx

239 lines
8.2 KiB
TypeScript

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<Notifd.Notification[]>([])
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<string, string> = {
"notify-send": "Notification",
}
// per-app icons for notifications that don't supply one
const APP_ICONS: Record<string, string> = {
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 (
<button
className={bind(notifd, "dontDisturb").as((v) =>
v ? "simple-toggle active" : "simple-toggle",
)}
onClicked={() => (notifd.dontDisturb = !notifd.dontDisturb)}
>
<box spacing={8}>
<icon className="icon" icon={dndIcon()} />
<label label="Do Not Disturb" />
</box>
</button>
)
}
// 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<number>()
// 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 (
<button
className={`notif${critical ? " critical" : ""}${toast ? " toast" : ""}`}
onClicked={() => dismissNotification(n)}
{...(toast
? {
onHover: () => hovered.add(n.id),
onHoverLost: () => hovered.delete(n.id),
}
: {})}
>
<box vertical>
<box className="notif-header">
<icon pixelSize={16} icon={appIcon} />
<label
className="notif-app-name"
label={appName}
halign={Gtk.Align.START}
hexpand
/>
<label className="notif-time" label={formatTime(n)} />
<button
className="notif-close"
onClicked={() => dismissNotification(n)}
>
<icon icon="window-close-symbolic" pixelSize={12} />
</button>
</box>
<box className="notif-content">
{n.image !== "" && (
<icon className="notif-image" pixelSize={36} icon={n.image} />
)}
<box vertical hexpand>
<label
className="notif-summary"
label={n.summary}
halign={Gtk.Align.START}
visible={n.summary !== ""}
/>
<label
className="notif-text"
label={n.body}
wrap
halign={Gtk.Align.START}
visible={n.body !== ""}
/>
</box>
</box>
{n.actions.filter((a) => a.label).length > 0 && (
<box className="notif-actions" homogeneous spacing={4}>
{n.actions
.filter((a) => a.label)
.map((a) => (
<button
className="notif-action"
onClicked={() => a.invoke()}
>
<label label={a.label} />
</button>
))}
</box>
)}
</box>
</button>
)
}
export default function Notifications(gdkmonitor: Gdk.Monitor) {
const { TOP, RIGHT } = Astal.WindowAnchor
return (
<window
className="popup-window"
namespace="ags-notifications"
application={App}
gdkmonitor={gdkmonitor}
layer={Astal.Layer.OVERLAY}
anchor={TOP | RIGHT}
exclusivity={Astal.Exclusivity.IGNORE}
keymode={Astal.Keymode.NONE}
marginTop={54}
marginRight={8}
visible={bind(notifd, "notifications").as((ns) => ns.length > 0)}
onNotifyVisible={(self) => {
if (!self.visible) hovered.clear()
}}
>
<box className="notifications" vertical spacing={8} widthRequest={360}>
{bind(notifd, "notifications").as((ns) =>
[...ns]
.sort((a, b) => b.time - a.time)
.map((n) => NotificationCard(n, true)),
)}
</box>
</window>
)
}