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 a9cfd9e5c2
commit 5fab76c41f
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); $hover-overlay: transparentize($base05, 0.92);
$active-hover: transparentize($primary-bg, 0.15); $active-hover: transparentize($primary-bg, 0.15);
$active-gradient: linear-gradient(to right, $primary-bg, darken($primary-bg, 4%)); $active-gradient: linear-gradient(to right, $primary-bg, darken($primary-bg, 4%));
$toast-bg: transparentize($base02, 0.15);
$bar-alpha: 0.7; $bar-alpha: 0.7;
$bar-bg: transparentize($base01, 1 - $bar-alpha); $bar-bg: transparentize($base01, 1 - $bar-alpha);
@ -582,6 +583,16 @@ scale {
&.critical { &.critical {
border-color: $base08; 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 { .notif-header {

View File

@ -12,6 +12,13 @@ export const dndIcon = Variable.derive([bind(notifd, "dontDisturb")], (dnd) =>
: "preferences-system-notifications-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() { export function DndToggle() {
return ( return (
<button <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 // notifications restored from the daemon's cache on startup are stale
for (const n of notifd.notifications) { for (const n of notifd.notifications) {
n.dismiss() 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) => { notifd.connect("notified", (_, id: number) => {
const n = notifd.get_notification(id) 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) { // re-notified ids update the existing entry instead of adding a duplicate
GLib.timeout_add(GLib.PRIORITY_DEFAULT, MAX_TIMEOUT, () => { if (!history.get().some((m) => m.id === id)) {
if (notifd.notifications.includes(n)) n.dismiss() history.set([...history.get(), n])
return GLib.SOURCE_REMOVE
})
} }
}) })
// 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) { function formatTime(n: Notifd.Notification) {
return GLib.DateTime.new_from_unix_local(n.time).format("%H:%M")! 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 const critical = n.urgency === Notifd.Urgency.CRITICAL
return ( return (
<button <button
className={`notif${critical ? " critical" : ""}`} className={`notif${critical ? " critical" : ""}${toast ? " toast" : ""}`}
onClicked={() => n.dismiss()} onClicked={() => dismissNotification(n)}
{...(toast
? {
onHover: () => hovered.add(n.id),
onHoverLost: () => hovered.delete(n.id),
}
: {})}
> >
<box vertical> <box vertical>
<box className="notif-header"> <box className="notif-header">
@ -77,7 +126,7 @@ export function NotificationCard(n: Notifd.Notification) {
<label className="notif-time" label={formatTime(n)} /> <label className="notif-time" label={formatTime(n)} />
<button <button
className="notif-close" className="notif-close"
onClicked={() => n.dismiss()} onClicked={() => dismissNotification(n)}
> >
<icon icon="window-close-symbolic" pixelSize={12} /> <icon icon="window-close-symbolic" pixelSize={12} />
</button> </button>
@ -137,12 +186,15 @@ export default function Notifications(gdkmonitor: Gdk.Monitor) {
marginTop={54} marginTop={54}
marginRight={8} marginRight={8}
visible={bind(notifd, "notifications").as((ns) => ns.length > 0)} visible={bind(notifd, "notifications").as((ns) => ns.length > 0)}
onNotifyVisible={(self) => {
if (!self.visible) hovered.clear()
}}
> >
<box className="notifications" vertical spacing={8} widthRequest={360}> <box className="notifications" vertical spacing={8} widthRequest={360}>
{bind(notifd, "notifications").as((ns) => {bind(notifd, "notifications").as((ns) =>
[...ns] [...ns]
.sort((a, b) => b.time - a.time) .sort((a, b) => b.time - a.time)
.map((n) => NotificationCard(n)), .map((n) => NotificationCard(n, true)),
)} )}
</box> </box>
</window> </window>

View File

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