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

203 lines
7.0 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.
export const history = Variable<Notifd.Notification[]>([])
export const notifCount = bind(history).as((ns) => ns.length)
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
// 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
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={n.app_icon || "preferences-system-notifications-symbolic"}
/>
<label
className="notif-app-name"
label={n.app_name || "Notification"}
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>
)
}