231 lines
8 KiB
QML
231 lines
8 KiB
QML
pragma Singleton
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Services.Notifications
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property bool panelOpen: false
|
|
property int unreadCount: 0
|
|
|
|
property ListModel notifications: ListModel {}
|
|
property var _refs: ({}) // maps nid → live Notification object
|
|
property var _pids: ({}) // maps nid → sender process PID (from D-Bus monitor)
|
|
|
|
property ListModel toasts: ListModel {}
|
|
|
|
// Runs every 100ms while toasts are active, decrementing progress (1.0 to 0.0 over 5s)
|
|
// and removing any toast that has expired
|
|
Timer {
|
|
id: toastTick
|
|
interval: 100
|
|
repeat: true
|
|
running: root.toasts.count > 0
|
|
|
|
onTriggered: {
|
|
let i = 0
|
|
while (i < root.toasts.count) {
|
|
const p = root.toasts.get(i).progress - 0.02
|
|
if (p <= 0) {
|
|
root.toasts.remove(i, 1)
|
|
} else {
|
|
root.toasts.setProperty(i, "progress", p)
|
|
i++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Executes a niri focus-window command
|
|
Process {
|
|
id: _focusProc
|
|
}
|
|
|
|
// Monitors D-Bus for Notify calls and outputs "nid:sender_pid" so we can
|
|
// walk /proc and focus the exact window that sent each notification.
|
|
Process {
|
|
id: _pidMonitor
|
|
command: ["bash", String(Qt.resolvedUrl("notif-pid-monitor.sh")).replace("file://", "")]
|
|
running: true
|
|
|
|
stdout: SplitParser {
|
|
onRead: line => {
|
|
const parts = line.split(":")
|
|
if (parts.length !== 2) return
|
|
const nid = parseInt(parts[0])
|
|
const pid = parts[1].trim()
|
|
if (!isNaN(nid) && pid) {
|
|
const p = root._pids
|
|
p[nid] = pid
|
|
root._pids = p
|
|
}
|
|
}
|
|
}
|
|
|
|
onExited: (exitCode, exitStatus) => _pidRestartTimer.start()
|
|
}
|
|
|
|
// Restarts the D-Bus monitor if dbus-monitor exits unexpectedly (e.g. session bus restart).
|
|
Timer {
|
|
id: _pidRestartTimer
|
|
interval: 1000
|
|
repeat: false
|
|
onTriggered: { _pidMonitor.running = false; _pidMonitor.running = true }
|
|
}
|
|
|
|
NotificationServer {
|
|
keepOnReload: true
|
|
|
|
onNotification: notif => {
|
|
const ts = Date.now()
|
|
const day = root._dayLabel(ts)
|
|
|
|
root.notifications.insert(0, {
|
|
nid: notif.id,
|
|
appName: notif.appName || "Unknown",
|
|
summary: notif.summary || "",
|
|
body: notif.body || "",
|
|
appIcon: notif.appIcon || "",
|
|
image: notif.image || "",
|
|
desktopEntry: notif.desktopEntry || "",
|
|
urgency: notif.urgency || 1,
|
|
timestamp: ts,
|
|
day: day
|
|
})
|
|
|
|
const r = root._refs
|
|
r[notif.id] = notif
|
|
root._refs = r
|
|
|
|
if (!root.panelOpen) root.unreadCount++
|
|
|
|
root.toasts.insert(0, {
|
|
tid: notif.id,
|
|
appName: notif.appName || "Unknown",
|
|
summary: notif.summary || "",
|
|
body: notif.body || "",
|
|
appIcon: notif.appIcon || "",
|
|
image: notif.image || "",
|
|
desktopEntry: notif.desktopEntry || "",
|
|
urgency: notif.urgency || 1,
|
|
progress: 1.0
|
|
})
|
|
}
|
|
}
|
|
|
|
function dismiss(index) {
|
|
if (index < 0 || index >= notifications.count) return
|
|
const entry = notifications.get(index)
|
|
const ref = _refs[entry.nid]
|
|
if (ref) {
|
|
try { ref.close() } catch (_) {}
|
|
const r = _refs
|
|
delete r[entry.nid]
|
|
_refs = r
|
|
}
|
|
const p = _pids
|
|
delete p[entry.nid]
|
|
_pids = p
|
|
notifications.remove(index, 1)
|
|
}
|
|
|
|
function dismissAll() {
|
|
for (let i = 0; i < notifications.count; i++) {
|
|
const ref = _refs[notifications.get(i).nid]
|
|
if (ref) { try { ref.close() } catch (_) {} }
|
|
}
|
|
notifications.clear()
|
|
_refs = {}
|
|
_pids = {}
|
|
unreadCount = 0
|
|
}
|
|
|
|
// Invokes the D-Bus action (tells the app which chat/content to open) and
|
|
// always also focuses the window via niri IPC.
|
|
// invoke() alone cannot steal focus on Wayland without an XDG activation token;
|
|
// niri IPC bypasses that restriction because the compositor grants focus directly.
|
|
// entry = desktopEntry || appName, passed from the model so it works even after
|
|
// a hot-reload clears _refs.
|
|
function invokeDefault(nid, entry) {
|
|
// Send ActionInvoked to the app if we still have a live reference.
|
|
const ref = _refs[nid]
|
|
if (ref) {
|
|
const actions = ref.actions ? Array.from(ref.actions) : []
|
|
if (actions.length > 0) {
|
|
let found = false
|
|
for (let i = 0; i < actions.length; i++) {
|
|
if (actions[i].identifier === "default") {
|
|
try { actions[i].invoke() } catch (_) {}
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if (!found) {
|
|
try { actions[0].invoke() } catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bring the window to front via niri IPC.
|
|
const senderPid = _pids[nid]
|
|
if (senderPid) {
|
|
// Walk /proc from the sender PID upward until we find a PID that owns
|
|
// a niri window, then focus that window. This correctly handles multiple
|
|
// instances of the same app (e.g. several kitty terminals).
|
|
_focusProc.command = [
|
|
"bash", "-c",
|
|
'P=' + senderPid + '; ' +
|
|
'while [ "$P" -gt 1 ]; do ' +
|
|
' WIN=$(niri msg --json windows | jq -r --arg p "$P" \'.[] | select(.pid == ($p | tonumber)) | .id // empty\'); ' +
|
|
' if [ -n "$WIN" ]; then niri msg action focus-window --id "$WIN"; exit 0; fi; ' +
|
|
' P=$(awk \'/^PPid:/{print $2}\' /proc/"$P"/status 2>/dev/null); ' +
|
|
' [ -z "$P" ] && exit 1; ' +
|
|
'done'
|
|
]
|
|
} else if (entry) {
|
|
// Fallback when no PID was captured: match by app_id, pick most recently focused.
|
|
_focusProc.command = [
|
|
"bash", "-c",
|
|
'WID=$(niri msg --json windows | jq -r --arg id "' + entry + '" ' +
|
|
'\'[.[] | select(.app_id == $id)] | sort_by(.focus_timestamp.secs, .focus_timestamp.nanos) | last | .id // empty\'); ' +
|
|
'[ -n "$WID" ] && niri msg action focus-window --id "$WID"'
|
|
]
|
|
} else {
|
|
return
|
|
}
|
|
_focusProc.running = false
|
|
_focusProc.running = true
|
|
}
|
|
|
|
function dismissToast(tid) {
|
|
for (let i = 0; i < toasts.count; i++) {
|
|
if (toasts.get(i).tid === tid) {
|
|
toasts.remove(i, 1)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
function togglePanel() {
|
|
panelOpen = !panelOpen
|
|
if (panelOpen) unreadCount = 0
|
|
}
|
|
|
|
function formatTime(ts) {
|
|
return Qt.formatDateTime(new Date(ts), "hh:mm")
|
|
}
|
|
|
|
function _dayLabel(ts) {
|
|
const now = new Date()
|
|
const date = new Date(ts)
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
const yesterday = new Date(today.getTime() - 86400000)
|
|
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
|
|
|
if (day.getTime() === today.getTime()) return "Today"
|
|
if (day.getTime() === yesterday.getTime()) return "Yesterday"
|
|
return Qt.formatDateTime(date, "dd/MM/yyyy")
|
|
}
|
|
}
|