Commit 789eedc5 authored by Kai Uwe Broulik's avatar Kai Uwe Broulik 🍇

More cleanups and finish

* Ship some more default rules in plasmanotifyrc
* Improved do not disturb menu (add "for 4 hours" and "until disabled")
* Expand unread notifications by default, overall improved expansion/collapse handling
* Show unread count in panel and reset when plasmoid is closed again
* Move notification sanitizer and its test from dataengine to lib
* Load pixmaps into model also for files and limit their physical size
* Enforce maximum limit for notifications (1000...)
parent 8c0be01f
......@@ -35,11 +35,6 @@ set_package_properties(KF5NetworkManagerQt PROPERTIES DESCRIPTION "Qt wrapper fo
PURPOSE "Needed by geolocation data engine."
)
find_package(KF5PulseAudioQt)
set_package_properties(KF5PulseAudioQt PROPERTIES DESCRIPTION "Qt bindings for PulseAudio"
TYPE RECOMMENDED
PURPOSE "Needed so do not disturb mode when disable notification sounds")
find_package(KF5Kirigami2 ${KF5_MIN_VERSION} CONFIG)
set_package_properties(KF5Kirigami2 PROPERTIES
DESCRIPTION "A QtQuick based components set"
......
......@@ -99,7 +99,7 @@ MouseArea {
anchors.centerIn: parent
font.pointSize: -1
// FIXME fontSizeMode is awful but FontMetrics also doesn't cut it
font.pixelSize: Math.round(parent.height * (0.3 + 0.3 * text.length))
font.pixelSize: Math.round(parent.height * (0.3 + 0.3 / text.length))
// TODO add animation when it changes?
text: compactRoot.unreadCount || ""
}
......
......@@ -106,66 +106,63 @@ ColumnLayout {
}
}
PlasmaComponents.ContextMenu {
PlasmaComponents.ModelContextMenu {
id: dndMenu
property date date
visualParent: dndCheck
onTriggered: {
notificationSettings.notificationsInhibitedUntil = item.date;
onClicked: {
notificationSettings.notificationsInhibitedUntil = model.date;
notificationSettings.save();
}
PlasmaComponents.MenuItem {
section: true
text: i18n("Do not disturb")
}
model: {
var model = [];
PlasmaComponents.MenuItem {
text: i18n("For 1 hour")
readonly property date date: {
var d = dndMenu.date;
d.setHours(d.getHours() + 1);
d.setSeconds(0);
return d;
}
}
PlasmaComponents.MenuItem {
text: i18n("Until this evening")
// 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
d = dndMenu.date;
// TODO make the user's preferred time schedule configurable
visible: dndMenu.date.getHours() < dndEveningHour
readonly property date date: {
var d = dndMenu.date;
d.setHours(dndEveningHour);
d.setMinutes(0);
d.setSeconds(0);
return d;
}
}
PlasmaComponents.MenuItem {
text: i18n("Until tomorrow morning")
visible: dndMenu.date.getHours() > dndMorningHour
readonly property date date: {
var d = dndMenu.date;
d.setDate(d.getDate() + 1);
d.setHours(dndMorningHour);
d.setMinutes(0);
d.setSeconds(0);
return d;
}
}
PlasmaComponents.MenuItem {
text: i18n("Until Monday")
d.setHours(dndEveningHour);
d.setMinutes(0);
d.setSeconds(0);
model.push({date: d, text: i18n("Until this evening"), visible: dndMenu.date.getHours() < dndEveningHour});
// Until next morning
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"), visible: dndMenu.date.getHours() > dndMorningHour});
// Until Monday
var d = dndMenu.date;
d.setHours(dndMorningHour);
// wraps around if neccessary
d.setDate(d.getDate() + (7 - d.getDay() + 1));
d.setMinutes(0);
d.setSeconds(0);
// show Friday and Saturday, Sunday is "0" but for that you can use "until tomorrow morning"
visible: dndMenu.date.getDay() >= 5
readonly property date date: {
var d = dndMenu.date;
d.setHours(dndMorningHour);
// wraps around if neccessary
d.setDate(d.getDate() + (7 - d.getDay() + 1));
d.setMinutes(0);
d.setSeconds(0);
return d;
}
model.push({date: d, text: i18n("Until Monday"), visible: dndMenu.date.getDay() >= 5});
// Until "turned off"
var 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;
}
}
}
......@@ -199,7 +196,8 @@ ColumnLayout {
var sections = [];
if (!isNaN(inhibitedUntil.getTime())) {
// 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)));
}
......@@ -362,6 +360,8 @@ ColumnLayout {
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
......@@ -432,9 +432,19 @@ ColumnLayout {
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 - historyModel.groupLimit))
visible: model.groupChildrenCount > historyModel.groupLimit && delegateLoader.ListView.nextSection !== delegateLoader.ListView.section
onClicked: model.isGroupExpanded = !model.isGroupExpanded
"Show %1 More", (model.groupChildrenCount - model.expandedGroupChildrenCount))
visible: (model.groupChildrenCount > model.expandedGroupChildrenCount || model.isGroupExpanded)
&& delegateLoader.ListView.nextSection !== delegateLoader.ListView.section
onClicked: {
// Scroll to the group top if groups are collsped
if (model.isGroupExpanded) {
var persistentGroupIdx = historyModel.makePersistentModelIndex(historyModel.groupIndex(historyModel.index(model.index, 0)));
model.isGroupExpanded = false;
list.positionViewAtIndex(persistentGroupIdx.row, ListView.Contain);
} else {
model.isGroupExpanded = true;
}
}
}
PlasmaCore.SvgItem {
......
......@@ -99,6 +99,7 @@ RowLayout {
property string agoText: ""
visible: text !== ""
text: generateRemainingText() || agoText
Layout.rightMargin: 0 // the ToolButton's margins are enough
function generateAgoText() {
if (!time || isNaN(time.getTime()) || notificationHeading.jobState === NotificationManager.Notifications.JobStateRunning) {
......@@ -173,7 +174,6 @@ RowLayout {
RowLayout {
id: headerButtonsRow
spacing: 0
Layout.leftMargin: units.smallSpacing
PlasmaComponents.ToolButton {
id: configureButton
......
......@@ -26,6 +26,8 @@ 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 {
......@@ -53,7 +55,7 @@ ColumnLayout {
// This isn't an alias because TextEdit RichText adds some HTML tags to it
property string body
property alias icon: iconItem.source
property var icon
property var urls: []
property int jobState
......@@ -197,18 +199,38 @@ ColumnLayout {
onLinkActivated: Qt.openUrlExternally(link)
}
// inGroup IconItem is reparented here
// inGroup iconContainer is reparented here
}
}
PlasmaCore.IconItem {
id: iconItem
Item {
id: iconContainer
Layout.preferredWidth: units.iconSizes.large
Layout.preferredHeight: units.iconSizes.large
usesPlasmaTheme: false
smooth: true
// don't show two identical icons
visible: valid && source != notificationItem.applicationIconSource
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: typeof notificationItem.icon === "string" ? notificationItem.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
}
}
}
......@@ -322,7 +344,7 @@ ColumnLayout {
}*/
PropertyChanges {
target: iconItem
target: iconContainer
parent: bodyTextRow
}
}
......
......@@ -183,7 +183,7 @@ PlasmaCore.Dialog {
NotificationItem {
id: notificationItem
// let the item bleed into the dialog margins so the close button margins cancel out
y: -notificationPopup.margins.top
y: closable || dismissable || configurable ? -notificationPopup.margins.top : 0
headingRightPadding: -notificationPopup.margins.right
width: parent.width
hovered: area.containsMouse
......
......@@ -42,6 +42,33 @@ QtObject {
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]
......@@ -62,7 +89,7 @@ QtObject {
property int popupLocation: {
switch (notificationSettings.popupPosition) {
// Auto-determine location based on plasmoid location
case NotificationManager.Settings.NearWidget:
case NotificationManager.Settings.CloseToWidget:
if (!plasmoid) {
return Qt.AlignBottom | Qt.AlignRight; // just in case
}
......@@ -262,8 +289,8 @@ QtObject {
urgencies |= NotificationManager.Notifications.NormalUrgency;
}
// Low only when enabled in settings
if (notificationSettings.lowPriorityPopups) {
// Low only when enabled in settings and not in do not disturb mode
if (!globals.inhibited && notificationSettings.lowPriorityPopups) {
urgencies |=NotificationManager.Notifications.LowUrgency;
}
......@@ -272,12 +299,10 @@ QtObject {
}
property QtObject notificationSettings: NotificationManager.Settings {
notificationSoundsInhibited: globals.inhibited
onNotificationsInhibitedUntilChanged: globals.checkInhibition()
}
// This periodically checks whether do not disturb mode timed out
// 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"]
......@@ -392,4 +417,9 @@ QtObject {
Qt.callLater(positionPopups);
}
}
// TODO use pulseaudio-qt for this once it becomes a framework
property QtObject pulseAudio: Loader {
source: "PulseAudio.qml"
}
}
/*
* Copyright 2017, 2019 Kai Uwe Broulik <kde@privat.broulik.de>
*
* 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 <http://www.gnu.org/licenses/>
*/
import QtQuick 2.2
import org.kde.plasma.private.volume 0.1
QtObject {
id: pulseAudio
readonly property string notificationStreamId: "sink-input-by-media-role:event"
property QtObject notificationStream
property QtObject instantiator: Instantiator {
model: StreamRestoreModel {}
delegate: QtObject {
readonly property string name: Name
readonly property bool muted: Muted
function mute() {
Muted = true
}
function unmute() {
Muted = false
}
}
onObjectAdded: {
if (object.name === notificationStreamId) {
notificationStream = object;
}
}
}
}
......@@ -35,7 +35,7 @@ Item {
id: root
Plasmoid.status: historyModel.activeJobsCount > 0
|| Globals.popupNotificationsModel.activeNotificationsCount > 0
|| historyModel.unreadNotificationsCount > 0
|| Globals.inhibited ? PlasmaCore.Types.ActiveStatus
: PlasmaCore.Types.PassiveStatus
......@@ -73,12 +73,18 @@ Item {
Plasmoid.switchHeight: units.gridUnit * 10
Plasmoid.onExpandedChanged: {
historyModel.lastRead = undefined; // reset to now
if (!plasmoid.expanded) {
// FIXME Qt.callLater because system tray gets confused when an applet becomes passive when clicking to hide it
Qt.callLater(function() {
historyModel.lastRead = undefined; // reset to now
historyModel.collapseAllGroups();
});
}
}
Plasmoid.compactRepresentation: CompactRepresentation {
activeCount: Globals.popupNotificationsModel.activeNotificationsCount
unreadCount: historyModel.unreadNotificationsCount
unreadCount: Math.min(99, historyModel.unreadNotificationsCount)
jobsCount: historyModel.activeJobsCount
jobsPercentage: historyModel.jobsPercentage
......@@ -102,6 +108,7 @@ Item {
sortMode: NotificationManager.Notifications.SortByTypeAndUrgency
groupMode: NotificationManager.Notifications.GroupApplicationsFlat
groupLimit: 2
expandUnread: true
blacklistedDesktopEntries: notificationSettings.historyBlacklistedApplications
blacklistedNotifyRcNames: notificationSettings.historyBlacklistedServices
urgencies: {
......@@ -116,6 +123,9 @@ Item {
function action_clearHistory() {
historyModel.clear(NotificationManager.Notifications.ClearExpired);
if (historyModel.count === 0) {
plasmoid.expanded = false;
}
}
function action_openKcm() {
......
include(ECMMarkAsTest)
add_definitions(-DTRANSLATION_DOMAIN=\"plasma_engine_notifications\")
set(notifications_engine_SRCS
notificationsengine.cpp
notificationservice.cpp
notificationaction.cpp
notificationsanitizer.cpp
)
ecm_qt_declare_logging_category(notifications_engine_SRCS HEADER debug.h
......@@ -33,19 +30,3 @@ kcoreaddons_desktop_to_json(plasma_engine_notifications plasma-dataengine-notifi
install(TARGETS plasma_engine_notifications DESTINATION ${KDE_INSTALL_PLUGINDIR}/plasma/dataengine)
install(FILES plasma-dataengine-notifications.desktop DESTINATION ${KDE_INSTALL_KSERVICES5DIR} )
install(FILES notifications.operations DESTINATION ${PLASMA_DATA_INSTALL_DIR}/services)
#unit test
set(notifications_test_SRCS
notificationsanitizer.cpp
notifications_test.cpp
)
ecm_qt_declare_logging_category(notifications_test_SRCS HEADER debug.h
IDENTIFIER NOTIFICATIONS
CATEGORY_NAME kde.dataengine.notifications`
DEFAULT_SEVERITY Info)
add_executable(notification_test ${notifications_test_SRCS})
target_link_libraries(notification_test Qt5::Test Qt5::Core)
ecm_mark_as_test(notification_test)
/*
* Copyright (C) 2017 David Edmundson <davidedmundson@kde.org>
*
* This program is free software you can redistribute it and/or
* modify it under the terms of the GNU Library General Public
* License as published by the Free Software Foundation; either
* version 2 of the License, or (at your option) any later version.
*
* 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
* Library General Public License for more details.
*
* You should have received a copy of the GNU Library General Public License
* along with this library; see the file COPYING.LIB. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
*/
#include "notificationsanitizer.h"
#include <QXmlStreamReader>
#include <QXmlStreamWriter>
#include <QRegularExpression>
#include <QUrl>
#include "debug.h"
QString NotificationSanitizer::parse(const QString &text)
{
// replace all \ns with <br/>
QString t = text;
t.replace(QLatin1String("\n"), QStringLiteral("<br/>"));
// Now remove all inner whitespace (\ns are already <br/>s)
t = t.simplified();
// Finally, check if we don't have multiple <br/>s following,
// can happen for example when "\n \n" is sent, this replaces
// all <br/>s in succsession with just one
t.replace(QRegularExpression(QStringLiteral("<br/>\\s*<br/>(\\s|<br/>)*")), QLatin1String("<br/>"));
// 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 &#123 character references will be allowed
t.replace(QRegularExpression(QStringLiteral("&(?!(?:apos|quot|[gl]t|amp);|#)")), QLatin1String("&amp;"));
QXmlStreamReader r(QStringLiteral("<html>") + t + QStringLiteral("</html>"));
QString result;
QXmlStreamWriter out(&result);
const QVector<QString> 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(NOTIFICATIONS) << "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 &apos; is html4 stuff
// so we need to replace it here otherwise it will not render at all.
result = result.replace(QLatin1String("&apos;"), QChar('\''));
return result;
}
/*
* Copyright (C) 2017 David Edmundson <davidedmundson@kde.org>
*
* This program is free software you can redistribute it and/or
* modify it under the terms of the GNU Library General Public
* License as published by the Free Software Foundation; either
* version 2 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,