import QtQuick import Quickshell import Quickshell.Io import qs.Settings import QtQuick.Layouts import qs.Components // The popup window PanelWithOverlay { id: notificationHistoryWin property string historyFilePath: Settings.settingsDir + "notification_history.json" property bool hasUnread: notificationHistoryWinRect.hasUnread && !notificationHistoryWinRect.visible function addToHistory(notification) { notificationHistoryWinRect.addToHistory(notification) } Rectangle { id: notificationHistoryWinRect implicitWidth: 400 property int maxPopupHeight: 800 property int minPopupHeight: 210 property int contentHeight: headerRow.height + historyList.contentHeight + 56 implicitHeight: Math.max(Math.min(contentHeight, maxPopupHeight), minPopupHeight) visible: parent.visible anchors.top: parent.top anchors.right: parent.right anchors.topMargin: 4 anchors.rightMargin: 4 color: Theme.backgroundPrimary radius: 20 property int maxHistory: 100 property bool hasUnread: false signal unreadChanged(bool hasUnread) ListModel { id: historyModel } FileView { id: historyFileView path: notificationHistoryWin.historyFilePath blockLoading: true printErrors: true watchChanges: true JsonAdapter { id: historyAdapter property var notifications: [] } onFileChanged: historyFileView.reload() onLoaded: notificationHistoryWinRect.loadHistory() onLoadFailed: function (error) { historyAdapter.notifications = []; historyFileView.writeAdapter(); } Component.onCompleted: if (path) reload() } function updateHasUnread() { var unread = false; for (let i = 0; i < historyModel.count; ++i) { if (historyModel.get(i).read === false) { unread = true; break; } } if (hasUnread !== unread) { hasUnread = unread; unreadChanged(hasUnread); } } function loadHistory() { if (historyAdapter.notifications) { historyModel.clear(); const notifications = historyAdapter.notifications; const count = Math.min(notifications.length, maxHistory); for (let i = 0; i < count; i++) { let n = notifications[i]; if (typeof n === 'object' && n !== null) { if (n.read === undefined) n.read = false; // Mark as read if window is open if (notificationHistoryWinRect.visible) n.read = true; historyModel.append(n); } } updateHasUnread(); } } function saveHistory() { const historyArray = []; const count = Math.min(historyModel.count, maxHistory); for (let i = 0; i < count; ++i) { let obj = historyModel.get(i); if (typeof obj === 'object' && obj !== null) { historyArray.push({ id: obj.id, appName: obj.appName, summary: obj.summary, body: obj.body, timestamp: obj.timestamp, read: obj.read === undefined ? false : obj.read }); } } historyAdapter.notifications = historyArray; Qt.callLater(function () { historyFileView.writeAdapter(); }); updateHasUnread(); } function addToHistory(notification) { if (!notification.id) notification.id = Date.now(); if (!notification.timestamp) notification.timestamp = new Date().toISOString(); // Mark as read if window is open notification.read = visible; // Remove duplicate by id for (let i = 0; i < historyModel.count; ++i) { if (historyModel.get(i).id === notification.id) { historyModel.remove(i); break; } } historyModel.insert(0, notification); if (historyModel.count > maxHistory) historyModel.remove(maxHistory); saveHistory(); } function clearHistory() { historyModel.clear(); historyAdapter.notifications = []; historyFileView.writeAdapter(); } function formatTimestamp(ts) { if (!ts) return ""; var date = typeof ts === "number" ? new Date(ts) : new Date(Date.parse(ts)); var y = date.getFullYear(); var m = (date.getMonth() + 1).toString().padStart(2, '0'); var d = date.getDate().toString().padStart(2, '0'); var h = date.getHours().toString().padStart(2, '0'); var min = date.getMinutes().toString().padStart(2, '0'); return `${y}-${m}-${d} ${h}:${min}`; } onVisibleChanged: { if (visible) { // Mark all as read when popup is opened let changed = false; for (let i = 0; i < historyModel.count; ++i) { if (historyModel.get(i).read === false) { historyModel.setProperty(i, 'read', true); changed = true; } } if (changed) saveHistory(); } } Rectangle { width: notificationHistoryWinRect.width height: notificationHistoryWinRect.height anchors.fill: parent color: Theme.backgroundPrimary radius: 20 Column { anchors.fill: parent anchors.margins: 16 spacing: 8 RowLayout { id: headerRow spacing: 4 anchors.topMargin: 4 anchors.left: parent.left anchors.right: parent.right Layout.fillHeight: true Layout.alignment: Qt.AlignVCenter Layout.preferredHeight: 52 anchors.leftMargin: 16 anchors.rightMargin: 16 Text { text: "Notification History" font.pixelSize: 18 font.bold: true color: Theme.textPrimary Layout.alignment: Qt.AlignVCenter } Item { Layout.fillWidth: true } Rectangle { id: clearAllButton width: 90 height: 32 radius: 16 color: clearAllMouseArea.containsMouse ? Theme.accentPrimary : Theme.surfaceVariant border.color: Theme.accentPrimary border.width: 1 Layout.alignment: Qt.AlignVCenter Row { anchors.centerIn: parent spacing: 6 Text { text: "delete_sweep" font.family: "Material Symbols Outlined" font.pixelSize: 14 color: clearAllMouseArea.containsMouse ? Theme.onAccent : Theme.accentPrimary verticalAlignment: Text.AlignVCenter } Text { text: "Clear" font.pixelSize: Theme.fontSizeSmall color: clearAllMouseArea.containsMouse ? Theme.onAccent : Theme.accentPrimary font.bold: true verticalAlignment: Text.AlignVCenter } } MouseArea { id: clearAllMouseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: notificationHistoryWinRect.clearHistory() } } } Rectangle { width: parent.width height: 0 color: "transparent" visible: true } Rectangle { anchors.left: parent.left anchors.right: parent.right anchors.topMargin: 56 height: notificationHistoryWinRect.height - 56 - 12 color: Theme.surfaceVariant radius: 20 Rectangle { anchors.fill: parent color: Theme.surface radius: 20 z: 0 } Rectangle { id: listContainer anchors.fill: parent anchors.topMargin: 12 anchors.bottomMargin: 12 color: "transparent" clip: true Column { anchors.fill: parent spacing: 0 ListView { id: historyList width: parent.width height: Math.min(contentHeight, parent.height) spacing: 12 model: historyModel.count > 0 ? historyModel : placeholderModel clip: true delegate: Item { width: parent.width height: notificationCard.implicitHeight + 12 Rectangle { id: notificationCard width: parent.width - 24 anchors.horizontalCenter: parent.horizontalCenter color: Theme.backgroundPrimary radius: 16 anchors.top: parent.top anchors.bottom: parent.bottom anchors.margins: 0 implicitHeight: contentColumn.implicitHeight + 20 Column { id: contentColumn anchors.fill: parent anchors.margins: 14 spacing: 6 RowLayout { id: headerRow2 spacing: 8 Rectangle { id: iconBackground width: 28 height: 28 radius: 20 color: Theme.accentPrimary border.color: Qt.darker(Theme.accentPrimary, 1.2) border.width: 1.2 Layout.alignment: Qt.AlignVCenter Text { anchors.centerIn: parent text: model.appName ? model.appName.charAt(0).toUpperCase() : "?" font.family: Theme.fontFamily font.pixelSize: 15 font.bold: true color: Theme.backgroundPrimary } } Column { id: appInfoColumn spacing: 0 Layout.alignment: Qt.AlignVCenter Text { text: model.appName || "No Notifications" font.bold: true color: Theme.textPrimary font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall verticalAlignment: Text.AlignVCenter } Text { visible: !model.isPlaceholder text: model.timestamp ? notificationHistoryWinRect.formatTimestamp(model.timestamp) : "" color: Theme.textDisabled font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeCaption verticalAlignment: Text.AlignVCenter } } Item { Layout.fillWidth: true } } Text { text: model.summary || (model.isPlaceholder ? "You're all caught up!" : "") color: Theme.textSecondary font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeBody width: parent.width wrapMode: Text.Wrap } Text { text: model.body || (model.isPlaceholder ? "No notifications to show." : "") color: Theme.textDisabled font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeBody width: parent.width wrapMode: Text.Wrap } } } } } } } } Rectangle { width: 1 height: 24 color: "transparent" } ListModel { id: placeholderModel ListElement { appName: "" summary: "" body: "" isPlaceholder: true } } } } } }