Keep notifications in dropdown after toast timeout and polish toasts

This commit is contained in:
Joshua Yuen 2026-09-12 20:09:54 -04:00
parent 530268c0ef
commit f40918b521
Signed by: josh
GPG Key ID: 530DA0331EFE99F5
3 changed files with 88 additions and 22 deletions

View File

@ -16,6 +16,7 @@ $hover-bg: transparentize($base02, 0.2);
$hover-overlay: transparentize($base05, 0.92);
$active-hover: transparentize($primary-bg, 0.15);
$active-gradient: linear-gradient(to right, $primary-bg, darken($primary-bg, 4%));
$toast-bg: transparentize($base02, 0.15);
$bar-alpha: 0.7;
$bar-bg: transparentize($base01, 1 - $bar-alpha);
@ -582,6 +583,16 @@ scale {
&.critical {
border-color: $base08;
}
// popup toasts are more opaque than list entries and become
// completely opaque while hovered
&.toast {
background-color: $toast-bg;
&:hover {
background-color: $base02;
}
}
}
.notif-header {

View File

@ -12,6 +12,13 @@ export const dndIcon = Variable.derive([bind(notifd, "dontDisturb")], (dnd) =>
: "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
@ -28,39 +35,81 @@ export function DndToggle() {
)
}
notifd.default_timeout = MAX_TIMEOUT
// 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()
}
// 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) 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
})
// 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) {
export function NotificationCard(n: Notifd.Notification, toast = false) {
const critical = n.urgency === Notifd.Urgency.CRITICAL
return (
<button
className={`notif${critical ? " critical" : ""}`}
onClicked={() => n.dismiss()}
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">
@ -77,7 +126,7 @@ export function NotificationCard(n: Notifd.Notification) {
<label className="notif-time" label={formatTime(n)} />
<button
className="notif-close"
onClicked={() => n.dismiss()}
onClicked={() => dismissNotification(n)}
>
<icon icon="window-close-symbolic" pixelSize={12} />
</button>
@ -137,12 +186,15 @@ export default function Notifications(gdkmonitor: Gdk.Monitor) {
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)),
.map((n) => NotificationCard(n, true)),
)}
</box>
</window>

View File

@ -1,7 +1,6 @@
import Gtk from "gi://Gtk?version=3.0"
import GLib from "gi://GLib"
import AstalWp from "gi://AstalWp"
import Notifd from "gi://AstalNotifd"
import { execAsync, Variable, bind } from "astal"
import Dropdown from "./Dropdown"
import { wifiIcon, WifiSection } from "./Wifi"
@ -9,13 +8,17 @@ import { bluetoothIndicator, BluetoothSection } from "./Bluetooth"
import { BrightnessRow } from "./Brightness"
import { MediaSection } from "./Media"
import { batteryIcon, batteryPercent, BatterySection } from "./Battery"
import { NotificationCard, DndToggle, dndIcon } from "./Notifications"
import {
clearNotifications,
dndIcon,
DndToggle,
history,
notifCount,
NotificationCard,
} from "./Notifications"
const speaker = AstalWp.get_default().audio.default_speaker!
const mic = AstalWp.get_default().audio.default_microphone
const notifd = Notifd.get_default()
const notifCount = bind(notifd, "notifications").as((ns) => ns.length)
const speakerIcon = Variable.derive(
[bind(speaker, "volume"), bind(speaker, "mute")],
@ -162,7 +165,7 @@ export default function QuickSettings(gdkmonitor: Gdk.Monitor) {
<box hexpand />
<button
className="notif-clear"
onClicked={() => [...notifd.notifications].forEach((n) => n.dismiss())}
onClicked={clearNotifications}
>
<label label="Clear" />
</button>
@ -173,7 +176,7 @@ export default function QuickSettings(gdkmonitor: Gdk.Monitor) {
label="no notifications"
visible={notifCount.as((n) => n === 0)}
/>
{bind(notifd, "notifications").as((ns) =>
{bind(history).as((ns) =>
[...ns]
.sort((a, b) => b.time - a.time)
.map((n) => NotificationCard(n)),