Add notification history (Widets/Notification/NotificationHistory.qml)

This commit is contained in:
ly-sec 2025-07-17 15:49:06 +02:00
parent 0ed7b3fa1c
commit cf26fe52d9
5 changed files with 395 additions and 0 deletions

View file

@ -0,0 +1,357 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import qs.Components
import qs.Settings
import QtQuick.Layouts
PanelWindow {
id: notificationHistoryWin
width: 400
height: 500
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 string configDir: Quickshell.configDir
property string historyFilePath: configDir + "/notification_history.json"
ListModel {
id: historyModel // Holds notification objects
}
FileView {
id: historyFileView
path: historyFilePath
blockLoading: true
printErrors: true
watchChanges: true
JsonAdapter {
id: historyAdapter
property var notifications: [] // Array of notification objects
}
onFileChanged: {
reload() // Reload if file changes on disk
}
onLoaded: {
loadHistory() // Populate model after loading
}
onLoadFailed: function(error) {
console.error("Failed to load history file:", error)
if (error.includes("No such file")) {
historyAdapter.notifications = [] // Create new file if missing
writeAdapter()
}
}
onSaved: {}
onSaveFailed: function(error) {
console.error("Failed to save history:", error)
}
Component.onCompleted: {
if (path) reload()
}
}
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++) {
if (typeof notifications[i] === 'object' && notifications[i] !== null) {
historyModel.append(notifications[i])
}
}
}
}
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
})
}
}
historyAdapter.notifications = historyArray
Qt.callLater(function() {
historyFileView.writeAdapter()
})
}
function addToHistory(notification) {
if (!notification.id) notification.id = Date.now()
if (!notification.timestamp) notification.timestamp = new Date().toISOString()
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}`;
}
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 {
spacing: 4
anchors.top: parent.top
anchors.topMargin: 16
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: 20
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.backgroundPrimary
radius: 20
border.width: 1
border.color: Theme.surfaceVariant
z: 0
}
Rectangle {
id: listContainer
anchors.fill: parent
anchors.topMargin: 12
anchors.bottomMargin: 12
color: "transparent"
clip: true
ListView {
id: historyList
anchors.fill: parent
spacing: 12
model: historyModel
delegate: Item {
height: notificationCard.implicitHeight + 12
Rectangle {
id: notificationCard
width: parent.width - 24
anchors.horizontalCenter: parent.horizontalCenter
color: Theme.backgroundPrimary
radius: 16
border.color: Theme.outline
border.width: 1
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
anchors.left: parent.left
anchors.right: parent.right
anchors.rightMargin: 0
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 || "Unknown App"
font.bold: true
color: Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
verticalAlignment: Text.AlignVCenter
}
Text {
text: formatTimestamp(model.timestamp)
color: Theme.textSecondary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeCaption
verticalAlignment: Text.AlignVCenter
}
}
Item { Layout.fillWidth: true }
Rectangle {
id: deleteButton
width: 24
height: 24
radius: 12
color: deleteMouseArea.containsMouse ? Theme.accentPrimary : Theme.surfaceVariant
border.color: Theme.accentPrimary
border.width: 1
Layout.alignment: Qt.AlignVCenter
z: 2
Row {
anchors.centerIn: parent
spacing: 0
Text {
text: "close"
font.family: "Material Symbols Outlined"
font.pixelSize: 16
color: deleteMouseArea.containsMouse ? Theme.onAccent : Theme.error
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: deleteMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
historyModel.remove(index)
saveHistory()
}
}
}
}
Text {
text: model.summary || ""
color: Theme.textSecondary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeBody
width: parent.width
wrapMode: Text.Wrap
}
Text {
text: model.body || ""
color: Theme.textDisabled
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeBody
width: parent.width
wrapMode: Text.Wrap
}
}
}
}
}
}
}
Rectangle { width: 1; height: 24; color: "transparent" }
}
}
}

View file

@ -0,0 +1,181 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Settings
PanelWindow {
id: window
width: 350
implicitHeight: notificationColumn.implicitHeight + 20
color: "transparent"
visible: false
screen: Quickshell.primaryScreen
focusable: false
anchors.top: true
anchors.right: true
margins.top: -20 // keep as you want
margins.right: 6
property var notifications: []
property int maxVisible: 5
property int spacing: 10
function addNotification(notification) {
var notifObj = {
id: notification.id,
appName: notification.appName || "Notification",
summary: notification.summary || "",
body: notification.body || "",
rawNotification: notification
};
notifications.unshift(notifObj);
if (notifications.length > maxVisible) {
notifications = notifications.slice(0, maxVisible);
}
visible = true;
notificationsChanged();
}
function dismissNotification(id) {
notifications = notifications.filter(n => n.id !== id);
if (notifications.length === 0) {
visible = false;
}
notificationsChanged();
}
Column {
id: notificationColumn
anchors.right: parent.right
spacing: window.spacing
width: parent.width
clip: false // prevent clipping during animation
Repeater {
model: notifications
delegate: Rectangle {
id: notificationDelegate
width: parent.width
height: contentColumn.height + 20
color: Theme.backgroundPrimary
radius: 20
opacity: 1
Column {
id: contentColumn
width: parent.width - 20
anchors.centerIn: parent
spacing: 5
Text {
text: modelData.appName
width: parent.width
color: "white"
font.family: Theme.fontFamily
font.bold: true
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Text {
text: modelData.summary
width: parent.width
color: "#eeeeee"
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
visible: text !== ""
}
Text {
text: modelData.body
width: parent.width
color: "#cccccc"
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeCaption
wrapMode: Text.Wrap
visible: text !== ""
}
}
Timer {
interval: 4000
running: true
onTriggered: {
dismissAnimation.start();
if (modelData.rawNotification) {
modelData.rawNotification.expire();
}
}
}
MouseArea {
anchors.fill: parent
onClicked: {
dismissAnimation.start();
if (modelData.rawNotification) {
modelData.rawNotification.dismiss();
}
}
}
ParallelAnimation {
id: dismissAnimation
NumberAnimation {
target: notificationDelegate
property: "opacity"
to: 0
duration: 300
}
NumberAnimation {
target: notificationDelegate
property: "height"
to: 0
duration: 300
}
onFinished: window.dismissNotification(modelData.id)
}
Component.onCompleted: {
opacity = 0;
height = 0;
appearAnimation.start();
}
ParallelAnimation {
id: appearAnimation
NumberAnimation {
target: notificationDelegate
property: "opacity"
to: 1
duration: 300
}
NumberAnimation {
target: notificationDelegate
property: "height"
to: contentColumn.height + 20
duration: 300
}
}
}
}
}
onNotificationsChanged: {
height = notificationColumn.implicitHeight + 20
}
Connections {
target: Quickshell
function onScreensChanged() {
if (window.screen) {
x = window.screen.width - width - 20
// y stays as it is (margins.top = -20)
}
}
}
}

View file

@ -0,0 +1,270 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Settings
PanelWindow {
id: window
implicitWidth: 350
implicitHeight: notificationColumn.implicitHeight
color: "transparent"
visible: notificationModel.count > 0
screen: Quickshell.primaryScreen !== undefined ? Quickshell.primaryScreen : null
focusable: false
property bool barVisible: true
anchors.top: true
anchors.right: true
margins.top: 6
margins.right: 6
ListModel {
id: notificationModel
}
property int maxVisible: 5
property int spacing: 5
function addNotification(notification) {
notificationModel.insert(0, {
id: notification.id,
appName: notification.appName || "Notification",
summary: notification.summary || "",
body: notification.body || "",
rawNotification: notification,
appeared: false,
dismissed: false
});
while (notificationModel.count > maxVisible) {
notificationModel.remove(notificationModel.count - 1);
}
}
function dismissNotificationById(id) {
for (var i = 0; i < notificationModel.count; i++) {
if (notificationModel.get(i).id === id) {
dismissNotificationByIndex(i);
break;
}
}
}
function dismissNotificationByIndex(index) {
if (index >= 0 && index < notificationModel.count) {
var notif = notificationModel.get(index);
if (!notif.dismissed) {
notificationModel.set(index, {
id: notif.id,
appName: notif.appName,
summary: notif.summary,
body: notif.body,
rawNotification: notif.rawNotification,
appeared: notif.appeared,
dismissed: true
});
}
}
}
Column {
id: notificationColumn
anchors.right: parent.right
spacing: window.spacing
width: parent.width
clip: false
Repeater {
id: notificationRepeater
model: notificationModel
delegate: Rectangle {
id: notificationDelegate
width: parent.width
color: Theme.backgroundPrimary
radius: 20
property bool appeared: model.appeared
property bool dismissed: model.dismissed
property var rawNotification: model.rawNotification
x: appeared ? 0 : width
opacity: dismissed ? 0 : 1
height: dismissed ? 0 : contentRow.height + 20
Row {
id: contentRow
anchors.centerIn: parent
spacing: 10
width: parent.width - 20
// Circular Icon container with border
Rectangle {
id: iconBackground
width: 36
height: 36
radius: width / 2 // Circular
color: Theme.accentPrimary
anchors.verticalCenter: parent.verticalCenter
border.color: Qt.darker(Theme.accentPrimary, 1.2)
border.width: 1.5
// Get all possible icon sources from notification
property var iconSources: [
rawNotification?.image || "",
rawNotification?.appIcon || "",
rawNotification?.icon || ""
]
// Try to load notification icon
Image {
id: iconImage
anchors.fill: parent
anchors.margins: 4
fillMode: Image.PreserveAspectFit
smooth: true
cache: false
asynchronous: true
sourceSize.width: 36
sourceSize.height: 36
source: {
for (var i = 0; i < iconBackground.iconSources.length; i++) {
var icon = iconBackground.iconSources[i];
if (!icon) continue;
if (icon.includes("?path=")) {
const [name, path] = icon.split("?path=");
const fileName = name.substring(name.lastIndexOf("/") + 1);
return `file://${path}/${fileName}`;
}
if (icon.startsWith('/')) {
return "file://" + icon;
}
return icon;
}
return "";
}
visible: status === Image.Ready && source.toString() !== ""
}
// Fallback to first letter of app name
Text {
anchors.centerIn: parent
visible: !iconImage.visible
text: model.appName ? model.appName.charAt(0).toUpperCase() : "?"
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeBody
font.bold: true
color: Theme.backgroundPrimary
}
}
Column {
width: contentRow.width - iconBackground.width - 10
spacing: 5
Text {
text: model.appName
width: parent.width
color: Theme.textPrimary
font.family: Theme.fontFamily
font.bold: true
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Text {
text: model.summary
width: parent.width
color: "#eeeeee"
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
visible: text !== ""
}
Text {
text: model.body
width: parent.width
color: "#cccccc"
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeCaption
wrapMode: Text.Wrap
visible: text !== ""
}
}
}
Timer {
interval: 4000
running: !dismissed
repeat: false
onTriggered: {
dismissAnimation.start();
if (rawNotification) rawNotification.expire();
}
}
MouseArea {
anchors.fill: parent
onClicked: {
dismissAnimation.start();
if (rawNotification) rawNotification.dismiss();
}
}
ParallelAnimation {
id: dismissAnimation
NumberAnimation { target: notificationDelegate; property: "opacity"; to: 0; duration: 150 }
NumberAnimation { target: notificationDelegate; property: "height"; to: 0; duration: 150 }
NumberAnimation { target: notificationDelegate; property: "x"; to: width; duration: 150; easing.type: Easing.InQuad }
onFinished: {
var idx = notificationRepeater.indexOf(notificationDelegate);
if (idx !== -1) {
notificationModel.remove(idx);
}
}
}
ParallelAnimation {
id: appearAnimation
NumberAnimation { target: notificationDelegate; property: "opacity"; to: 1; duration: 150 }
NumberAnimation { target: notificationDelegate; property: "height"; to: contentRow.height + 20; duration: 150 }
NumberAnimation { target: notificationDelegate; property: "x"; to: 0; duration: 150; easing.type: Easing.OutQuad }
}
Component.onCompleted: {
if (!appeared) {
opacity = 0;
height = 0;
x = width;
appearAnimation.start();
var idx = notificationRepeater.indexOf(notificationDelegate);
if (idx !== -1) {
var oldItem = notificationModel.get(idx);
notificationModel.set(idx, {
id: oldItem.id,
appName: oldItem.appName,
summary: oldItem.summary,
body: oldItem.body,
rawNotification: oldItem.rawNotification,
appeared: true,
dismissed: oldItem.dismissed
});
}
}
}
}
}
}
Connections {
target: Quickshell
function onScreensChanged() {
if (window.screen) {
x = window.screen.width - width - 20
}
}
}
}