import { execAsync } from "ags/process"; import { generalConfig } from "../config"; import { onCleanup } from "ags"; import GObject, { getter, ParamSpec, property, register, signal } from "ags/gobject"; import AstalNotifd from "gi://AstalNotifd"; import GLib from "gi://GLib?version=2.0"; export type HistoryNotification = { id: number; appName: string; body: string; summary: string; urgency: AstalNotifd.Urgency; appIcon?: string; time: number; image?: string; } export class NotificationTimeout { #source?: GLib.Source; #args?: Array; #millis: number; #lastRemained!: number; readonly callback: () => void; get millis(): number { return this.#millis; } get remaining(): number { return this.source!.get_time() } get lastRemained(): number { return this.#lastRemained; } get running(): boolean { return Boolean(this.source?.is_destroyed()); } get source(): GLib.Source|undefined { return this.#source; } constructor(millis: number, callback: () => void, start: boolean = true, ...args: Array) { this.#millis = millis; this.callback = callback; this.#args = args; if(!start) return; this.start(); } cancel(): void { // use lastRemained to calculate on what time the user hold the notification, so it // can be released by the remaining time (works like a timeout "pause") this.#lastRemained = Math.floor(Math.max(this.#source!.get_ready_time() - GLib.get_monotonic_time()) / 1000); this.#source?.destroy(); this.#source?.unref(); this.#source = undefined; } start(newMillis?: number): GLib.Source { if(this.running) throw new Error("Notifications: Can't start a new counter if it's already running!"); if(newMillis !== undefined) this.#millis = newMillis; this.#source = setTimeout( this.callback, this.#millis, this.#args ); this.#lastRemained = Math.floor(Math.max(this.#source!.get_ready_time() - GLib.get_monotonic_time()) / 1000); return this.#source; } }; @register({ GTypeName: "Notifications" }) export class Notifications extends GObject.Object { private static instance: (Notifications|null) = null; declare $signals: GObject.Object.SignalSignatures & { "history-added": (notification: HistoryNotification) => void; "history-removed": (notificationId: number) => void; "history-cleared": () => void; "notification-added": (notification: AstalNotifd.Notification) => void; "notification-removed": (notificationId: number) => void; "notification-replaced": (notificationId: number) => void; }; #notifications = new Map(); #history: Array = []; #connections: Array = []; @getter(Array) public get notifications() { return [...this.#notifications.values()].map(([n]) => n); }; @getter(Array) public get history() { return this.#history }; @getter(Array) public get notificationsOnHold() { return [...this.#notifications.values()].filter(([_, s]) => typeof s === "undefined" ).map(([n]) => n); } @property(Number) public historyLimit: number = 10; /** skip notifications directly to notification history */ @property(Boolean) public ignoreNotifications: boolean = false; @signal(AstalNotifd.Notification) notificationAdded(_notification: AstalNotifd.Notification) {}; @signal(Number) notificationRemoved(_id: number) {}; @signal(Object as unknown as ParamSpec) historyAdded(_notification: Object) {}; @signal() historyCleared() {}; @signal(Number) historyRemoved(_id: number) {}; @signal(Number) notificationReplaced(_id: number) {}; constructor() { super(); this.#connections.push( AstalNotifd.get_default().connect("notified", (notifd, id) => { const notification = notifd.get_notification(id); if(this.getNotifd().dontDisturb || this.ignoreNotifications) { this.addHistory(notification, () => notification.dismiss()); return; } this.addNotification(notification, this.getNotificationTimeout(notification) > 0); }), AstalNotifd.get_default().connect("resolved", (notifd, id, _reason) => { this.removeNotification(id); this.addHistory(notifd.get_notification(id)); }) ); onCleanup(() => { this.#connections.map(id => AstalNotifd.get_default().disconnect(id)); }); } public static getDefault(): Notifications { if(!this.instance) this.instance = new Notifications(); return this.instance; } public async sendNotification(props: { urgency?: AstalNotifd.Urgency; appName?: string; image?: string; summary: string; body?: string; replaceId?: number; actions?: Array<{ id?: (string|number); text: string; onAction?: () => void }> }): Promise<{ id?: (string|number); text: string; onAction?: () => void }|null|void> { return await execAsync([ "notify-send", ...(props.urgency ? [ "-u", this.getUrgencyString(props.urgency) ] : []), ...(props.appName ? [ "-a", props.appName ] : []), ...(props.image ? [ "-i", props.image ] : []), ...(props.actions ? props.actions.map((action) => [ "-A", action.text ] ).flat(2) : []), ...(props.replaceId ? [ "-r", props.replaceId.toString() ] : []), props.summary, props.body ? props.body : "" ]).then((stdout) => { stdout = stdout.trim(); if(!stdout) { if(props.actions && props.actions.length > 0) return null; return; } if(props.actions && props.actions.length > 0) { const action = props.actions[Number.parseInt(stdout)]; action?.onAction?.(); return action ?? undefined; } }).catch((err: Error) => { console.error(`Notifications: Couldn't send notification! Is the daemon running? Stderr:\n${ err.message ? `${err.message}\n` : ""}Stack: ${err.stack}`); }); } public getUrgencyString(urgency: AstalNotifd.Notification|AstalNotifd.Urgency) { switch((urgency instanceof AstalNotifd.Notification) ? urgency.urgency : urgency) { case AstalNotifd.Urgency.LOW: return "low"; case AstalNotifd.Urgency.CRITICAL: return "critical"; } return "normal"; } private addHistory(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void { if(!notif) return; this.#history.length === this.historyLimit && this.removeHistory(this.#history[this.#history.length - 1]); this.#history.map((notifb, i) => notifb.id === notif.id && this.#history.splice(i, 1)); this.#history.unshift({ id: notif.id, appName: notif.app_name, body: notif.body, summary: notif.summary, urgency: notif.urgency, appIcon: notif.app_icon, time: notif.time, image: notif.image ? notif.image : undefined } as HistoryNotification); this.notify("history"); this.emit("history-added", this.#history[0]); onAdded?.(notif); } public async clearHistory(): Promise { this.#history.reverse().map((notif) => { this.#history = this.history.filter((n) => n.id !== notif.id); this.emit("history-removed", notif.id); }); this.emit("history-cleared"); this.notify("history"); } public removeHistory(notif: (HistoryNotification|number)): void { const notifId = (typeof notif === "number") ? notif : notif.id; this.#history = this.#history.filter((item: HistoryNotification) => item.id !== notifId); this.notify("history"); this.emit("history-removed", notifId); } private addNotification( notif: AstalNotifd.Notification, removeOnTimeout: boolean = true, onTimeoutEnd?: () => void ): void { const replaced = this.#notifications.has(notif.id); const notifTimeout = this.getNotificationTimeout(notif); const onEnd = () => { removeOnTimeout && this.removeNotification(notif); onTimeoutEnd?.(); } // destroy timer of replaced notification(if there's any) if(replaced) { const data = this.#notifications.get(notif.id)!; (data?.[1] instanceof NotificationTimeout) && data[1].cancel(); } this.#notifications.set(notif.id, [ notif, new NotificationTimeout(notifTimeout, onEnd, notifTimeout > 0) ]); replaced && this.emit("notification-replaced", notif.id); this.notify("notifications"); this.emit("notification-added", notif); if(notifTimeout <= 0) onEnd?.(); } public getNotificationTimeout(notif: AstalNotifd.Notification): number { return generalConfig.getProperty( `notifications.timeout_${this.getUrgencyString(notif.urgency)}`, "number" ); } public removeNotification(notif: (AstalNotifd.Notification|number), addToHistory: boolean = true): void { notif = typeof notif === "number" ? this.#notifications.get(notif)?.[0]! : notif; if(!notif) return; const timeout = this.#notifications.get(notif.id)![1]; timeout.running && timeout.cancel(); this.#notifications.delete(notif.id); addToHistory && this.addHistory(notif); notif.dismiss(); this.notify("notifications"); this.emit("notification-removed", notif.id); } public holdNotification(notif: AstalNotifd.Notification|number): void { const id = typeof notif === "number" ? notif : notif.id; const data = this.#notifications.get(id); if(!data) return; data[1].cancel(); this.notify("notifications-on-hold"); } public releaseNotification(notif: AstalNotifd.Notification|number): void { const id = typeof notif === "number" ? notif : notif.id; const data = this.#notifications.get(id); if(!data) return; data[1].start(data[1].lastRemained); this.notify("notifications-on-hold"); } public toggleDoNotDisturb(value?: boolean): boolean { value = value ?? !AstalNotifd.get_default().dontDisturb; AstalNotifd.get_default().dontDisturb = value; return value; } public getNotifd(): AstalNotifd.Notifd { return AstalNotifd.get_default(); } public emit( signal: Signal, ...args: Parameters<(typeof this.$signals)[Signal]> ): void { super.emit(signal, ...args); } public connect( signal: Signal, callback: (self: typeof this, ...params: Parameters<(typeof this.$signals)[Signal]>) => ReturnType<(typeof this.$signals)[Signal]> ): number { return super.connect(signal, callback); } }