diff options
Diffstat (limited to 'modules/notifpopups.tsx')
| -rw-r--r-- | modules/notifpopups.tsx | 163 |
1 files changed, 163 insertions, 0 deletions
diff --git a/modules/notifpopups.tsx b/modules/notifpopups.tsx new file mode 100644 index 0000000..a9a898c --- /dev/null +++ b/modules/notifpopups.tsx @@ -0,0 +1,163 @@ +import { GLib, register, timeout } from "astal"; +import { Astal, Gtk, Widget } from "astal/gtk3"; +import AstalNotifd from "gi://AstalNotifd"; + +const urgencyToString = (urgency: AstalNotifd.Urgency) => { + switch (urgency) { + case AstalNotifd.Urgency.LOW: + return "low"; + case AstalNotifd.Urgency.NORMAL: + return "normal"; + case AstalNotifd.Urgency.CRITICAL: + return "critical"; + } +}; + +const getTime = (time: number) => { + const messageTime = GLib.DateTime.new_from_unix_local(time); + const todayDay = GLib.DateTime.new_now_local().get_day_of_year(); + if (messageTime.get_day_of_year() === todayDay) { + const aMinuteAgo = GLib.DateTime.new_now_local().add_seconds(-60); + return aMinuteAgo !== null && messageTime.compare(aMinuteAgo) > 0 ? "Now" : messageTime.format("%H:%M"); + } else if (messageTime.get_day_of_year() === todayDay - 1) return "Yesterday"; + return messageTime.format("%d/%m"); +}; + +const Icon = ({ icon }: { icon: string }) => { + if (GLib.file_test(icon, GLib.FileTest.EXISTS)) + return ( + <box + valign={Gtk.Align.START} + className="image" + css={` + background-image: url("${icon}"); + `} + /> + ); + if (Astal.Icon.lookup_icon(icon)) return <icon valign={Gtk.Align.START} className="image" icon={icon} />; + return null; +}; + +@register() +class NotifPopup extends Widget.Box { + readonly #revealer; + #destroyed = false; + + constructor({ notification }: { notification: AstalNotifd.Notification }) { + super(); + + this.#revealer = ( + <revealer revealChild transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} transitionDuration={150}> + <box className="wrapper"> + <box vertical className={`popup ${urgencyToString(notification.urgency)}`}> + <box className="header"> + {(notification.appIcon || notification.desktopEntry) && ( + <icon className="app-icon" icon={notification.appIcon || notification.desktopEntry} /> + )} + <label className="app-name" label={notification.appName ?? "Unknown"} /> + <box hexpand /> + <label + className="time" + label={getTime(notification.time)!} + setup={self => + timeout(60000, () => !this.#destroyed && (self.label = getTime(notification.time)!)) + } + /> + </box> + <box hexpand className="separator" /> + <box className="content"> + {notification.image && <Icon icon={notification.image} />} + <box vertical> + <label className="summary" xalign={0} label={notification.summary} truncate /> + <label className="body" xalign={0} label={notification.body} wrap useMarkup /> + </box> + </box> + <box className="actions"> + <button hexpand cursor="pointer" onClicked={() => notification.dismiss()} label="Close" /> + {notification.actions.map(a => ( + <button hexpand cursor="pointer" onClicked={() => notification.invoke(a.id)}> + {notification.actionIcons ? <icon icon={a.label} /> : a.label} + </button> + ))} + </box> + </box> + </box> + </revealer> + ) as Widget.Revealer; + this.add(this.#revealer); + + // Init animation + const width = this.get_preferred_width()[1]; + this.css = `margin-left: ${width}px; margin-right: -${width}px;`; + timeout(1, () => { + this.css = `transition: 150ms cubic-bezier(0.05, 0.9, 0.1, 1.1); margin-left: 0; margin-right: 0;`; + }); + + // Close popup after timeout + // timeout( + // notification.expireTimeout > 0 + // ? notification.expireTimeout + // : notification.urgency === AstalNotifd.Urgency.CRITICAL + // ? 10000 + // : 5000, + // () => this.destroyWithAnims() + // ); + } + + destroyWithAnims() { + if (this.#destroyed) return; + this.#destroyed = true; + + const animTime = 120; + const animMargin = this.get_allocated_width(); + this.css = `transition: ${animTime}ms cubic-bezier(0.85, 0, 0.15, 1); + margin-left: ${animMargin}px; margin-right: -${animMargin}px;`; + timeout(animTime, () => { + this.#revealer.revealChild = false; + timeout(this.#revealer.transitionDuration, () => this.destroy()); + }); + } +} + +export default () => ( + <window + namespace="notifpopups" + anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT | Astal.WindowAnchor.BOTTOM} + exclusivity={Astal.Exclusivity.IGNORE} + > + <box + vertical + valign={Gtk.Align.START} + className="notifpopups" + setup={self => { + const notifd = AstalNotifd.get_default(); + const map = new Map<number, NotifPopup>(); + self.hook(notifd, "notified", (self, id) => { + const notification = notifd.get_notification(id); + const popup = (<NotifPopup notification={notification} />) as NotifPopup; + map.set(notification.id, popup); + self.add( + <eventbox + // Dismiss on middle click + onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()} + // Close on hover lost + onHoverLost={() => popup.destroyWithAnims()} + > + {popup} + </eventbox> + ); + }); + self.hook(notifd, "resolved", (_, id) => { + const popup = map.get(id); + if (popup) { + popup.destroyWithAnims(); + map.delete(id); + } + }); + + // Change input region to child region so can click through empty space + self.connect("size-allocate", () => self.get_window()?.set_child_input_shapes()); + }} + /> + </window> +); |