noctalia-shell/Widgets/Notification/NotificationHistory.qml
ly-sec dbb5a9160c NotificationHistory Updates
Add read/unread bell icon
Edit style of the Panel
Small fixes
2025-07-17 16:56:51 +02:00

399 lines
No EOL
17 KiB
QML

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
}
}
}
}
}
}