import QtQuick 2.15 import QtQuick.Controls 2.15 import Quickshell import Quickshell.Io import qs.Components import qs.Settings import QtQuick.Layouts 1.15 Item { id: root property string configDir: Quickshell.configDir property string historyFilePath: configDir + "/notification_history.json" property bool hasUnread: notificationHistoryWin.hasUnread && !notificationHistoryWin.visible function addToHistory(notification) { notificationHistoryWin.addToHistory(notification) } width: 22; height: 22 // Bell icon/button Item { id: bell width: 22; height: 22 Text { anchors.centerIn: parent text: root.hasUnread ? "notifications_unread" : "notifications" font.family: mouseAreaBell.containsMouse ? "Material Symbols Rounded" : "Material Symbols Outlined" font.pixelSize: 16 font.weight: root.hasUnread ? Font.Bold : Font.Normal color: mouseAreaBell.containsMouse ? Theme.accentPrimary : (root.hasUnread ? Theme.accentPrimary : Theme.textDisabled) } MouseArea { id: mouseAreaBell anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: notificationHistoryWin.visible = !notificationHistoryWin.visible } } // The popup window PanelWindow { id: notificationHistoryWin width: 400 property int maxPopupHeight: 500 property int minPopupHeight: 230 property int contentHeight: headerRow.height + historyList.contentHeight + 56 height: Math.max(Math.min(contentHeight, maxPopupHeight), minPopupHeight) color: "transparent" visible: false screen: Quickshell.primaryScreen focusable: true anchors.top: true anchors.right: true margins.top: 4 margins.right: 4 property int maxHistory: 100 property bool hasUnread: false signal unreadChanged(bool hasUnread) ListModel { id: historyModel } FileView { id: historyFileView path: root.historyFilePath blockLoading: true printErrors: true watchChanges: true JsonAdapter { id: historyAdapter property var notifications: [] } onFileChanged: historyFileView.reload() onLoaded: notificationHistoryWin.loadHistory() onLoadFailed: function(error) { if (error.includes("No such file")) { historyAdapter.notifications = [] 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 (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: notificationHistoryWin.width height: notificationHistoryWin.height anchors.fill: parent color: Theme.backgroundPrimary radius: 20 Column { anchors.fill: parent anchors.margins: 16 spacing: 8 RowLayout { id: headerRow spacing: 4 anchors.top: parent.top anchors.topMargin: 4 anchors.left: parent.left anchors.right: parent.right 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: notificationHistoryWin.clearHistory() } } } Rectangle { width: parent.width height: 0 color: "transparent" visible: true } Rectangle { anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.topMargin: 56 anchors.bottom: parent.bottom 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 Item { id: topSpacer; height: (parent.height - historyList.height) / 2 } 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: headerRow 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 ? notificationHistoryWin.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 } } } } } }