noctalia-shell/Widgets/Notification/NotificationHistory.qml
2025-08-05 17:41:08 +02:00

388 lines
17 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import qs.Settings
import QtQuick.Layouts
import qs.Components
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
}
}
}
}
}
}