203 lines
7.0 KiB
TypeScript
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>
|
|
)
|
|
}
|