diff --git a/applets/notifications/package/contents/ui/FullRepresentation.qml b/applets/notifications/package/contents/ui/FullRepresentation.qml index 02f442dd0..3fb98868b 100644 --- a/applets/notifications/package/contents/ui/FullRepresentation.qml +++ b/applets/notifications/package/contents/ui/FullRepresentation.qml @@ -1,556 +1,556 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see */ import QtQuick 2.8 import QtQuick.Layouts 1.1 import org.kde.plasma.plasmoid 2.0 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as PlasmaComponents import org.kde.plasma.components 3.0 as PlasmaComponents3 import org.kde.plasma.extras 2.0 as PlasmaExtras import org.kde.kcoreaddons 1.0 as KCoreAddons import org.kde.notificationmanager 1.0 as NotificationManager import "global" ColumnLayout{ // FIXME fix popup size when resizing panel smaller (so it collapses) //Layout.preferredWidth: units.gridUnit * 18 //Layout.preferredHeight: units.gridUnit * 24 //Layout.minimumWidth: units.gridUnit * 10 //Layout.minimumHeight: units.gridUnit * 15 Layout.fillHeight: plasmoid.formFactor === PlasmaCore.Types.Vertical spacing: units.smallSpacing // TODO these should be configurable in the future readonly property int dndMorningHour: 6 readonly property int dndEveningHour: 20 // HACK forward focus to the list onActiveFocusChanged: { if (activeFocus) { list.forceActiveFocus(); } } Connections { target: plasmoid onExpandedChanged: { if (plasmoid.expanded) { list.positionViewAtBeginning(); list.currentIndex = -1; } } } // header ColumnLayout { Layout.fillWidth: true spacing: 0 RowLayout { Layout.fillWidth: true RowLayout { id: dndRow spacing: units.smallSpacing PlasmaComponents3.CheckBox { id: dndCheck text: i18n("Do not disturb") spacing: units.smallSpacing checkable: true checked: Globals.inhibited // Let the menu open on press onPressed: { if (!Globals.inhibited) { dndMenu.date = new Date(); // shows ontop of CheckBox to hide the fact that it's unchecked // until you actually select something :) dndMenu.open(0, 0); } } // but disable only on click onClicked: { if (Globals.inhibited) { notificationSettings.notificationsInhibitedUntil = undefined; notificationSettings.revokeApplicationInhibitions(); notificationSettings.save(); } } contentItem: RowLayout { spacing: dndCheck.spacing PlasmaCore.IconItem { Layout.leftMargin: dndCheck.mirrored ? 0 : dndCheck.indicator.width + dndCheck.spacing Layout.rightMargin: dndCheck.mirrored ? dndCheck.indicator.width + dndCheck.spacing : 0 source: "notifications-disabled" Layout.preferredWidth: units.iconSizes.smallMedium Layout.preferredHeight: units.iconSizes.smallMedium } PlasmaComponents.Label { text: i18n("Do not disturb") } } PlasmaComponents.ModelContextMenu { id: dndMenu property date date visualParent: dndCheck onClicked: { notificationSettings.notificationsInhibitedUntil = model.date; notificationSettings.save(); } model: { var model = []; // For 1 hour var d = dndMenu.date; d.setHours(d.getHours() + 1); d.setSeconds(0); model.push({date: d, text: i18n("For 1 hour")}); d = dndMenu.date; d.setHours(d.getHours() + 4); d.setSeconds(0); model.push({date: d, text: i18n("For 4 hours")}); // Until this evening if (dndMenu.date.getHours() < dndEveningHour) { d = dndMenu.date; // TODO make the user's preferred time schedule configurable d.setHours(dndEveningHour); d.setMinutes(0); d.setSeconds(0); model.push({date: d, text: i18n("Until this evening")}); } // Until next morning if (dndMenu.date.getHours() > dndMorningHour) { d = dndMenu.date; d.setDate(d.getDate() + 1); d.setHours(dndMorningHour); d.setMinutes(0); d.setSeconds(0); model.push({date: d, text: i18n("Until tomorrow morning")}); } // Until Monday // show Friday and Saturday, Sunday is "0" but for that you can use "until tomorrow morning" if (dndMenu.date.getDay() >= 5) { d = dndMenu.date; d.setHours(dndMorningHour); // wraps around if neccessary d.setDate(d.getDate() + (7 - d.getDay() + 1)); d.setMinutes(0); d.setSeconds(0); model.push({date: d, text: i18n("Until Monday")}); } // Until "turned off" d = dndMenu.date; // Just set it to one year in the future so we don't need yet another "do not disturb enabled" property d.setFullYear(d.getFullYear() + 1); model.push({date: d, text: i18n("Until turned off")}); return model; } } } } Item { Layout.fillWidth: true } PlasmaComponents.ToolButton { iconName: "configure" tooltip: plasmoid.action("openKcm").text visible: plasmoid.action("openKcm").enabled onClicked: plasmoid.action("openKcm").trigger() } } PlasmaExtras.DescriptiveLabel { Layout.leftMargin: dndCheck.mirrored ? 0 : dndCheck.indicator.width + 2 * dndCheck.spacing + units.iconSizes.smallMedium Layout.rightMargin: dndCheck.mirrored ? dndCheck.indicator.width + 2 * dndCheck.spacing + units.iconSizes.smallMedium : 0 Layout.fillWidth: true wrapMode: Text.WordWrap textFormat: Text.PlainText text: { if (!Globals.inhibited) { return ""; } var inhibitedUntil = notificationSettings.notificationsInhibitedUntil; var inhibitedByApp = notificationSettings.notificationsInhibitedByApplication; var sections = []; // Show until time if valid but not if too far int he future if (!isNaN(inhibitedUntil.getTime()) && inhibitedUntil.getTime() - new Date().getTime() < 365 * 24 * 60 * 60 * 1000 /* 1 year*/) { sections.push(i18nc("Do not disturb until date", "Until %1", KCoreAddons.Format.formatRelativeDateTime(inhibitedUntil, Locale.ShortFormat))); } if (inhibitedByApp) { var inhibitionAppNames = notificationSettings.notificationInhibitionApplications; var inhibitionAppReasons = notificationSettings.notificationInhibitionReasons; for (var i = 0, length = inhibitionAppNames.length; i < length; ++i) { var name = inhibitionAppNames[i]; var reason = inhibitionAppReasons[i]; if (reason) { sections.push(i18nc("Do not disturb until app has finished (reason)", "While %1 is active (%2)", name, reason)); } else { sections.push(i18nc("Do not disturb until app has finished", "While %1 is active", name)); } } } return sections.join(" · "); } visible: text !== "" } } PlasmaCore.SvgItem { elementId: "horizontal-line" Layout.fillWidth: true // why is this needed here but not in the delegate? Layout.preferredHeight: naturalSize.height svg: PlasmaCore.Svg { id: lineSvg imagePath: "widgets/line" } } // actual notifications PlasmaExtras.ScrollArea { Layout.fillWidth: true Layout.fillHeight: true Layout.preferredWidth: units.gridUnit * 18 Layout.preferredHeight: units.gridUnit * 24 ListView { id: list model: historyModel currentIndex: -1 Keys.onDeletePressed: { var idx = historyModel.index(currentIndex, 0); if (historyModel.data(idx, NotificationManager.Notifications.ClosableRole)) { historyModel.close(idx); // TODO would be nice to stay inside the current group when deleting an item } } Keys.onEnterPressed: Keys.onReturnPressed(event) Keys.onReturnPressed: { // Trigger default action, if any var idx = historyModel.index(currentIndex, 0); if (historyModel.data(idx, NotificationManager.Notifications.HasDefaultActionRole)) { historyModel.invokeDefaultAction(idx); return; } // Trigger thumbnail URL if there's one var urls = historyModel.data(idx, NotificationManager.Notifications.UrlsRole); if (urls && urls.length === 1) { Qt.openUrlExternally(urls[0]); historyModel.expire(idx); return; } // TODO for finished jobs trigger "Open" or "Open Containing Folder" action } Keys.onLeftPressed: setGroupExpanded(currentIndex, LayoutMirroring.enabled) Keys.onRightPressed: setGroupExpanded(currentIndex, !LayoutMirroring.enabled) Keys.onPressed: { switch (event.key) { case Qt.Key_Home: currentIndex = 0; break; case Qt.Key_End: currentIndex = count - 1; break; } } function isRowExpanded(row) { var idx = historyModel.index(row, 0); return historyModel.data(idx, NotificationManager.Notifications.IsGroupExpandedRole); } function setGroupExpanded(row, expanded) { var rowIdx = historyModel.index(row, 0); var persistentRowIdx = historyModel.makePersistentModelIndex(rowIdx); var persistentGroupIdx = historyModel.makePersistentModelIndex(historyModel.groupIndex(rowIdx)); historyModel.setData(rowIdx, expanded, NotificationManager.Notifications.IsGroupExpandedRole); // If the current item went away when the group collapsed, scroll to the group heading if (!persistentRowIdx || !persistentRowIdx.valid) { if (persistentGroupIdx && persistentGroupIdx.valid) { list.positionViewAtIndex(persistentGroupIdx.row, ListView.Contain); // When closed via keyboard, also set a sane current index if (list.currentIndex > -1) { list.currentIndex = persistentGroupIdx.row; } } } } highlightMoveDuration: 0 highlightResizeDuration: 0 // Not using PlasmaComponents.Highlight as this is only for indicating keyboard focus highlight: PlasmaCore.FrameSvgItem { imagePath: "widgets/listitem" prefix: "pressed" } header: RowLayout { width: list.width PlasmaExtras.Heading { Layout.fillWidth: true level: 3 opacity: 0.6 text: list.count === 0 ? i18n("No unread notifications.") : i18n("Notifications") } PlasmaComponents.ToolButton { iconName: "edit-clear-history" tooltip: i18n("Clear History") visible: plasmoid.action("clearHistory").visible onClicked: action_clearHistory() } } add: Transition { SequentialAnimation { PauseAnimation { duration: units.longDuration } ParallelAnimation { NumberAnimation { property: "opacity"; from: 0; duration: units.longDuration } NumberAnimation { property: "height"; from: 0; duration: units.longDuration } } } } addDisplaced: Transition { NumberAnimation { properties: "y"; duration: units.longDuration } } remove: Transition { ParallelAnimation { NumberAnimation { property: "opacity"; to: 0; duration: units.longDuration } NumberAnimation { property: "x"; to: list.width; duration: units.longDuration } } } removeDisplaced: Transition { SequentialAnimation { PauseAnimation { duration: units.longDuration } NumberAnimation { properties: "y"; duration: units.longDuration } } } // This is so the delegates can detect the change in "isInGroup" and show a separator section { property: "isInGroup" criteria: ViewSection.FullString } delegate: Loader { id: delegateLoader width: list.width sourceComponent: model.isGroup ? groupDelegate : notificationDelegate Component { id: groupDelegate NotificationHeader { applicationName: model.applicationName applicationIconSource: model.applicationIconName // don't show timestamp for group configurable: model.configurable closable: model.closable closeButtonTooltip: i18n("Close Group") onCloseClicked: { historyModel.close(historyModel.index(index, 0)) if (list.count === 0) { plasmoid.expanded = false; } } onConfigureClicked: historyModel.configure(historyModel.index(index, 0)) } } Component { id: notificationDelegate ColumnLayout { spacing: units.smallSpacing RowLayout { Item { id: groupLineContainer Layout.fillHeight: true Layout.topMargin: units.smallSpacing width: units.iconSizes.small visible: model.isInGroup PlasmaCore.SvgItem { elementId: "vertical-line" svg: lineSvg anchors.horizontalCenter: parent.horizontalCenter width: units.iconSizes.small height: parent.height } } NotificationItem { Layout.fillWidth: true notificationType: model.type inGroup: model.isInGroup applicationName: model.applicationName applicationIconSource: model.applicationIconName - deviceName: model.deviceName || "" + originName: model.originName || "" time: model.updated || model.created // configure button on every single notifications is bit overwhelming configurable: !inGroup && model.configurable dismissable: model.type === NotificationManager.Notifications.JobType && model.jobState !== NotificationManager.Notifications.JobStateStopped && model.dismissed // TODO would be nice to be able to undismiss jobs even when they autohide && notificationSettings.permanentJobPopups dismissed: model.dismissed || false closable: model.closable summary: model.summary body: model.body || "" icon: model.image || model.iconName urls: model.urls || [] jobState: model.jobState || 0 percentage: model.percentage || 0 jobError: model.jobError || 0 suspendable: !!model.suspendable killable: !!model.killable jobDetails: model.jobDetails || null configureActionLabel: model.configureActionLabel || "" // In the popup the default action is triggered by clicking on the popup // however in the list this is undesirable, so instead show a clickable button // in case you have a non-expired notification in history (do not disturb mode) // unless it has the same label as an action readonly property bool addDefaultAction: (model.hasDefaultAction && model.defaultActionLabel && (model.actionLabels || []).indexOf(model.defaultActionLabel) === -1) ? true : false actionNames: { var actions = (model.actionNames || []); if (addDefaultAction) { actions.unshift("default"); // prepend } return actions; } actionLabels: { var labels = (model.actionLabels || []); if (addDefaultAction) { labels.unshift(model.defaultActionLabel); } return labels; } onCloseClicked: { historyModel.close(historyModel.index(index, 0)); if (list.count === 0) { plasmoid.expanded = false; } } onDismissClicked: model.dismissed = false onConfigureClicked: historyModel.configure(historyModel.index(index, 0)) onActionInvoked: { if (actionName === "default") { historyModel.invokeDefaultAction(historyModel.index(index, 0)); } else { historyModel.invokeAction(historyModel.index(index, 0), actionName); } // Keep it in the history historyModel.expire(historyModel.index(index, 0)); } onOpenUrl: { Qt.openUrlExternally(url); historyModel.expire(historyModel.index(index, 0)); } onFileActionInvoked: historyModel.expire(historyModel.index(index, 0)) onSuspendJobClicked: historyModel.suspendJob(historyModel.index(index, 0)) onResumeJobClicked: historyModel.resumeJob(historyModel.index(index, 0)) onKillJobClicked: historyModel.killJob(historyModel.index(index, 0)) } } PlasmaComponents.ToolButton { Layout.preferredWidth: minimumWidth iconName: model.isGroupExpanded ? "arrow-up" : "arrow-down" text: model.isGroupExpanded ? i18n("Show Fewer") : i18nc("Expand to show n more notifications", "Show %1 More", (model.groupChildrenCount - model.expandedGroupChildrenCount)) visible: (model.groupChildrenCount > model.expandedGroupChildrenCount || model.isGroupExpanded) && delegateLoader.ListView.nextSection !== delegateLoader.ListView.section onClicked: list.setGroupExpanded(model.index, !model.isGroupExpanded) } PlasmaCore.SvgItem { Layout.fillWidth: true Layout.bottomMargin: units.smallSpacing elementId: "horizontal-line" svg: lineSvg // property is only atached to the delegate itself (the Loader in our case) visible: (!model.isInGroup || delegateLoader.ListView.nextSection !== delegateLoader.ListView.section) && delegateLoader.ListView.nextSection !== "" // don't show after last item } } } } } } } diff --git a/applets/notifications/package/contents/ui/NotificationHeader.qml b/applets/notifications/package/contents/ui/NotificationHeader.qml index 11f3f783e..50cc95647 100644 --- a/applets/notifications/package/contents/ui/NotificationHeader.qml +++ b/applets/notifications/package/contents/ui/NotificationHeader.qml @@ -1,217 +1,217 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see */ import QtQuick 2.8 import QtQuick.Layouts 1.1 import QtQuick.Window 2.2 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as PlasmaComponents import org.kde.plasma.extras 2.0 as PlasmaExtras import org.kde.notificationmanager 1.0 as NotificationManager import org.kde.kcoreaddons 1.0 as KCoreAddons import "global" RowLayout { id: notificationHeading property bool inGroup property int notificationType property var applicationIconSource property string applicationName - property string deviceName + property string originName property string configureActionLabel property alias configurable: configureButton.visible property alias dismissable: dismissButton.visible property bool dismissed property alias closeButtonTooltip: closeButton.tooltip property alias closable: closeButton.visible property var time property int jobState property QtObject jobDetails signal configureClicked signal dismissClicked signal closeClicked // notification created/updated time changed onTimeChanged: updateAgoText() function updateAgoText() { ageLabel.agoText = ageLabel.generateAgoText(); } spacing: units.smallSpacing Layout.preferredHeight: Math.max(applicationNameLabel.implicitHeight, units.iconSizes.small) Connections { target: Globals // clock time changed onTimeChanged: notificationHeading.updateAgoText() } PlasmaCore.IconItem { id: applicationIconItem Layout.preferredWidth: units.iconSizes.small Layout.preferredHeight: units.iconSizes.small source: notificationHeading.applicationIconSource usesPlasmaTheme: false visible: valid } PlasmaExtras.DescriptiveLabel { id: applicationNameLabel Layout.fillWidth: true textFormat: Text.PlainText elide: Text.ElideRight - text: notificationHeading.applicationName + (notificationHeading.deviceName ? " · " + notificationHeading.deviceName : "") + text: notificationHeading.applicationName + (notificationHeading.originName ? " · " + notificationHeading.originName : "") } PlasmaExtras.DescriptiveLabel { id: ageLabel // the "n minutes ago" text, for jobs we show remaining time instead // updated periodically by a Timer hence this property with generate() function property string agoText: "" visible: text !== "" text: generateRemainingText() || agoText Layout.rightMargin: -notificationHeading.spacing // the ToolButton's margins are enough function generateAgoText() { if (!time || isNaN(time.getTime()) || notificationHeading.jobState === NotificationManager.Notifications.JobStateRunning) { return ""; } var now = new Date(); var deltaMinutes = Math.floor((now.getTime() - time.getTime()) / 1000 / 60); if (deltaMinutes < 1) { return ""; } // Received less than an hour ago, show relative minutes if (deltaMinutes < 60) { return i18ncp("Notification was added minutes ago, keep short", "%1 min ago", "%1 min ago", deltaMinutes); } // Received less than a day ago, show time, 22 hours so the time isn't as ambiguous between today and yesterday if (deltaMinutes < 60 * 22) { return Qt.formatTime(time, Qt.locale().timeFormat(Locale.ShortFormat).replace(/.ss?/i, "")); } // Otherwise show relative date (Yesterday, "Last Sunday", or just date if too far in the past) return KCoreAddons.Format.formatRelativeDate(time, Locale.ShortFormat); } function generateRemainingText() { if (notificationHeading.notificationType !== NotificationManager.Notifications.JobType || notificationHeading.jobState === NotificationManager.Notifications.JobStateStopped) { return ""; } var details = notificationHeading.jobDetails; if (!details || !details.speed) { return ""; } var remaining = details.totalBytes - details.processedBytes; if (remaining <= 0) { return ""; } var eta = remaining / details.speed; if (!eta) { return ""; } if (eta < 60) { // 1 minute return i18ncp("seconds remaining, keep short", "%1 s remaining", "%1 s remaining", Math.round(eta)); } if (eta < 60 * 60) {// 1 hour return i18ncp("minutes remaining, keep short", "%1 min remaining", "%1 min remaining", Math.round(eta / 60)); } if (eta < 60 * 60 * 5) { // 5 hours max, if it takes even longer there's no real point in shoing that return i18ncp("hours remaining, keep short", "%1 h remaining", "%1 h remaining", Math.round(eta / 60 / 60)); } return ""; } PlasmaCore.ToolTipArea { anchors.fill: parent active: ageLabel.agoText !== "" subText: notificationHeading.time ? notificationHeading.time.toLocaleString(Qt.locale(), Locale.LongFormat) : "" } } RowLayout { id: headerButtonsRow spacing: 0 PlasmaComponents.ToolButton { id: configureButton tooltip: notificationHeading.configureActionLabel || i18n("Configure") iconSource: "configure" visible: false onClicked: notificationHeading.configureClicked() } PlasmaComponents.ToolButton { id: dismissButton tooltip: notificationHeading.dismissed ? i18nc("Opposite of minimize", "Restore") : i18n("Minimize") iconSource: notificationHeading.dismissed ? "window-restore" : "window-minimize" visible: false onClicked: notificationHeading.dismissClicked() } PlasmaComponents.ToolButton { id: closeButton tooltip: i18n("Close") iconSource: "window-close" visible: false onClicked: notificationHeading.closeClicked() } } states: [ State { when: notificationHeading.inGroup PropertyChanges { target: applicationIconItem source: "" } PropertyChanges { target: applicationNameLabel visible: false } } ] } diff --git a/applets/notifications/package/contents/ui/NotificationItem.qml b/applets/notifications/package/contents/ui/NotificationItem.qml index 3b0b9953d..cb33b8241 100644 --- a/applets/notifications/package/contents/ui/NotificationItem.qml +++ b/applets/notifications/package/contents/ui/NotificationItem.qml @@ -1,364 +1,364 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see */ import QtQuick 2.8 import QtQuick.Layouts 1.1 import QtQuick.Window 2.2 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as PlasmaComponents import org.kde.plasma.extras 2.0 as PlasmaExtras import org.kde.kquickcontrolsaddons 2.0 as KQCAddons import org.kde.notificationmanager 1.0 as NotificationManager ColumnLayout { id: notificationItem property bool hovered: false property int maximumLineCount: 0 property alias bodyCursorShape: bodyLabel.cursorShape property int notificationType property bool inGroup: false property alias applicationIconSource: notificationHeading.applicationIconSource property alias applicationName: notificationHeading.applicationName - property alias deviceName: notificationHeading.deviceName + property alias originName: notificationHeading.originName property string summary property alias time: notificationHeading.time property alias configurable: notificationHeading.configurable property alias dismissable: notificationHeading.dismissable property alias dismissed: notificationHeading.dismissed property alias closable: notificationHeading.closable // This isn't an alias because TextEdit RichText adds some HTML tags to it property string body property var icon property var urls: [] property int jobState property int percentage property int jobError: 0 property bool suspendable property bool killable property QtObject jobDetails property bool showDetails property alias configureActionLabel: notificationHeading.configureActionLabel property var actionNames: [] property var actionLabels: [] property int headingLeftPadding: 0 property int headingRightPadding: 0 property int thumbnailLeftPadding: 0 property int thumbnailRightPadding: 0 property int thumbnailTopPadding: 0 property int thumbnailBottomPadding: 0 readonly property bool menuOpen: bodyLabel.contextMenu !== null || (thumbnailStripLoader.item && thumbnailStripLoader.item.menuOpen) || (jobLoader.item && jobLoader.item.menuOpen) readonly property bool dragging: thumbnailStripLoader.item && thumbnailStripLoader.item.dragging signal bodyClicked(var mouse) signal closeClicked signal configureClicked signal dismissClicked signal actionInvoked(string actionName) signal openUrl(string url) signal fileActionInvoked signal suspendJobClicked signal resumeJobClicked signal killJobClicked spacing: units.smallSpacing NotificationHeader { id: notificationHeading Layout.fillWidth: true Layout.leftMargin: notificationItem.headingLeftPadding Layout.rightMargin: notificationItem.headingRightPadding inGroup: notificationItem.inGroup notificationType: notificationItem.notificationType jobState: notificationItem.jobState jobDetails: notificationItem.jobDetails onConfigureClicked: notificationItem.configureClicked() onDismissClicked: notificationItem.dismissClicked() onCloseClicked: notificationItem.closeClicked() } RowLayout { id: defaultHeaderContainer Layout.fillWidth: true } // Notification body RowLayout { id: bodyRow Layout.fillWidth: true spacing: units.smallSpacing ColumnLayout { Layout.fillWidth: true spacing: 0 RowLayout { id: summaryRow Layout.fillWidth: true visible: summaryLabel.text !== "" PlasmaExtras.Heading { id: summaryLabel Layout.fillWidth: true Layout.preferredHeight: implicitHeight textFormat: Text.PlainText maximumLineCount: 3 wrapMode: Text.WordWrap elide: Text.ElideRight level: 4 text: { if (notificationItem.notificationType === NotificationManager.Notifications.JobType) { if (notificationItem.jobState === NotificationManager.Notifications.JobStateSuspended) { if (notificationItem.summary) { return i18nc("Job name, e.g. Copying is paused", "%1 (Paused)", notificationItem.summary); } } else if (notificationItem.jobState === NotificationManager.Notifications.JobStateStopped) { if (notificationItem.jobError) { if (notificationItem.summary) { return i18nc("Job name, e.g. Copying has failed", "%1 (Failed)", notificationItem.summary); } else { return i18n("Job Failed"); } } else { if (notificationItem.summary) { return i18nc("Job name, e.g. Copying has finished", "%1 (Finished)", notificationItem.summary); } else { return i18n("Job Finished"); } } } } // some apps use their app name as summary, avoid showing the same text twice // try very hard to match the two if (notificationItem.summary && notificationItem.summary.toLocaleLowerCase().trim() != notificationItem.applicationName.toLocaleLowerCase().trim()) { return notificationItem.summary; } return ""; } visible: text !== "" } // inGroup headerItem is reparented here } RowLayout { id: bodyTextRow Layout.fillWidth: true spacing: units.smallSpacing SelectableLabel { id: bodyLabel // FIXME how to assign this via State? target: bodyLabel.Layout doesn't work and just assigning the property doesn't either Layout.alignment: notificationItem.inGroup ? Qt.AlignTop : Qt.AlignVCenter Layout.fillWidth: true Layout.maximumHeight: notificationItem.maximumLineCount > 0 ? (theme.mSize(font).height * notificationItem.maximumLineCount) : -1 text: notificationItem.body // Cannot do text !== "" because RichText adds some HTML tags even when empty visible: notificationItem.body !== "" onClicked: notificationItem.bodyClicked(mouse) onLinkActivated: Qt.openUrlExternally(link) } // inGroup iconContainer is reparented here } } Item { id: iconContainer Layout.preferredWidth: units.iconSizes.large Layout.preferredHeight: units.iconSizes.large visible: iconItem.active || imageItem.active PlasmaCore.IconItem { id: iconItem // don't show two identical icons readonly property bool active: valid && source != notificationItem.applicationIconSource anchors.fill: parent usesPlasmaTheme: false smooth: true source: { var icon = notificationItem.icon; if (typeof icon !== "string") { // displayed by QImageItem below return ""; } // don't show a generic "info" icon since this is a notification already if (icon === "dialog-information") { return ""; } return icon; } visible: active } KQCAddons.QImageItem { id: imageItem readonly property bool active: !null && nativeWidth > 0 anchors.fill: parent smooth: true fillMode: KQCAddons.QImageItem.PreserveAspectFit visible: active image: typeof notificationItem.icon === "object" ? notificationItem.icon : undefined } } } // Job progress reporting Loader { id: jobLoader Layout.fillWidth: true active: notificationItem.notificationType === NotificationManager.Notifications.JobType visible: active sourceComponent: JobItem { jobState: notificationItem.jobState jobError: notificationItem.jobError percentage: notificationItem.percentage suspendable: notificationItem.suspendable killable: notificationItem.killable jobDetails: notificationItem.jobDetails showDetails: notificationItem.showDetails onSuspendJobClicked: notificationItem.suspendJobClicked() onResumeJobClicked: notificationItem.resumeJobClicked() onKillJobClicked: notificationItem.killJobClicked() onOpenUrl: notificationItem.openUrl(url) onFileActionInvoked: notificationItem.fileActionInvoked() hovered: notificationItem.hovered } } RowLayout { Layout.fillWidth: true visible: actionRepeater.count > 0 // Notification actions Flow { // it's a Flow so it can wrap if too long Layout.fillWidth: true spacing: units.smallSpacing layoutDirection: Qt.RightToLeft Repeater { id: actionRepeater model: { var buttons = []; // HACK We want the actions to be right-aligned but Flow also reverses var actionNames = (notificationItem.actionNames || []).reverse(); var actionLabels = (notificationItem.actionLabels || []).reverse(); for (var i = 0; i < actionNames.length; ++i) { buttons.push({ actionName: actionNames[i], label: actionLabels[i] }); } return buttons; } PlasmaComponents.ToolButton { flat: false // why does it spit "cannot assign undefined to string" when a notification becomes expired? text: modelData.label || "" Layout.preferredWidth: minimumWidth onClicked: notificationItem.actionInvoked(modelData.actionName) } } } } // thumbnails Loader { id: thumbnailStripLoader Layout.leftMargin: notificationItem.thumbnailLeftPadding Layout.rightMargin: notificationItem.thumbnailRightPadding Layout.topMargin: notificationItem.thumbnailTopPadding Layout.bottomMargin: notificationItem.thumbnailBottomPadding Layout.fillWidth: true active: notificationItem.urls.length > 0 visible: active sourceComponent: ThumbnailStrip { leftPadding: -thumbnailStripLoader.Layout.leftMargin rightPadding: -thumbnailStripLoader.Layout.rightMargin topPadding: -thumbnailStripLoader.Layout.topMargin bottomPadding: -thumbnailStripLoader.Layout.bottomMargin urls: notificationItem.urls onOpenUrl: notificationItem.openUrl(url) onFileActionInvoked: notificationItem.fileActionInvoked() } } states: [ State { when: notificationItem.inGroup PropertyChanges { target: notificationHeading parent: summaryRow } PropertyChanges { target: summaryRow visible: true } PropertyChanges { target: summaryLabel visible: true } /*PropertyChanges { target: bodyLabel.Label alignment: Qt.AlignTop }*/ PropertyChanges { target: iconContainer parent: bodyTextRow } } ] } diff --git a/applets/notifications/package/contents/ui/NotificationPopup.qml b/applets/notifications/package/contents/ui/NotificationPopup.qml index c6fdfe6c8..96373c10b 100644 --- a/applets/notifications/package/contents/ui/NotificationPopup.qml +++ b/applets/notifications/package/contents/ui/NotificationPopup.qml @@ -1,206 +1,206 @@ /* * Copyright 2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see */ import QtQuick 2.8 import QtQuick.Layouts 1.1 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as Components import org.kde.notificationmanager 1.0 as NotificationManager import ".." PlasmaCore.Dialog { id: notificationPopup property int popupWidth property alias notificationType: notificationItem.notificationType property alias applicationName: notificationItem.applicationName property alias applicationIconSource: notificationItem.applicationIconSource - property alias deviceName: notificationItem.deviceName + property alias originName: notificationItem.originName property alias time: notificationItem.time property alias summary: notificationItem.summary property alias body: notificationItem.body property alias icon: notificationItem.icon property alias urls: notificationItem.urls property int urgency property int timeout property int dismissTimeout property alias jobState: notificationItem.jobState property alias percentage: notificationItem.percentage property alias jobError: notificationItem.jobError property alias suspendable: notificationItem.suspendable property alias killable: notificationItem.killable property alias jobDetails: notificationItem.jobDetails property alias configureActionLabel: notificationItem.configureActionLabel property alias configurable: notificationItem.configurable property alias dismissable: notificationItem.dismissable property alias closable: notificationItem.closable property bool hasDefaultAction property alias actionNames: notificationItem.actionNames property alias actionLabels: notificationItem.actionLabels signal configureClicked signal dismissClicked signal closeClicked signal defaultActionInvoked signal actionInvoked(string actionName) signal openUrl(string url) signal fileActionInvoked signal expired signal suspendJobClicked signal resumeJobClicked signal killJobClicked property int defaultTimeout: 5000 readonly property int effectiveTimeout: { if (timeout === -1) { return defaultTimeout; } if (dismissTimeout) { return dismissTimeout; } return timeout; } location: PlasmaCore.Types.Floating flags: Qt.WindowDoesNotAcceptFocus visible: false // When notification is updated, restart hide timer onTimeChanged: { if (timer.running) { timer.restart(); } } mainItem: MouseArea { id: area width: notificationPopup.popupWidth height: notificationItem.implicitHeight + notificationItem.y hoverEnabled: true cursorShape: hasDefaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor acceptedButtons: hasDefaultAction ? Qt.LeftButton : Qt.NoButton onClicked: notificationPopup.defaultActionInvoked() LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft LayoutMirroring.childrenInherit: true Timer { id: timer interval: notificationPopup.effectiveTimeout running: notificationPopup.visible && !area.containsMouse && interval > 0 && !notificationItem.dragging && !notificationItem.menuOpen onTriggered: { if (notificationPopup.dismissTimeout) { notificationPopup.dismissClicked(); } else { notificationPopup.expired(); } } } Timer { id: timeoutIndicatorDelayTimer // only show indicator for the last ten seconds of timeout readonly property int remainingTimeout: 10000 interval: Math.max(0, timer.interval - remainingTimeout) running: interval > 0 && timer.running } Rectangle { id: timeoutIndicatorRect anchors { right: parent.right rightMargin: -notificationPopup.margins.right bottom: parent.bottom bottomMargin: -notificationPopup.margins.bottom } width: units.devicePixelRatio * 3 color: theme.highlightColor opacity: timeoutIndicatorAnimation.running ? 0.6 : 0 visible: units.longDuration > 1 Behavior on opacity { NumberAnimation { duration: units.longDuration } } NumberAnimation { id: timeoutIndicatorAnimation target: timeoutIndicatorRect property: "height" from: area.height + notificationPopup.margins.top + notificationPopup.margins.bottom to: 0 duration: Math.min(timer.interval, timeoutIndicatorDelayTimer.remainingTimeout) running: timer.running && !timeoutIndicatorDelayTimer.running && units.longDuration > 1 } } NotificationItem { id: notificationItem // let the item bleed into the dialog margins so the close button margins cancel out y: closable || dismissable || configurable ? -notificationPopup.margins.top : 0 headingRightPadding: -notificationPopup.margins.right width: parent.width hovered: area.containsMouse maximumLineCount: 8 bodyCursorShape: notificationPopup.hasDefaultAction ? Qt.PointingHandCursor : 0 thumbnailLeftPadding: -notificationPopup.margins.left thumbnailRightPadding: -notificationPopup.margins.right thumbnailTopPadding: -notificationPopup.margins.top thumbnailBottomPadding: -notificationPopup.margins.bottom closable: true onBodyClicked: { if (area.acceptedButtons & mouse.button) { area.clicked(null /*mouse*/); } } onCloseClicked: notificationPopup.closeClicked() onDismissClicked: notificationPopup.dismissClicked() onConfigureClicked: notificationPopup.configureClicked() onActionInvoked: notificationPopup.actionInvoked(actionName) onOpenUrl: notificationPopup.openUrl(url) onFileActionInvoked: notificationPopup.fileActionInvoked() onSuspendJobClicked: notificationPopup.suspendJobClicked() onResumeJobClicked: notificationPopup.resumeJobClicked() onKillJobClicked: notificationPopup.killJobClicked() } } } diff --git a/applets/notifications/package/contents/ui/global/Globals.qml b/applets/notifications/package/contents/ui/global/Globals.qml index ce83b1985..2e291b443 100644 --- a/applets/notifications/package/contents/ui/global/Globals.qml +++ b/applets/notifications/package/contents/ui/global/Globals.qml @@ -1,428 +1,428 @@ /* * Copyright 2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see */ pragma Singleton import QtQuick 2.8 import QtQuick.Layouts 1.1 import org.kde.plasma.plasmoid 2.0 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as Components import org.kde.kquickcontrolsaddons 2.0 import org.kde.notificationmanager 1.0 as NotificationManager import ".." // This singleton object contains stuff shared between all notification plasmoids, namely: // - Popup creation and placement // - Do not disturb mode QtObject { id: globals // Listened to by "ago" label in NotificationHeader to update all of them in unison signal timeChanged property bool inhibited: false onInhibitedChanged: { var pa = pulseAudio.item; if (!pa) { return; } var stream = pa.notificationStream; if (!stream) { return; } if (inhibited) { // Only remember that we muted if previously not muted. if (!stream.muted) { notificationSettings.notificationSoundsInhibited = true; stream.mute(); } } else { // Only unmute if we previously muted it. if (notificationSettings.notificationSoundsInhibited) { stream.unmute(); } notificationSettings.notificationSoundsInhibited = false; } notificationSettings.save(); } // Some parts of the code rely on plasmoid.nativeInterface and since we're in a singleton here // this is named "plasmoid" property QtObject plasmoid: plasmoids[0] // HACK When a plasmoid is destroyed, QML sets its value to "null" in the Array // so we then remove it so we have a working "plasmoid" again onPlasmoidChanged: { if (!plasmoid) { // this doesn't emit a change, only in ratePlasmoids() it will detect the change plasmoids.splice(0, 1); // remove first ratePlasmoids(); } } // all notification plasmoids property var plasmoids: [] property int popupLocation: { switch (notificationSettings.popupPosition) { // Auto-determine location based on plasmoid location case NotificationManager.Settings.CloseToWidget: if (!plasmoid) { return Qt.AlignBottom | Qt.AlignRight; // just in case } var alignment = 0; if (plasmoid.location === PlasmaCore.Types.LeftEdge) { alignment |= Qt.AlignLeft; } else if (plasmoid.location === PlasmaCore.Types.RightEdge) { alignment |= Qt.AlignRight; } else { // would be nice to do plasmoid.compactRepresentationItem.mapToItem(null) and then // position the popups depending on the relative position within the panel alignment |= Qt.application.layoutDirection === Qt.RightToLeft ? Qt.AlignLeft : Qt.AlignRight; } if (plasmoid.location === PlasmaCore.Types.TopEdge) { alignment |= Qt.AlignTop; } else { alignment |= Qt.AlignBottom; } return alignment; case NotificationManager.Settings.TopLeft: return Qt.AlignTop | Qt.AlignLeft; case NotificationManager.Settings.TopCenter: return Qt.AlignTop | Qt.AlignHCenter; case NotificationManager.Settings.TopRight: return Qt.AlignTop | Qt.AlignRight; case NotificationManager.Settings.BottomLeft: return Qt.AlignBottom | Qt.AlignLeft; case NotificationManager.Settings.BottomCenter: return Qt.AlignBottom | Qt.AlignHCenter; case NotificationManager.Settings.BottomRight: return Qt.AlignBottom | Qt.AlignRight; } } // The raw width of the popup's content item, the Dialog itself adds some margins property int popupWidth: units.gridUnit * 18 property int popupEdgeDistance: units.largeSpacing property int popupSpacing: units.largeSpacing // How much vertical screen real estate the notification popups may consume readonly property real popupMaximumScreenFill: 0.75 property var screenRect: plasmoid ? plasmoid.availableScreenRect : undefined onPopupLocationChanged: Qt.callLater(positionPopups) onScreenRectChanged: Qt.callLater(positionPopups) Component.onCompleted: checkInhibition() function adopt(plasmoid) { // this doesn't emit a change, only in ratePlasmoids() it will detect the change globals.plasmoids.push(plasmoid); ratePlasmoids(); } // Sorts plasmoids based on a heuristic to find a suitable plasmoid to follow when placing popups function ratePlasmoids() { var plasmoidScore = function(plasmoid) { if (!plasmoid) { return 0; } var score = 0; // Prefer plasmoids in a panel, prefer horizontal panels over vertical ones if (plasmoid.location === PlasmaCore.Types.LeftEdge || plasmoid.location === PlasmaCore.Types.RightEdge) { score += 1; } else if (plasmoid.location === PlasmaCore.Types.TopEdge || plasmoid.location === PlasmaCore.Types.BottomEdge) { score += 2; } // Prefer iconified plasmoids if (!plasmoid.expanded) { ++score; } // Prefer plasmoids on primary screen if (plasmoid.nativeInterface && plasmoid.nativeInterface.isPrimaryScreen(plasmoid.screenGeometry)) { ++score; } return score; } var newPlasmoids = plasmoids; newPlasmoids.sort(function (a, b) { var scoreA = plasmoidScore(a); var scoreB = plasmoidScore(b); // Sort descending by score if (scoreA < scoreB) { return 1; } else if (scoreA > scoreB) { return -1; } else { return 0; } }); globals.plasmoids = newPlasmoids; } function checkInhibition() { globals.inhibited = Qt.binding(function() { var inhibited = false; var inhibitedUntil = notificationSettings.notificationsInhibitedUntil; if (!isNaN(inhibitedUntil.getTime())) { inhibited |= (new Date().getTime() < inhibitedUntil.getTime()); } if (notificationSettings.notificationsInhibitedByApplication) { inhibited |= true; } return inhibited; }); } function positionPopups() { var rect = screenRect; if (!rect || rect.width <= 0 || rect.height <= 0) { return; } var y = screenRect.y; if (popupLocation & Qt.AlignBottom) { y += screenRect.height; } else { y += popupEdgeDistance; } var x = screenRect.x; if (popupLocation & Qt.AlignLeft) { x += popupEdgeDistance; } for (var i = 0; i < popupInstantiator.count; ++i) { var popup = popupInstantiator.objectAt(i); if (popupLocation & Qt.AlignHCenter) { popup.x = x + (screenRect.width - popup.width) / 2; } else if (popupLocation & Qt.AlignRight) { popup.x = screenRect.width - popupEdgeDistance - popup.width; } else { popup.x = x; } var delta = popupSpacing + popup.height; if (popupLocation & Qt.AlignTop) { popup.y = y; y += delta; } else { y -= delta; popup.y = y; } // don't let notifications take more than popupMaximumScreenFill of the screen var visible = true; if (i > 0) { // however always show at least one popup if (popupLocation & Qt.AlignTop) { visible = (popup.y + popup.height < screenRect.y + (screenRect.height * popupMaximumScreenFill)); } else { visible = (popup.y > screenRect.y + (screenRect.height * (1 - popupMaximumScreenFill))); } } // TODO would be nice to hide popups when systray or panel controller is open popup.visible = visible; } } property QtObject popupNotificationsModel: NotificationManager.Notifications { limit: globals.screenRect ? (Math.ceil(globals.screenRect.height / (theme.mSize(theme.defaultFont).height * 4))) : 0 showExpired: false showDismissed: false blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications blacklistedNotifyRcNames: notificationSettings.popupBlacklistedServices whitelistedDesktopEntries: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedApplications : [] whitelistedNotifyRcNames: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedServices : [] showJobs: notificationSettings.jobsInNotifications sortMode: NotificationManager.Notifications.SortByTypeAndUrgency groupMode: NotificationManager.Notifications.GroupDisabled urgencies: { var urgencies = 0; // Critical always except in do not disturb mode when disabled in settings if (!globals.inhibited || notificationSettings.criticalPopupsInDoNotDisturbMode) { urgencies |= NotificationManager.Notifications.CriticalUrgency; } // Normal only when not in do not disturb mode if (!globals.inhibited) { urgencies |= NotificationManager.Notifications.NormalUrgency; } // Low only when enabled in settings and not in do not disturb mode if (!globals.inhibited && notificationSettings.lowPriorityPopups) { urgencies |=NotificationManager.Notifications.LowUrgency; } return urgencies; } } property QtObject notificationSettings: NotificationManager.Settings { onNotificationsInhibitedUntilChanged: globals.checkInhibition() } // This periodically checks whether do not disturb mode timed out and updates the "minutes ago" labels property QtObject timeSource: PlasmaCore.DataSource { engine: "time" connectedSources: ["Local"] interval: 60000 // 1 min intervalAlignment: PlasmaCore.Types.AlignToMinute onDataChanged: { checkInhibition(); globals.timeChanged(); } } property Instantiator popupInstantiator: Instantiator { model: popupNotificationsModel delegate: NotificationPopup { // so Instantiator can access that after the model row is gone readonly property var notificationId: model.notificationId popupWidth: globals.popupWidth type: model.urgency === NotificationManager.Notifications.CriticalUrgency && notificationSettings.keepCriticalAlwaysOnTop ? PlasmaCore.Dialog.CriticalNotification : PlasmaCore.Dialog.Notification notificationType: model.type applicationName: model.applicationName applicationIconSource: model.applicationIconName - deviceName: model.deviceName || "" + originName: model.originName || "" time: model.updated || model.created configurable: model.configurable // For running jobs instead of offering a "close" button that might lead the user to // think that will cancel the job, we offer a "dismiss" button that hides it in the history dismissable: model.type === NotificationManager.Notifications.JobType && model.jobState !== NotificationManager.Notifications.JobStateStopped // TODO would be nice to be able to "pin" jobs when they autohide && notificationSettings.permanentJobPopups closable: model.closable summary: model.summary body: model.body || "" icon: model.image || model.iconName hasDefaultAction: model.hasDefaultAction || false timeout: model.timeout // Increase default timeout for notifications with a URL so you have enough time // to interact with the thumbnail or bring the window to the front where you want to drag it into defaultTimeout: notificationSettings.popupTimeout + (model.urls && model.urls.length > 0 ? 5000 : 0) // When configured to not keep jobs open permanently, we autodismiss them after the standard timeout dismissTimeout: !notificationSettings.permanentJobPopups && model.type === NotificationManager.Notifications.JobType && model.jobState !== NotificationManager.Notifications.JobStateStopped ? defaultTimeout : 0 urls: model.urls || [] urgency: model.urgency || NotificationManager.Notifications.NormalUrgency jobState: model.jobState || 0 percentage: model.percentage || 0 jobError: model.jobError || 0 suspendable: !!model.suspendable killable: !!model.killable jobDetails: model.jobDetails || null configureActionLabel: model.configureActionLabel || "" actionNames: model.actionNames actionLabels: model.actionLabels onExpired: popupNotificationsModel.expire(popupNotificationsModel.index(index, 0)) onCloseClicked: popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) onDismissClicked: model.dismissed = true onConfigureClicked: popupNotificationsModel.configure(popupNotificationsModel.index(index, 0)) onDefaultActionInvoked: { popupNotificationsModel.invokeDefaultAction(popupNotificationsModel.index(index, 0)) popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) } onActionInvoked: { popupNotificationsModel.invokeAction(popupNotificationsModel.index(index, 0), actionName) popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) } onOpenUrl: { Qt.openUrlExternally(url); popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) } onFileActionInvoked: popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) onSuspendJobClicked: popupNotificationsModel.suspendJob(popupNotificationsModel.index(index, 0)) onResumeJobClicked: popupNotificationsModel.resumeJob(popupNotificationsModel.index(index, 0)) onKillJobClicked: popupNotificationsModel.killJob(popupNotificationsModel.index(index, 0)) onHeightChanged: Qt.callLater(positionPopups) onWidthChanged: Qt.callLater(positionPopups) Component.onCompleted: { // Register apps that were seen spawning a popup so they can be configured later // Apps with notifyrc can already be configured anyway if (model.type === NotificationManager.Notifications.NotificationType && model.desktopEntry && !model.notifyRcName) { notificationSettings.registerKnownApplication(model.desktopEntry); notificationSettings.save(); } // Tell the model that we're handling the timeout now popupNotificationsModel.stopTimeout(popupNotificationsModel.index(index, 0)); } } onObjectAdded: { // also needed for it to correctly layout its contents object.visible = true; Qt.callLater(positionPopups); } onObjectRemoved: { var notificationId = object.notificationId // Popup might have been destroyed because of a filter change, tell the model to do the timeout work for us again // cannot use QModelIndex here as the model row is already gone popupNotificationsModel.startTimeout(notificationId); Qt.callLater(positionPopups); } } // TODO use pulseaudio-qt for this once it becomes a framework property QtObject pulseAudio: Loader { source: "PulseAudio.qml" } } diff --git a/libnotificationmanager/notification.cpp b/libnotificationmanager/notification.cpp index 1146265af..9e55e48e8 100644 --- a/libnotificationmanager/notification.cpp +++ b/libnotificationmanager/notification.cpp @@ -1,654 +1,654 @@ /* * Copyright 2008 Dmitry Suzdalev * Copyright 2017 David Edmundson * Copyright 2018-2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #include "notification.h" #include "notification_p.h" #include "notifications.h" #include #include #include #include #include #include #include #include #include #include #include #include "debug.h" #include "notifications.h" using namespace NotificationManager; Notification::Private::Private() { } Notification::Private::~Private() = default; QString Notification::Private::sanitize(const QString &text) { // replace all \ns with
QString t = text; t.replace(QLatin1String("\n"), QStringLiteral("
")); // Now remove all inner whitespace (\ns are already
s) t = t.simplified(); // Finally, check if we don't have multiple
s following, // can happen for example when "\n \n" is sent, this replaces // all
s in succsession with just one t.replace(QRegularExpression(QStringLiteral("
\\s*
(\\s|
)*")), QLatin1String("
")); // This fancy RegExp escapes every occurrence of & since QtQuick Text will blatantly cut off // text where it finds a stray ampersand. // Only &{apos, quot, gt, lt, amp}; as well as { character references will be allowed t.replace(QRegularExpression(QStringLiteral("&(?!(?:apos|quot|[gl]t|amp);|#)")), QLatin1String("&")); // Don't bother adding some HTML structure if the body is now empty if (t.isEmpty()) { return t; } QXmlStreamReader r(QStringLiteral("") + t + QStringLiteral("")); QString result; QXmlStreamWriter out(&result); const QVector allowedTags = {"b", "i", "u", "img", "a", "html", "br", "table", "tr", "td"}; out.writeStartDocument(); while (!r.atEnd()) { r.readNext(); if (r.tokenType() == QXmlStreamReader::StartElement) { const QString name = r.name().toString(); if (!allowedTags.contains(name)) { continue; } out.writeStartElement(name); if (name == QLatin1String("img")) { auto src = r.attributes().value("src").toString(); auto alt = r.attributes().value("alt").toString(); const QUrl url(src); if (url.isLocalFile()) { out.writeAttribute(QStringLiteral("src"), src); } else { //image denied for security reasons! Do not copy the image src here! } out.writeAttribute(QStringLiteral("alt"), alt); } if (name == QLatin1String("a")) { out.writeAttribute(QStringLiteral("href"), r.attributes().value("href").toString()); } } if (r.tokenType() == QXmlStreamReader::EndElement) { const QString name = r.name().toString(); if (!allowedTags.contains(name)) { continue; } out.writeEndElement(); } if (r.tokenType() == QXmlStreamReader::Characters) { const auto text = r.text().toString(); out.writeCharacters(text); //this auto escapes chars -> HTML entities } } out.writeEndDocument(); if (r.hasError()) { qCWarning(NOTIFICATIONMANAGER) << "Notification to send to backend contains invalid XML: " << r.errorString() << "line" << r.lineNumber() << "col" << r.columnNumber(); } // The Text.StyledText format handles only html3.2 stuff and ' is html4 stuff // so we need to replace it here otherwise it will not render at all. result = result.replace(QLatin1String("'"), QChar('\'')); return result; } QImage Notification::Private::decodeNotificationSpecImageHint(const QDBusArgument &arg) { int width, height, rowStride, hasAlpha, bitsPerSample, channels; QByteArray pixels; char* ptr; char* end; arg.beginStructure(); arg >> width >> height >> rowStride >> hasAlpha >> bitsPerSample >> channels >> pixels; arg.endStructure(); #define SANITY_CHECK(condition) \ if (!(condition)) { \ qCWarning(NOTIFICATIONMANAGER) << "Image decoding sanity check failed on" << #condition; \ return QImage(); \ } SANITY_CHECK(width > 0); SANITY_CHECK(width < 2048); SANITY_CHECK(height > 0); SANITY_CHECK(height < 2048); SANITY_CHECK(rowStride > 0); #undef SANITY_CHECK auto copyLineRGB32 = [](QRgb* dst, const char* src, int width) { const char* end = src + width * 3; for (; src != end; ++dst, src+=3) { *dst = qRgb(src[0], src[1], src[2]); } }; auto copyLineARGB32 = [](QRgb* dst, const char* src, int width) { const char* end = src + width * 4; for (; src != end; ++dst, src+=4) { *dst = qRgba(src[0], src[1], src[2], src[3]); } }; QImage::Format format = QImage::Format_Invalid; void (*fcn)(QRgb*, const char*, int) = nullptr; if (bitsPerSample == 8) { if (channels == 4) { format = QImage::Format_ARGB32; fcn = copyLineARGB32; } else if (channels == 3) { format = QImage::Format_RGB32; fcn = copyLineRGB32; } } if (format == QImage::Format_Invalid) { qCWarning(NOTIFICATIONMANAGER) << "Unsupported image format (hasAlpha:" << hasAlpha << "bitsPerSample:" << bitsPerSample << "channels:" << channels << ")"; return QImage(); } QImage image(width, height, format); ptr = pixels.data(); end = ptr + pixels.length(); for (int y=0; y end) { qCWarning(NOTIFICATIONMANAGER) << "Image data is incomplete. y:" << y << "height:" << height; break; } fcn((QRgb*)image.scanLine(y), ptr, width); } return image; } void Notification::Private::sanitizeImage(QImage &image) { if (image.isNull()) { return; } const QSize max = maximumImageSize(); if (image.size().width() > max.width() || image.size().height() > max.height()) { image = image.scaled(max, Qt::KeepAspectRatio, Qt::SmoothTransformation); } } void Notification::Private::loadImagePath(const QString &path) { // image_path and appIcon should either be a URL with file scheme or the name of a themed icon. // We're lenient and also allow local paths. image = QImage(); // clear icon.clear(); QUrl imageUrl; if (path.startsWith(QLatin1Char('/'))) { imageUrl = QUrl::fromLocalFile(path); } else if (path.contains(QLatin1Char('/'))) { // bad heuristic to detect a URL imageUrl = QUrl(path); if (!imageUrl.isLocalFile()) { qCDebug(NOTIFICATIONMANAGER) << "Refused to load image from" << path << "which isn't a valid local location."; return; } } if (!imageUrl.isValid()) { // try icon path instead; icon = path; return; } QImageReader reader(imageUrl.toLocalFile()); reader.setAutoTransform(true); const QSize imageSize = reader.size(); if (imageSize.isValid() && (imageSize.width() > maximumImageSize().width() || imageSize.height() > maximumImageSize().height())) { const QSize thumbnailSize = imageSize.scaled(maximumImageSize(), Qt::KeepAspectRatio); reader.setScaledSize(thumbnailSize); } image = reader.read(); } QString Notification::Private::defaultComponentName() { // NOTE Keep in sync with KNotification return QStringLiteral("plasma_workspace"); } QSize Notification::Private::maximumImageSize() { return QSize(256, 256); } void Notification::Private::processHints(const QVariantMap &hints) { auto end = hints.end(); desktopEntry = hints.value(QStringLiteral("desktop-entry")).toString(); QString serviceName; configurableService = false; if (!desktopEntry.isEmpty()) { KService::Ptr service = KService::serviceByDesktopName(desktopEntry); // Also try lower-case desktopEntry (Firefox sends "Firefox" which doesn't match "firefox"...) if (!service) { const QString lowerDesktopEntry = desktopEntry.toLower(); service = KService::serviceByDesktopName(lowerDesktopEntry); if (service) { qCInfo(NOTIFICATIONMANAGER) << "Application sent desktop-entry" << desktopEntry << "but it actually was" << lowerDesktopEntry << ", this is an application bug!"; desktopEntry = lowerDesktopEntry; } } if (service) { serviceName = service->name(); applicationIconName = service->icon(); configurableService = !service->noDisplay(); } } notifyRcName = hints.value(QStringLiteral("x-kde-appname")).toString(); const bool isDefaultEvent = (notifyRcName == defaultComponentName()); configurableNotifyRc = false; if (!notifyRcName.isEmpty()) { // Check whether the application actually has notifications we can configure KConfig config(notifyRcName + QStringLiteral(".notifyrc"), KConfig::NoGlobals); config.addConfigSources(QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, QStringLiteral("knotifications5/") + notifyRcName + QStringLiteral(".notifyrc"))); KConfigGroup globalGroup(&config, "Global"); const QString iconName = globalGroup.readEntry("IconName"); // For default events we try to show the application name from the desktop entry if possible // This will have us show e.g. "Dr Konqi" instead of generic "Plasma Desktop" if (isDefaultEvent && !serviceName.isEmpty()) { applicationName = serviceName; } // also only overwrite application icon name for non-default events (or if we don't have a service icon) if (!iconName.isEmpty() && (!isDefaultEvent || applicationIconName.isEmpty())) { applicationIconName = iconName; } const QRegularExpression regexp(QStringLiteral("^Event/([^/]*)$")); configurableNotifyRc = !config.groupList().filter(regexp).isEmpty(); } // Special override for KDE Connect since the notification is sent by kdeconnectd // but actually comes from a different app on the phone const QString applicationDisplayName = hints.value(QStringLiteral("x-kde-display-appname")).toString(); if (!applicationDisplayName.isEmpty()) { applicationName = applicationDisplayName; } - deviceName = hints.value(QStringLiteral("x-kde-device-name")).toString(); + originName = hints.value(QStringLiteral("x-kde-origin-name")).toString(); eventId = hints.value(QStringLiteral("x-kde-eventId")).toString(); bool ok; const int urgency = hints.value(QStringLiteral("urgency")).toInt(&ok); // DBus type is actually "byte" if (ok) { // FIXME use separate enum again switch (urgency) { case 0: setUrgency(Notifications::LowUrgency); break; case 1: setUrgency(Notifications::NormalUrgency); break; case 2: setUrgency(Notifications::CriticalUrgency); break; } } urls = QUrl::fromStringList(hints.value(QStringLiteral("x-kde-urls")).toStringList()); // Underscored hints was in use in version 1.1 of the spec but has been // replaced by dashed hints in version 1.2. We need to support it for // users of the 1.2 version of the spec. auto it = hints.find(QStringLiteral("image-data")); if (it == end) { it = hints.find(QStringLiteral("image_data")); } if (it == end) { // This hint was in use in version 1.0 of the spec but has been // replaced by "image_data" in version 1.1. We need to support it for // users of the 1.0 version of the spec. it = hints.find(QStringLiteral("icon_data")); } if (it != end) { image = decodeNotificationSpecImageHint(it->value()); } if (image.isNull()) { it = hints.find(QStringLiteral("image-path")); if (it == end) { it = hints.find(QStringLiteral("image_path")); } if (it != end) { loadImagePath(it->toString()); } } sanitizeImage(image); } void Notification::Private::setUrgency(Notifications::Urgency urgency) { this->urgency = urgency; // Critical notifications must not time out // TODO should we really imply this here and not on the view side? // are there usecases for critical but can expire? // "critical updates available"? if (urgency == Notifications::CriticalUrgency) { timeout = 0; } } Notification::Notification(uint id) : d(new Private()) { d->id = id; d->created = QDateTime::currentDateTimeUtc(); } Notification::Notification(const Notification &other) : d(new Private(*other.d)) { } Notification::Notification(Notification &&other) : d(other.d) { other.d = nullptr; } Notification &Notification::operator=(const Notification &other) { d = new Private(*other.d); return *this; } Notification &Notification::operator=(Notification &&other) { d = other.d; other.d = nullptr; return *this; } Notification::~Notification() { delete d; } uint Notification::id() const { return d->id; } QDateTime Notification::created() const { return d->created; } QDateTime Notification::updated() const { return d->updated; } void Notification::resetUpdated() { d->updated = QDateTime::currentDateTimeUtc(); } QString Notification::summary() const { return d->summary; } void Notification::setSummary(const QString &summary) { d->summary = summary; } QString Notification::body() const { return d->body; } void Notification::setBody(const QString &body) { d->body = Private::sanitize(body.trimmed()); } QString Notification::icon() const { return d->icon; } void Notification::setIcon(const QString &icon) { d->loadImagePath(icon); Private::sanitizeImage(d->image); } QImage Notification::image() const { return d->image; } void Notification::setImage(const QImage &image) { d->image = image; } QString Notification::desktopEntry() const { return d->desktopEntry; } QString Notification::notifyRcName() const { return d->notifyRcName; } QString Notification::eventId() const { return d->eventId; } QString Notification::applicationName() const { return d->applicationName; } void Notification::setApplicationName(const QString &applicationName) { d->applicationName = applicationName; } QString Notification::applicationIconName() const { return d->applicationIconName; } void Notification::setApplicationIconName(const QString &applicationIconName) { d->applicationIconName = applicationIconName; } -QString Notification::deviceName() const +QString Notification::originName() const { - return d->deviceName; + return d->originName; } QStringList Notification::actionNames() const { return d->actionNames; } QStringList Notification::actionLabels() const { return d->actionLabels; } bool Notification::hasDefaultAction() const { return d->hasDefaultAction; } QString Notification::defaultActionLabel() const { return d->defaultActionLabel; } void Notification::setActions(const QStringList &actions) { if (actions.count() % 2 != 0) { qCWarning(NOTIFICATIONMANAGER) << "List of actions must contain an even number of items, tried to set actions to" << actions; return; } d->hasDefaultAction = false; d->hasConfigureAction = false; QStringList names; QStringList labels; for (int i = 0; i < actions.count(); i += 2) { const QString &name = actions.at(i); const QString &label = actions.at(i + 1); if (!d->hasDefaultAction && name == QLatin1String("default")) { d->hasDefaultAction = true; d->defaultActionLabel = label; continue; } if (!d->hasConfigureAction && name == QLatin1String("settings")) { d->hasConfigureAction = true; d->configureActionLabel = label; continue; } names << name; labels << label; } d->actionNames = names; d->actionLabels = labels; } QList Notification::urls() const { return d->urls; } void Notification::setUrls(const QList &urls) { d->urls = urls; } Notifications::Urgency Notification::urgency() const { return d->urgency; } int Notification::timeout() const { return d->timeout; } void Notification::setTimeout(int timeout) { d->timeout = timeout; } bool Notification::configurable() const { return d->hasConfigureAction || d->configurableNotifyRc || d->configurableService; } QString Notification::configureActionLabel() const { return d->configureActionLabel; } bool Notification::expired() const { return d->expired; } void Notification::setExpired(bool expired) { d->expired = expired; } bool Notification::dismissed() const { return d->dismissed; } void Notification::setDismissed(bool dismissed) { d->dismissed = dismissed; } void Notification::processHints(const QVariantMap &hints) { d->processHints(hints); } diff --git a/libnotificationmanager/notification.h b/libnotificationmanager/notification.h index efb8035af..c2109e3f4 100644 --- a/libnotificationmanager/notification.h +++ b/libnotificationmanager/notification.h @@ -1,123 +1,123 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #pragma once #include #include #include #include #include #include "notifications.h" #include "notificationmanager_export.h" namespace NotificationManager { /** * @short Represents a single notification * * @author Kai Uwe Broulik **/ class NOTIFICATIONMANAGER_EXPORT Notification { public: explicit Notification(uint id = 0); Notification(const Notification &other); Notification(Notification &&other) Q_DECL_NOEXCEPT; Notification &operator=(const Notification &other); Notification &operator=(Notification &&other) Q_DECL_NOEXCEPT; virtual ~Notification(); uint id() const; QDateTime created() const; QDateTime updated() const; void resetUpdated(); QString summary() const; void setSummary(const QString &summary); QString body() const; void setBody(const QString &body); QString icon() const; void setIcon(const QString &icon); QImage image() const; void setImage(const QImage &image); QString desktopEntry() const; QString notifyRcName() const; QString eventId() const; QString applicationName() const; void setApplicationName(const QString &applicationName); QString applicationIconName() const; void setApplicationIconName(const QString &applicationIconName); - QString deviceName() const; + QString originName() const; // should we group the two into a QPair or something? QStringList actionNames() const; QStringList actionLabels() const; bool hasDefaultAction() const; QString defaultActionLabel() const; void setActions(const QStringList &actions); QList urls() const; void setUrls(const QList &urls); // FIXME use separate enum again Notifications::Urgency urgency() const; void setUrgency(Notifications::Urgency urgency); int timeout() const; void setTimeout(int timeout); bool configurable() const; QString configureActionLabel() const; bool expired() const; void setExpired(bool expired); bool dismissed() const; void setDismissed(bool dismissed); void processHints(const QVariantMap &hints); private: friend class NotificationsModel; friend class ServerPrivate; class Private; Private *d; }; } // namespace NotificationManager diff --git a/libnotificationmanager/notification_p.h b/libnotificationmanager/notification_p.h index b68e68b18..c3e2bc8d1 100644 --- a/libnotificationmanager/notification_p.h +++ b/libnotificationmanager/notification_p.h @@ -1,93 +1,93 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #pragma once #include #include #include #include #include #include #include #include "notifications.h" namespace NotificationManager { class Q_DECL_HIDDEN Notification::Private { public: Private(); ~Private(); static QString sanitize(const QString &text); static QImage decodeNotificationSpecImageHint(const QDBusArgument &arg); static void sanitizeImage(QImage &image); void loadImagePath(const QString &path); static QString defaultComponentName(); static QSize maximumImageSize(); void processHints(const QVariantMap &hints); void setUrgency(Notifications::Urgency urgency); uint id = 0; QDateTime created; QDateTime updated; QString summary; QString body; // Can be theme icon name or path QString icon; QImage image; QString applicationName; QString desktopEntry; bool configurableService = false; QString applicationIconName; - QString deviceName; + QString originName; QStringList actionNames; QStringList actionLabels; bool hasDefaultAction = false; QString defaultActionLabel; bool hasConfigureAction = false; QString configureActionLabel; bool configurableNotifyRc = false; QString notifyRcName; QString eventId; QList urls; Notifications::Urgency urgency = Notifications::NormalUrgency; int timeout = -1; bool expired = false; bool dismissed = false; }; } // namespace NotificationManager diff --git a/libnotificationmanager/notifications.h b/libnotificationmanager/notifications.h index a6492fb1a..3dc22e514 100644 --- a/libnotificationmanager/notifications.h +++ b/libnotificationmanager/notifications.h @@ -1,525 +1,525 @@ /* * Copyright 2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #pragma once #include #include #include #include "notificationmanager_export.h" namespace NotificationManager { /** * @brief A model with notifications and jobs * * This model contains application notifications as well as jobs * and lets you apply fine-grained filter, sorting, and grouping rules. * * @author Kai Uwe Broulik **/ class NOTIFICATIONMANAGER_EXPORT Notifications : public QSortFilterProxyModel, public QQmlParserStatus { Q_OBJECT Q_INTERFACES(QQmlParserStatus) /** * The number of notifications the model should at most contain. * * Default is 0, which is no limit. */ Q_PROPERTY(int limit READ limit WRITE setLimit NOTIFY limitChanged) /** * Whether to show expired notifications. * * Expired notifications are those that timed out, i.e. ones that were not explicitly * closed or acted upon by the user, nor revoked by the issuing application. * * An expired notification has its actions removed. * * Default is false. */ Q_PROPERTY(bool showExpired READ showExpired WRITE setShowExpired NOTIFY showExpiredChanged) /** * Whether to show dismissed notifications. * * Dismissed notifications are those that are temporarily hidden by the user. * This can e.g. be a copy job that has its popup closed but still continues in the background. * * Default is false. */ Q_PROPERTY(bool showDismissed READ showDismissed WRITE setShowDismissed NOTIFY showDismissedChanged) /** * A list of desktop entries for which no notifications should be shown. * * If the same desktop entry is present in both blacklist and whitelist, * the blacklist takes precedence, i.e. the notification is not shown. */ Q_PROPERTY(QStringList blacklistedDesktopEntries READ blacklistedDesktopEntries WRITE setBlacklistedDesktopEntries NOTIFY blacklistedDesktopEntriesChanged) /** * A list of notifyrc names for which no notifications should be shown. * * If the same notifyrc name is present in both blacklist and whitelist, * the blacklist takes precedence, i.e. the notification is not shown. */ Q_PROPERTY(QStringList blacklistedNotifyRcNames READ blacklistedNotifyRcNames WRITE setBlacklistedNotifyRcNames NOTIFY blacklistedNotifyRcNamesChanged) /** * A list of desktop entries for which notifications should be shown. * * This bypasses any filtering for urgency. * * If the same desktop entry is present in both whitelist and blacklist, * the blacklist takes precedence, i.e. the notification is not shown. * * Default is empty list, which means normal filtering is applied. */ Q_PROPERTY(QStringList whitelistedDesktopEntries READ whitelistedDesktopEntries WRITE setWhitelistedDesktopEntries NOTIFY whitelistedDesktopEntriesChanged) /** * A list of notifyrc names for which notifications should be shown. * * This bypasses any filtering for urgency. * * If the same notifyrc name is present in both whitelist and blacklist, * the blacklist takes precedence, i.e. the notification is not shown. * * Default is empty list, which means normal filtering is applied. */ Q_PROPERTY(QStringList whitelistedNotifyRcNames READ whitelistedNotifyRcNames WRITE setWhitelistedNotifyRcNames NOTIFY whitelistedNotifyRcNamesChanged) /** * Whether to show notifications. * * Default is true. */ Q_PROPERTY(bool showNotifications READ showNotifications WRITE setShowNotifications NOTIFY showNotificationsChanged) /** * Whether to show application jobs. * * Default is false. */ Q_PROPERTY(bool showJobs READ showJobs WRITE setShowJobs NOTIFY showJobsChanged) /** * The notification urgency types the model should contain. * * Default is all urgencies: low, normal, critical. */ Q_PROPERTY(Urgencies urgencies READ urgencies WRITE setUrgencies NOTIFY urgenciesChanged) /** * The sort mode for notifications. * * Default is strictly by date created/updated. */ Q_PROPERTY(SortMode sortMode READ sortMode WRITE setSortMode NOTIFY sortModeChanged) /** * The group mode for notifications. * * Default is ungrouped. */ Q_PROPERTY(GroupMode groupMode READ groupMode WRITE setGroupMode NOTIFY groupModeChanged) /** * How many notifications are shown in each group. * * You can expand a group by setting the IsGroupExpandedRole to true. * * Default is 0, which means no limit. */ Q_PROPERTY(int groupLimit READ groupLimit WRITE setGroupLimit NOTIFY groupLimitChanged) /** * Whether to automatically show notifications that are unread. * * This is any notification that was created or updated after the value of @c lastRead. */ Q_PROPERTY(bool expandUnread READ expandUnread WRITE setExpandUnread NOTIFY expandUnreadChanged) /** * The number of notifications in the model */ Q_PROPERTY(int count READ count NOTIFY countChanged) /** * The number of active, i.e. non-expired notifications */ Q_PROPERTY(int activeNotificationsCount READ activeNotificationsCount NOTIFY activeNotificationsCountChanged) /** * The number of inactive, i.e. non-expired notifications */ Q_PROPERTY(int expiredNotificationsCount READ expiredNotificationsCount NOTIFY expiredNotificationsCountChanged) /** * The time when the user last could read the notifications. * This is typically reset whenever the list of notifications is opened and is used to determine * the @c unreadNotificationsCount */ Q_PROPERTY(QDateTime lastRead READ lastRead WRITE setLastRead RESET resetLastRead NOTIFY lastReadChanged) /** * The number of notifications added since lastRead * * This can be used to show a "n unread notifications" label */ Q_PROPERTY(int unreadNotificationsCount READ unreadNotificationsCount NOTIFY unreadNotificationsCountChanged) /** * The number of active jobs */ Q_PROPERTY(int activeJobsCount READ activeJobsCount NOTIFY activeJobsCountChanged) /** * The combined percentage of all jobs. * * This is the average of all percentages and could can be used to show * a global progress bar. */ Q_PROPERTY(int jobsPercentage READ jobsPercentage NOTIFY jobsPercentageChanged) public: explicit Notifications(QObject *parent = nullptr); ~Notifications() override; enum Roles { IdRole = Qt::UserRole + 1, ///< A notification identifier. This can be uint notification ID or string application job source. SummaryRole = Qt::DisplayRole, ///< The notification summary. ImageRole = Qt::DecorationRole, ///< The notification main image, which is not the application icon. Only valid for pixmap icons. IsGroupRole = Qt::UserRole + 2, ///< Whether the item is a group GroupChildrenCountRole, ///< The number of children in a group. ExpandedGroupChildrenCountRole, ///< The number of children in a group that are expanded. IsGroupExpandedRole, ///< Whether the group is expanded, this role is writable. IsInGroupRole, ///< Whether the notification is currently inside a group. TypeRole, ///< The type of model entry, either NotificationType or JobType. CreatedRole, ///< When the notification was first created. UpdatedRole, ///< When the notification was last updated, invalid when it hasn't been updated. BodyRole, ///< The notification body text. IconNameRole, ///< The notification main icon name, which is not the application icon. Only valid for icon names, if a URL supplied, it is loaded and exposed as ImageRole instead. DesktopEntryRole, ///< The desktop entry (without .desktop suffix, e.g. org.kde.spectacle) of the application that sent the notification. NotifyRcNameRole, ///< The notifyrc name (e.g. spectaclerc) of the application that sent the notification. ApplicationNameRole, ///< The user-visible name of the application (e.g. Spectacle) ApplicationIconNameRole, ///< The icon name of the application - DeviceNameRole, ///< The name of the device the notification originally came from, if it was proxied through a sync service like KDE Connect + OriginNameRole, ///< The name of the device or account the notification originally came from, e.g. "My Phone" (in case of device sync) or "foo@example.com" (in case of an email notification) // Jobs JobStateRole, ///< The state of the job, either JobStateJopped, JobStateSuspended, or JobStateRunning. PercentageRole, ///< The percentage of the job. Use @c jobsPercentage to get a global percentage for all jobs. JobErrorRole, ///< The error id of the job, zero in case of no error. SuspendableRole, ///< Whether the job can be suspended @sa suspendJob KillableRole, ///< Whether the job can be killed/canceled @sa killJob JobDetailsRole, ///< A pointer to a Job item itself containing more detailed information about the job ActionNamesRole, ///< The IDs of the actions, excluding the default and settings action, e.g. [action1, action2] ActionLabelsRole, ///< The user-visible labels of the actions, excluding the default and settings action, e.g. ["Accept", "Reject"] HasDefaultActionRole, ///< Whether the notification has a default action, which is one that is invoked when the popup itself is clicked DefaultActionLabelRole, ///< The user-visible label of the default action, typically not shown as the popup itself becomes clickable UrlsRole, ///< A list of URLs associated with the notification, e.g. a path to a screenshot that was just taken or image received UrgencyRole, ///< The notification urgency, either LowUrgency, NormalUrgency, or CriticalUrgency. Jobs do not have an urgency. TimeoutRole, ///< The timeout for the notification in milliseconds. 0 means the notification should not timeout, -1 means a sensible default should be applied. ConfigurableRole, ///< Whether the notification can be configured because a desktopEntry or notifyRcName is known, or the notification has a setting action. @sa configure ConfigureActionLabelRole, ///< The user-visible label for the settings action ClosableRole, ///< Whether the item can be closed. Notifications are always closable, jobs are only when in JobStateStopped. ExpiredRole, ///< The notification timed out and closed. Actions on it cannot be invoked anymore. DismissedRole ///< The notification got temporarily hidden by the user but could still be interacted with. }; Q_ENUM(Roles) /** * The type of model item. */ enum Type { NoType, NotificationType, ///< This item represents a notification. JobType ///< This item represents an application job. }; Q_ENUM(Type) /** * The notification urgency. * * @note jobs do not have an urgency, yet still might be above normal urgency notifications. */ enum Urgency { // these don't match the spec's value LowUrgency = 1 << 0, ///< The notification has low urgency, it is not important and may not be shown or added to a history. NormalUrgency = 1 << 1, ///< The notification has normal urgency. This is also the default if no urgecny is supplied. CriticalUrgency = 1 << 2 }; Q_ENUM(Urgency) Q_DECLARE_FLAGS(Urgencies, Urgency) Q_FLAG(Urgencies) /** * Which items should be cleared in a call to @c clear */ enum ClearFlag { ClearExpired = 1 << 1, // TODO more }; Q_ENUM(ClearFlag) Q_DECLARE_FLAGS(ClearFlags, ClearFlag) Q_FLAG(ClearFlags) /** * The state an application job is in. */ enum JobState { JobStateStopped, ///< The job is stopped. It has either finished (error is 0) or failed (error is not 0) JobStateRunning, ///< The job is currently running. JobStateSuspended ///< The job is currentl paused }; Q_ENUM(JobState) /** * The sort mode for the model. */ enum SortMode { SortByDate = 0, ///< Sort notifications strictly by the date they were updated or created. // should this be flags? SortJobsFirst | SortByUrgency | ...? SortByTypeAndUrgency ///< Sort notifications taking into account their type and urgency. The order is (descending): Critical, jobs, Normal, Low. }; Q_ENUM(SortMode) /** * The group mode for the model. */ enum GroupMode { GroupDisabled = 0, //GroupApplicationsTree, // TODO make actual tree GroupApplicationsFlat }; Q_ENUM(GroupMode) int limit() const; void setLimit(int limit); bool showExpired() const; void setShowExpired(bool show); bool showDismissed() const; void setShowDismissed(bool show); QStringList blacklistedDesktopEntries() const; void setBlacklistedDesktopEntries(const QStringList &blacklist); QStringList blacklistedNotifyRcNames() const; void setBlacklistedNotifyRcNames(const QStringList &blacklist); QStringList whitelistedDesktopEntries() const; void setWhitelistedDesktopEntries(const QStringList &whitelist); QStringList whitelistedNotifyRcNames() const; void setWhitelistedNotifyRcNames(const QStringList &whitelist); bool showNotifications() const; void setShowNotifications(bool showNotifications); bool showJobs() const; void setShowJobs(bool showJobs); Urgencies urgencies() const; void setUrgencies(Urgencies urgencies); SortMode sortMode() const; void setSortMode(SortMode sortMode); GroupMode groupMode() const; void setGroupMode(GroupMode groupMode); int groupLimit() const; void setGroupLimit(int limit); bool expandUnread() const; void setExpandUnread(bool expand); int count() const; int activeNotificationsCount() const; int expiredNotificationsCount() const; QDateTime lastRead() const; void setLastRead(const QDateTime &lastRead); void resetLastRead(); int unreadNotificationsCount() const; int activeJobsCount() const; int jobsPercentage() const; /** * Convert the given QModelIndex into a QPersistentModelIndex */ Q_INVOKABLE QPersistentModelIndex makePersistentModelIndex(const QModelIndex &idx) const; /** * @brief Expire a notification * * Closes the notification in response to its timeout running out. * * Call this if you have an implementation that handles the timeout itself * by having called @c stopTimeout * * @sa stopTimeout */ Q_INVOKABLE void expire(const QModelIndex &idx); /** * @brief Close a notification * * Closes the notification in response to the user explicitly closing it. * * When the model index belongs to a group, the entire group is closed. */ Q_INVOKABLE void close(const QModelIndex &idx); /** * @brief Configure a notification * * This will invoke the settings action, if available, otherwise open the * kcm_notifications KCM for configuring the respective application and event. */ Q_INVOKABLE void configure(const QModelIndex &idx); // TODO pass ctx for transient handling /** * @brief Invoke the default notification action * * Invokes the action that should be triggered when clicking * the notification bubble itself. */ Q_INVOKABLE void invokeDefaultAction(const QModelIndex &idx); /** * @brief Invoke a notification action * * Invokes the action with the given actionId on the notification. * For invoking the default action, i.e. the one that is triggered * when clicking the notification bubble, use invokeDefaultAction */ Q_INVOKABLE void invokeAction(const QModelIndex &idx, const QString &actionId); /** * @brief Start automatic timeout of notifications * * Call this if you no longer handle the timeout yourself. * * @sa stopTimeout */ Q_INVOKABLE void startTimeout(const QModelIndex &idx); Q_INVOKABLE void startTimeout(uint notificationId); /** * @brief Stop the automatic timeout of notifications * * Call this if you have an implementation that handles the timeout itself * taking into account e.g. whether the user is currently interacting with * the notification to not close it under their mouse. Call @c expire * once your custom timer has run out. * * @sa expire */ Q_INVOKABLE void stopTimeout(const QModelIndex &idx); /** * @brief Suspend a job */ Q_INVOKABLE void suspendJob(const QModelIndex &idx); /** * @brief Resume a job */ Q_INVOKABLE void resumeJob(const QModelIndex &idx); /** * @brief Kill a job */ Q_INVOKABLE void killJob(const QModelIndex &idx); /** * @brief Clear notifications * * Removes the notifications matching th ClearFlags from the model. * This can be used for e.g. a "Clear History" action. */ Q_INVOKABLE void clear(ClearFlags flags); /** * Returns a model index pointing to the group of a notification. */ Q_INVOKABLE QModelIndex groupIndex(const QModelIndex &idx) const; Q_INVOKABLE void collapseAllGroups(); QVariant data(const QModelIndex &index, int role) const override; bool setData(const QModelIndex &index, const QVariant &value, int role) override; int rowCount(const QModelIndex &parent = QModelIndex()) const override; QHash roleNames() const override; bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; signals: void limitChanged(); void showExpiredChanged(); void showDismissedChanged(); void blacklistedDesktopEntriesChanged(); void blacklistedNotifyRcNamesChanged(); void whitelistedDesktopEntriesChanged(); void whitelistedNotifyRcNamesChanged(); void showNotificationsChanged(); void showJobsChanged(); void urgenciesChanged(); void sortModeChanged(); void groupModeChanged(); void groupLimitChanged(); void expandUnreadChanged(); void countChanged(); void activeNotificationsCountChanged(); void expiredNotificationsCountChanged(); void lastReadChanged(); void unreadNotificationsCountChanged(); void activeJobsCountChanged(); void jobsPercentageChanged(); protected: void classBegin() override; void componentComplete() override; private: class Private; QScopedPointer d; }; } // namespace NotificationManager Q_DECLARE_OPERATORS_FOR_FLAGS(NotificationManager::Notifications::Urgencies) diff --git a/libnotificationmanager/notificationsmodel.cpp b/libnotificationmanager/notificationsmodel.cpp index bfa414c4a..6f8168816 100644 --- a/libnotificationmanager/notificationsmodel.cpp +++ b/libnotificationmanager/notificationsmodel.cpp @@ -1,488 +1,488 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #include "notificationsmodel.h" #include "debug.h" #include "server.h" #include "notifications.h" #include "notification.h" #include "notification_p.h" #include #include #include #include #include #include static const int s_notificationsLimit = 1000; using namespace NotificationManager; class Q_DECL_HIDDEN NotificationsModel::Private { public: explicit Private(NotificationsModel *q); ~Private(); void onNotificationAdded(const Notification ¬ification); void onNotificationReplaced(uint replacedId, const Notification ¬ification); void onNotificationRemoved(uint notificationId, Server::CloseReason reason); void setupNotificationTimeout(const Notification ¬ification); int rowOfNotification(uint id) const; NotificationsModel *q; QVector notifications; // Fallback timeout to ensure all notifications expire eventually // otherwise when it isn't shown to the user and doesn't expire // an app might wait indefinitely for the notification to do so QHash notificationTimeouts; QDateTime lastRead; }; NotificationsModel::Private::Private(NotificationsModel *q) : q(q) , lastRead(QDateTime::currentDateTimeUtc()) { } NotificationsModel::Private::~Private() { qDeleteAll(notificationTimeouts); notificationTimeouts.clear(); } void NotificationsModel::Private::onNotificationAdded(const Notification ¬ification) { // If we get the same notification in succession, just compress them into one if (!notifications.isEmpty()) { const Notification &lastNotification = notifications.constLast(); if (lastNotification.applicationName() == notification.applicationName() && lastNotification.summary() == notification.summary() && lastNotification.body() == notification.body() && lastNotification.desktopEntry() == notification.desktopEntry() && lastNotification.applicationName() == notification.applicationName()) { onNotificationReplaced(lastNotification.id(), notification); return; } } // Once we reach a certain insane number of notifications discard some old ones // as we keep pixmaps around etc if (notifications.count() >= s_notificationsLimit) { const int cleanupCount = s_notificationsLimit / 2; qCDebug(NOTIFICATIONMANAGER) << "Reached the notification limit of" << s_notificationsLimit << ", discarding the oldest" << cleanupCount << "notifications"; q->beginRemoveRows(QModelIndex(), 0, cleanupCount - 1); for (int i = 0 ; i < cleanupCount; ++i) { notifications.removeAt(0); // TODO close gracefully? } q->endRemoveRows(); } setupNotificationTimeout(notification); q->beginInsertRows(QModelIndex(), notifications.count(), notifications.count()); notifications.append(std::move(notification)); q->endInsertRows(); } void NotificationsModel::Private::onNotificationReplaced(uint replacedId, const Notification ¬ification) { const int row = rowOfNotification(replacedId); if (row == -1) { return; } setupNotificationTimeout(notification); notifications[row] = notification; const QModelIndex idx = q->index(row, 0); emit q->dataChanged(idx, idx); } void NotificationsModel::Private::onNotificationRemoved(uint removedId, Server::CloseReason reason) { const int row = rowOfNotification(removedId); if (row == -1) { return; } q->stopTimeout(removedId); // When a notification expired, keep it around in the history and mark it as such if (reason == Server::CloseReason::Expired) { const QModelIndex idx = q->index(row, 0); Notification ¬ification = notifications[row]; notification.setExpired(true); // Since the notification is "closed" it cannot have any actions // unless it is "resident" which we don't support notification.setActions(QStringList()); emit q->dataChanged(idx, idx, { Notifications::ExpiredRole, // TODO only emit those if actually changed? Notifications::ActionNamesRole, Notifications::ActionLabelsRole, Notifications::HasDefaultActionRole, Notifications::DefaultActionLabelRole, Notifications::ConfigurableRole }); return; } // Otherwise if explicitly closed by either user or app, remove it q->beginRemoveRows(QModelIndex(), row, row); notifications.removeAt(row); q->endRemoveRows(); } void NotificationsModel::Private::setupNotificationTimeout(const Notification ¬ification) { if (notification.timeout() == 0) { // In case it got replaced by a persistent notification q->stopTimeout(notification.id()); return; } QTimer *timer = notificationTimeouts.value(notification.id()); if (!timer) { timer = new QTimer(); timer->setSingleShot(true); connect(timer, &QTimer::timeout, q, [this, timer] { const uint id = timer->property("notificationId").toUInt(); q->expire(id); }); notificationTimeouts.insert(notification.id(), timer); } timer->stop(); timer->setProperty("notificationId", notification.id()); timer->setInterval(60000 /*1min*/ + (notification.timeout() == -1 ? 120000 /*2min, max configurable default timeout*/ : notification.timeout())); timer->start(); } int NotificationsModel::Private::rowOfNotification(uint id) const { auto it = std::find_if(notifications.constBegin(), notifications.constEnd(), [id](const Notification &item) { return item.id() == id; }); if (it == notifications.constEnd()) { return -1; } return std::distance(notifications.constBegin(), it); } NotificationsModel::NotificationsModel() : QAbstractListModel(nullptr) , d(new Private(this)) { connect(&Server::self(), &Server::notificationAdded, this, [this](const Notification ¬ification) { d->onNotificationAdded(notification); }); connect(&Server::self(), &Server::notificationReplaced, this, [this](uint replacedId, const Notification ¬ification) { d->onNotificationReplaced(replacedId, notification); }); connect(&Server::self(), &Server::notificationRemoved, this, [this](uint removedId, Server::CloseReason reason) { d->onNotificationRemoved(removedId, reason); }); Server::self().init(); } NotificationsModel::~NotificationsModel() = default; NotificationsModel::Ptr NotificationsModel::createNotificationsModel() { static QWeakPointer s_instance; if (!s_instance) { QSharedPointer ptr(new NotificationsModel()); s_instance = ptr.toWeakRef(); return ptr; } return s_instance.toStrongRef(); } QDateTime NotificationsModel::lastRead() const { return d->lastRead; } void NotificationsModel::setLastRead(const QDateTime &lastRead) { if (d->lastRead != lastRead) { d->lastRead = lastRead; emit lastReadChanged(); } } QVariant NotificationsModel::data(const QModelIndex &index, int role) const { if (!checkIndex(index)) { return QVariant(); } const Notification ¬ification = d->notifications.at(index.row()); switch (role) { case Notifications::IdRole: return notification.id(); case Notifications::TypeRole: return Notifications::NotificationType; case Notifications::CreatedRole: if (notification.created().isValid()) { return notification.created(); } break; case Notifications::UpdatedRole: if (notification.updated().isValid()) { return notification.updated(); } break; case Notifications::SummaryRole: return notification.summary(); case Notifications::BodyRole: return notification.body(); case Notifications::IconNameRole: if (notification.image().isNull()) { return notification.icon(); } break; case Notifications::ImageRole: if (!notification.image().isNull()) { return notification.image(); } break; case Notifications::DesktopEntryRole: return notification.desktopEntry(); case Notifications::NotifyRcNameRole: return notification.notifyRcName(); case Notifications::ApplicationNameRole: return notification.applicationName(); case Notifications::ApplicationIconNameRole: return notification.applicationIconName(); - case Notifications::DeviceNameRole: return notification.deviceName(); + case Notifications::OriginNameRole: return notification.originName(); case Notifications::ActionNamesRole: return notification.actionNames(); case Notifications::ActionLabelsRole: return notification.actionLabels(); case Notifications::HasDefaultActionRole: return notification.hasDefaultAction(); case Notifications::DefaultActionLabelRole: return notification.defaultActionLabel(); case Notifications::UrlsRole: return QVariant::fromValue(notification.urls()); case Notifications::UrgencyRole: return static_cast(notification.urgency()); case Notifications::TimeoutRole: return notification.timeout(); case Notifications::ClosableRole: return true; case Notifications::ConfigurableRole: return notification.configurable(); case Notifications::ConfigureActionLabelRole: return notification.configureActionLabel(); case Notifications::ExpiredRole: return notification.expired(); } return QVariant(); } int NotificationsModel::rowCount(const QModelIndex &parent) const { if (parent.isValid()) { return 0; } return d->notifications.count(); } void NotificationsModel::expire(uint notificationId) { if (d->rowOfNotification(notificationId) > -1) { Server::self().closeNotification(notificationId, Server::CloseReason::Expired); } } void NotificationsModel::close(uint notificationId) { if (d->rowOfNotification(notificationId) > -1) { Server::self().closeNotification(notificationId, Server::CloseReason::DismissedByUser); } } void NotificationsModel::configure(uint notificationId) { const int row = d->rowOfNotification(notificationId); if (row == -1) { return; } const Notification ¬ification = d->notifications.at(row); if (notification.d->hasConfigureAction) { Server::self().invokeAction(notificationId, QStringLiteral("settings")); // FIXME make a static Notification::configureActionName() or something return; } if (!notification.desktopEntry().isEmpty() || !notification.notifyRcName().isEmpty()) { configure(notification.desktopEntry(), notification.notifyRcName(), notification.eventId()); return; } qCWarning(NOTIFICATIONMANAGER) << "Trying to configure notification" << notificationId << "which isn't configurable"; } void NotificationsModel::configure(const QString &desktopEntry, const QString ¬ifyRcName, const QString &eventId) { // TODO would be nice to just have a signal but since NotificationsModel is shared, // if we connect to this from Notifications you would get a signal in every instance // and potentialy open the config dialog multiple times. QStringList args; if (!desktopEntry.isEmpty()) { args.append(QStringLiteral("--desktop-entry")); args.append(desktopEntry); } if (!notifyRcName.isEmpty()) { args.append(QStringLiteral("--notifyrc")); args.append(notifyRcName); } if (!eventId.isEmpty()) { args.append(QStringLiteral("--event-id")); args.append(eventId); } QProcess::startDetached(QStringLiteral("kcmshell5"), { QStringLiteral("notifications"), QStringLiteral("--args"), KShell::joinArgs(args) }); } void NotificationsModel::invokeDefaultAction(uint notificationId) { const int row = d->rowOfNotification(notificationId); if (row == -1) { return; } const Notification ¬ification = d->notifications.at(row); if (!notification.hasDefaultAction()) { qCWarning(NOTIFICATIONMANAGER) << "Trying to invoke default action on notification" << notificationId << "which doesn't have one"; return; } Server::self().invokeAction(notificationId, QStringLiteral("default")); // FIXME make a static Notification::defaultActionName() or something } void NotificationsModel::invokeAction(uint notificationId, const QString &actionName) { const int row = d->rowOfNotification(notificationId); if (row == -1) { return; } const Notification ¬ification = d->notifications.at(row); if (!notification.actionNames().contains(actionName)) { qCWarning(NOTIFICATIONMANAGER) << "Trying to invoke action" << actionName << "on notification" << notificationId << "which it doesn't have"; return; } Server::self().invokeAction(notificationId, actionName); } void NotificationsModel::startTimeout(uint notificationId) { const int row = d->rowOfNotification(notificationId); if (row == -1) { return; } const Notification ¬ification = d->notifications.at(row); if (!notification.timeout() || notification.expired()) { return; } d->setupNotificationTimeout(notification); } void NotificationsModel::stopTimeout(uint notificationId) { delete d->notificationTimeouts.take(notificationId); } void NotificationsModel::clear(Notifications::ClearFlags flags) { if (d->notifications.isEmpty()) { return; } // Tries to remove a contiguous group if possible as the likely case is // you have n unread notifications at the end of the list, we don't want to // remove and signal each item individually QVector> clearQueue; QPair clearRange{-1, -1}; for (int i = d->notifications.count() - 1; i >= 0; --i) { const Notification ¬ification = d->notifications.at(i); bool clear = (flags.testFlag(Notifications::ClearExpired) && notification.expired()); if (clear) { if (clearRange.second == -1) { clearRange.second = i; } clearRange.first = i; } else { if (clearRange.first != -1) { clearQueue.append(clearRange); clearRange.first = -1; clearRange.second = -1; } } } if (clearRange.first != -1) { clearQueue.append(clearRange); clearRange.first = -1; clearRange.second = -1; } for (const auto &range : clearQueue) { beginRemoveRows(QModelIndex(), range.first, range.second); for (int i = range.second; i >= range.first; --i) { d->notifications.removeAt(i); } endRemoveRows(); } } diff --git a/libnotificationmanager/server_p.cpp b/libnotificationmanager/server_p.cpp index 1d07075d8..f9863d6f8 100644 --- a/libnotificationmanager/server_p.cpp +++ b/libnotificationmanager/server_p.cpp @@ -1,344 +1,344 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #include "server_p.h" #include "debug.h" #include "notificationsadaptor.h" #include "notification.h" #include "notification_p.h" #include "server.h" #include "utils_p.h" #include #include #include #include #include #include using namespace NotificationManager; ServerPrivate::ServerPrivate(QObject *parent) : QObject(parent) , m_inhibitionWatcher(new QDBusServiceWatcher(this)) { m_inhibitionWatcher->setConnection(QDBusConnection::sessionBus()); m_inhibitionWatcher->setWatchMode(QDBusServiceWatcher::WatchForUnregistration); connect(m_inhibitionWatcher, &QDBusServiceWatcher::serviceUnregistered, this, &ServerPrivate::onServiceUnregistered); } ServerPrivate::~ServerPrivate() = default; bool ServerPrivate::init() { if (m_valid) { return true; } new NotificationsAdaptor(this); if (!QDBusConnection::sessionBus().registerObject(QStringLiteral("/org/freedesktop/Notifications"), this)) { qCWarning(NOTIFICATIONMANAGER) << "Failed to register Notification DBus object"; return false; } if (!QDBusConnection::sessionBus().registerService(QStringLiteral("org.freedesktop.Notifications"))) { qCWarning(NOTIFICATIONMANAGER) << "Failed to register Notification service on DBus"; return false; } connect(this, &ServerPrivate::inhibitedChanged, this, [this] { // emit DBus change signal... QDBusMessage signal = QDBusMessage::createSignal( QStringLiteral("/org/freedesktop/Notifications"), QStringLiteral("org.freedesktop.DBus.Properties"), QStringLiteral("PropertiesChanged") ); signal.setArguments({ QStringLiteral("org.freedesktop.Notifications"), QVariantMap{ // updated {QStringLiteral("Inhibited"), inhibited()}, }, QStringList() // invalidated }); QDBusConnection::sessionBus().send(signal); }); qCDebug(NOTIFICATIONMANAGER) << "Registered Notification service on DBus"; KConfigGroup config(KSharedConfig::openConfig(), QStringLiteral("Notifications")); const bool broadcastsEnabled = config.readEntry("ListenForBroadcasts", false); if (broadcastsEnabled) { qCDebug(NOTIFICATIONMANAGER) << "Notification server is configured to listen for broadcasts"; QDBusConnection::systemBus().connect({}, {}, QStringLiteral("org.kde.BroadcastNotifications"), QStringLiteral("Notify"), this, SLOT(onBroadcastNotification(QMap))); } m_valid = true; return true; } uint ServerPrivate::Notify(const QString &app_name, uint replaces_id, const QString &app_icon, const QString &summary, const QString &body, const QStringList &actions, const QVariantMap &hints, int timeout) { const bool wasReplaced = replaces_id > 0; int notificationId = 0; if (wasReplaced) { notificationId = replaces_id; } else { // Avoid wrapping around to 0 in case of overflow if (!m_highestNotificationId) { ++m_highestNotificationId; } notificationId = m_highestNotificationId; ++m_highestNotificationId; } Notification notification(notificationId); notification.setSummary(summary); notification.setBody(body); notification.setApplicationName(app_name); notification.setActions(actions); notification.setTimeout(timeout); // might override some of the things we set above (like application name) notification.d->processHints(hints); // If we didn't get a pixmap, load the app_icon instead if (notification.d->image.isNull()) { notification.setIcon(app_icon); } // No application name? Try to figure out the process name using the sender's PID if (notification.applicationName().isEmpty()) { qCInfo(NOTIFICATIONMANAGER) << "Notification from service" << message().service() << "didn't contain any identification information, this is an application bug!"; const QString processName = Utils::processNameFromDBusService(connection(), message().service()); if (!processName.isEmpty()) { qCDebug(NOTIFICATIONMANAGER) << "Resolved notification to be from" << processName; notification.setApplicationName(processName); } } if (wasReplaced) { notification.resetUpdated(); emit static_cast(parent())->notificationReplaced(replaces_id, notification); } else { emit static_cast(parent())->notificationAdded(notification); } return notificationId; } void ServerPrivate::CloseNotification(uint id) { // spec says "If the notification no longer exists, an empty D-BUS Error message is sent back." static_cast(parent())->closeNotification(id, Server::CloseReason::Revoked); } QStringList ServerPrivate::GetCapabilities() const { // should this be configurable somehow so the UI can tell what it implements? return QStringList{ QStringLiteral("body"), QStringLiteral("body-hyperlinks"), QStringLiteral("body-markup"), QStringLiteral("body-images"), QStringLiteral("icon-static"), QStringLiteral("actions"), QStringLiteral("x-kde-urls"), - QStringLiteral("x-kde-device-name"), + QStringLiteral("x-kde-origin-name"), QStringLiteral("x-kde-display-appname"), QStringLiteral("inhibitions") }; } QString ServerPrivate::GetServerInformation(QString &vendor, QString &version, QString &specVersion) const { vendor = QStringLiteral("KDE"); version = QLatin1String(PROJECT_VERSION); specVersion = QStringLiteral("1.2"); return QStringLiteral("Plasma"); } void ServerPrivate::onBroadcastNotification(const QMap &properties) { qCDebug(NOTIFICATIONMANAGER) << "Received broadcast notification"; const auto currentUserId = KUserId::currentEffectiveUserId().nativeId(); // a QVariantList with ints arrives as QDBusArgument here, using a QStringList for simplicity const QStringList &userIds = properties.value(QStringLiteral("uids")).toStringList(); if (!userIds.isEmpty()) { auto it = std::find_if(userIds.constBegin(), userIds.constEnd(), [currentUserId](const QVariant &id) { bool ok; auto uid = id.toString().toLongLong(&ok); return ok && uid == currentUserId; }); if (it == userIds.constEnd()) { qCDebug(NOTIFICATIONMANAGER) << "It is not meant for us, ignoring"; return; } } bool ok; int timeout = properties.value(QStringLiteral("timeout")).toInt(&ok); if (!ok) { timeout = -1; // -1 = server default, 0 would be "persistent" } Notify( properties.value(QStringLiteral("appName")).toString(), 0, // replaces_id properties.value(QStringLiteral("appIcon")).toString(), properties.value(QStringLiteral("summary")).toString(), properties.value(QStringLiteral("body")).toString(), {}, // no actions properties.value(QStringLiteral("hints")).toMap(), timeout ); } uint ServerPrivate::add(const Notification ¬ification) { // TODO check if notification with ID already exists and signal update instead if (notification.id() == 0) { ++m_highestNotificationId; notification.d->id = m_highestNotificationId; emit static_cast(parent())->notificationAdded(notification); } else { emit static_cast(parent())->notificationReplaced(notification.id(), notification); } return notification.id(); } uint ServerPrivate::Inhibit(const QString &desktop_entry, const QString &reason, const QVariantMap &hints) { const QString dbusService = message().service(); qCDebug(NOTIFICATIONMANAGER) << "Request inhibit from service" << dbusService << "which is" << desktop_entry << "with reason" << reason; if (desktop_entry.isEmpty()) { // TODO return error return 0; } KService::Ptr service = KService::serviceByDesktopName(desktop_entry); QString applicationName; if (service) { // should we check for this and error if it didn't find a service? applicationName = service->name(); } m_inhibitionWatcher->addWatchedService(dbusService); ++m_highestInhibitionCookie; m_inhibitions.insert(m_highestInhibitionCookie, { desktop_entry, applicationName, reason, hints }); m_inhibitionServices.insert(m_highestInhibitionCookie, dbusService); emit inhibitedChanged(); emit inhibitionAdded(); return m_highestInhibitionCookie; } void ServerPrivate::onServiceUnregistered(const QString &serviceName) { qCDebug(NOTIFICATIONMANAGER) << "Inhibition service unregistered" << serviceName; const uint cookie = m_inhibitionServices.key(serviceName); if (!cookie) { qCInfo(NOTIFICATIONMANAGER) << "Unknown inhibition service unregistered" << serviceName; return; } // We do lookups in there again... UnInhibit(cookie); } void ServerPrivate::UnInhibit(uint cookie) { qCDebug(NOTIFICATIONMANAGER) << "Request release inhibition for cookie" << cookie; const QString service = m_inhibitionServices.value(cookie); if (service.isEmpty()) { qCInfo(NOTIFICATIONMANAGER) << "Requested to release inhibition with cookie" << cookie << "that doesn't exist"; // TODO if called from dbus raise error return; } m_inhibitionWatcher->removeWatchedService(service); m_inhibitions.remove(cookie); m_inhibitionServices.remove(cookie); if (m_inhibitions.isEmpty()) { emit inhibitedChanged(); emit inhibitionRemoved(); } } QList ServerPrivate::inhibitions() const { return m_inhibitions.values(); } bool ServerPrivate::inhibited() const { // TODO this currently only returns whether an app has an inhibition going, // there's no way for apps to query whether user enabled do not disturb from the applet // so they could change their behavior. return !m_inhibitions.isEmpty(); } void ServerPrivate::clearInhibitions() { if (m_inhibitions.isEmpty()) { return; } m_inhibitionWatcher->setWatchedServices(QStringList()); // remove all watches m_inhibitionServices.clear(); m_inhibitions.clear(); emit inhibitedChanged(); emit inhibitionRemoved(); }