Commit 9cee05eb authored by Kai Uwe Broulik's avatar Kai Uwe Broulik 🍇

Implement do not disturb mode and grouping collapse

- Rework compact representation animations a bit
  For some reason they often got stuck
- Implement do not disturb mode for applications
- Add fallback timeout to ensure notifications eventually timeout
  Otherwise when disabling dnd mode you will get spammed
  and we would also keep apps running indefinitely waiting for the notification to close
- Install plasmanotifyrc with some sane defaults
  So far only lets Spectacle show its screenshot notifications in dnd mode
- Introduce Closable role rather than hardcoding that behavior everywhere in the view
- Better app identification/grouping for jobs
- Be more lenient about app identification (firefox will match Firefox, too)
- Cleanups
parent 54b4fc09
......@@ -69,19 +69,6 @@ MouseArea {
elementId: "notification-disabled"
Behavior on scale {
NumberAnimation {
duration: units.longDuration
easing.type: Easing.InOutQuad
}
}
Behavior on opacity {
NumberAnimation {
duration: units.longDuration
easing.type: Easing.InOutQuad
}
}
Item {
id: jobProgressItem
anchors {
......@@ -112,6 +99,22 @@ MouseArea {
anchors.centerIn: parent
font.pointSize: -1
font.pixelSize: Math.round(parent.height * 0.6)
text: count || "" // don't show number zero
property int count: 0
onCountChanged: countChangeAnimation.start();
ParallelAnimation {
id: countChangeAnimation
NumberAnimation {
target: countLabel
property: "scale"
from: 2
to: 1
duration: units.longDuration
easing.type: Easing.InOutQuad
}
}
}
PlasmaComponents.BusyIndicator {
......@@ -130,42 +133,16 @@ MouseArea {
opacity: 0
scale: 2
visible: opacity > 0
Behavior on scale {
NumberAnimation {
duration: units.longDuration
easing.type: Easing.InOutQuad
}
}
Behavior on opacity {
NumberAnimation {
duration: units.longDuration
easing.type: Easing.InOutQuad
}
}
}
}
PlasmaCore.IconItem {
id: dndIcon
anchors.fill: parent
source: "face-quiet"
source: "notifications-disabled"
opacity: 0
scale: 2
visible: opacity > 0
Behavior on scale {
NumberAnimation {
duration: units.longDuration
easing.type: Easing.InOutQuad
}
}
Behavior on opacity {
NumberAnimation {
duration: units.longDuration
easing.type: Easing.InOutQuad
}
}
}
states: [
......@@ -177,7 +154,7 @@ MouseArea {
}
PropertyChanges {
target: countLabel
text: compactRoot.jobsCount
count: compactRoot.jobsCount
}
PropertyChanges {
target: busyIndicator
......@@ -217,7 +194,19 @@ MouseArea {
}
PropertyChanges {
target: countLabel
text: compactRoot.unreadCount
count: compactRoot.unreadCount
}
}
]
transitions: [
Transition {
to: "*" // any state
NumberAnimation {
targets: [notificationIcon, notificationActiveItem, dndIcon]
properties: "opacity,scale"
duration: units.longDuration
easing.type: Easing.InOutQuad
}
}
]
......
......@@ -75,17 +75,19 @@ ColumnLayout {
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
// FIXME proper icon
source: "face-quiet"
source: "notifications-disabled"
Layout.preferredWidth: units.iconSizes.smallMedium
Layout.preferredHeight: units.iconSizes.smallMedium
}
......@@ -144,6 +146,17 @@ ColumnLayout {
}
PlasmaComponents.MenuItem {
text: i18n("Until Monday")
// 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;
}
}
}
}
......@@ -155,30 +168,50 @@ ColumnLayout {
PlasmaComponents.ToolButton {
iconName: "configure"
tooltip: plasmoid.action("configure").text
visible: plasmoid.action("configure").enabled
onClicked: plasmoid.action("configure").trigger()
tooltip: plasmoid.action("openKcm").text
visible: plasmoid.action("openKcm").enabled
onClicked: plasmoid.action("openKcm").trigger()
}
}
PlasmaExtras.DescriptiveLabel {
leftPadding: dndCheck.mirrored ? 0 : units.smallSpacing + dndCheck.indicator.width + dndCheck.spacing
rightPadding: dndCheck.mirrored ? units.smallSpacing + dndCheck.indicator.width + dndCheck.spacing : 0
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) {
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil
var inhibitedUntilValid = !isNaN(inhibitedUntil.getTime());
if (!Globals.inhibited) {
return "";
}
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil;
var inhibitedByApp = notificationSettings.notificationsInhibitedByApplication;
// TODO check app inhibition, too
if (inhibitedUntilValid) {
return i18nc("Do not disturb mode enabled until date", "Until %1",
KCoreAddons.Format.formatRelativeDateTime(inhibitedUntil, Locale.ShortFormat));
var sections = [];
if (!isNaN(inhibitedUntil.getTime())) {
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 "";
}
return sections.join(" · ");
}
visible: text !== ""
}
......@@ -208,8 +241,8 @@ ColumnLayout {
PlasmaComponents.ToolButton {
iconName: "edit-clear-history"
tooltip: i18n("Clear History")
visible: historyModel.expiredNotificationsCount > 0
onClicked: historyModel.clear(NotificationManager.Notifications.ClearExpired)
visible: plasmoid.action("clearHistory").visible
onClicked: action_clearHistory()
}
}
......@@ -249,11 +282,23 @@ ColumnLayout {
applicationName: model.applicationName
applicationIconSource: model.applicationIconName
time: model.updated || model.created
//time: model.updated || model.created
configurable: model.configurable
closable: model.closable
expandable: true
expanded: model.isGroupExpanded
expandedCount: model.groupChildrenCount
collapsedCount: historyModel.groupLimit
onExpandClicked: {
if (expanded) {
historyModel.collapse(historyModel.index(index, 0));
} else {
historyModel.expand(historyModel.index(index, 0));
}
}
// FIXME close group
onCloseClicked: historyModel.close(historyModel.index(index, 0))
//onDismissClicked: model.dismissed = false
// FIXME don't configure event but just app
......@@ -269,7 +314,7 @@ ColumnLayout {
notificationType: model.type
headerVisible: !model.isInGroup
inGroup: model.isInGroup
applicationName: model.applicationName
applicatonIconSource: model.applicationIconName
......@@ -283,8 +328,7 @@ ColumnLayout {
dismissable: model.type === NotificationManager.Notifications.JobType
&& model.jobState !== NotificationManager.Notifications.JobStateStopped
&& model.dismissed
closable: model.type === NotificationManager.Notifications.NotificationType
|| model.jobState === NotificationManager.Notifications.JobStateStopped
closable: model.closable
summary: model.summary
body: model.body || ""
......@@ -304,17 +348,21 @@ ColumnLayout {
// 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 (model.hasDefaultAction) {
if (addDefaultAction) {
actions.unshift("default"); // prepend
}
return actions;
}
actionLabels: {
var labels = (model.actionLabels || []);
if (model.hasDefaultAction) {
labels.unshift(model.defaultActionLabel || i18n("Open")); // make sure it has some label
if (addDefaultAction) {
labels.unshift(model.defaultActionLabel);
}
return labels;
}
......
......@@ -60,7 +60,7 @@ ColumnLayout {
Layout.fillWidth: true
textFormat: Text.PlainText
wrapMode: Text.WordWrap
text: jobItem.errorText || jobItem.jobDetails.text
text: jobItem.errorText || (jobItem.jobDetails ? jobItem.jobDetails.text : "")
visible: text !== ""
}
......@@ -134,6 +134,7 @@ ColumnLayout {
property var url: {
if (jobItem.jobState !== NotificationManager.Notifications.JobStateStopped
|| jobItem.error
|| !jobItem.jobDetails
|| jobItem.jobDetails.totalFiles <= 0) {
return null;
}
......@@ -170,7 +171,7 @@ ColumnLayout {
PlasmaComponents.Button {
// would be nice to have the file icon here?
text: jobItem.jobDetails.totalFiles > 1 ? i18n("Open Containing Folder") : i18n("Open")
text: jobItem.jobDetails && jobItem.jobDetails.totalFiles > 1 ? i18n("Open Containing Folder") : i18n("Open")
onClicked: jobItem.openUrl(jobDoneActions.url)
width: minimumWidth
}
......
......@@ -28,7 +28,7 @@ ColumnLayout {
property alias notificationType: notificationItem.notificationType
property alias headerVisible: notificationItem.headerVisible
property alias inGroup: notificationItem.inGroup
property alias applicationName: notificationItem.applicationName
property alias applicatonIconSource: notificationItem.applicationIconSource
......
......@@ -34,15 +34,20 @@ import "global"
RowLayout {
id: notificationHeading
property bool inGroup
property int notificationType
property alias applicationIconSource: applicationIconItem.source
property var applicationIconSource
property string applicationName
property string deviceName
property string configureActionLabel
property alias expandable: expandButton.visible
property bool expanded
property int collapsedCount
property int expandedCount
property alias configurable: configureButton.visible
property alias dismissable: dismissButton.visible
property alias closable: closeButton.visible
......@@ -52,6 +57,7 @@ RowLayout {
property int jobState
property QtObject jobDetails
signal expandClicked
signal configureClicked
signal dismissClicked
signal closeClicked
......@@ -69,7 +75,6 @@ RowLayout {
Connections {
target: Globals
// clock time changed
// TODO should we do this only when actually visible/expanded?
onTimeChanged: notificationHeading.updateAgoText()
}
......@@ -77,6 +82,7 @@ RowLayout {
id: applicationIconItem
Layout.preferredWidth: units.iconSizes.small
Layout.preferredHeight: units.iconSizes.small
source: !notificationHeading.inGroup ? notificationHeading.applicationIconSource : ""
usesPlasmaTheme: false
visible: valid
}
......@@ -96,7 +102,16 @@ RowLayout {
// updated periodically by a Timer hence this property with generate() function
property string agoText: ""
visible: text !== ""
text: generateRemainingText() || agoText
text: {
if (expandable) {
if (expanded) {
return expandedCount;
} else {
return i18nc("n more notifications", "+%1", (expandedCount - collapsedCount));
}
}
return generateRemainingText() || agoText;
}
function generateAgoText() {
if (!time || isNaN(time.getTime()) || notificationHeading.jobState === NotificationManager.Notifications.JobStateRunning) {
......@@ -175,6 +190,15 @@ RowLayout {
// These aren't ToolButtons so they can be perfectly aligned
// FIXME fix layout overlap
HeaderButton {
id: expandButton
tooltip: notificationHeading.expanded ? i18n("Show Less") : i18n("Show More")
iconSource: notificationHeading.expanded ? "arrow-up" : "arrow-down"
visible: false
onClicked: notificationHeading.expandClicked()
}
HeaderButton {
id: configureButton
tooltip: notificationHeading.configureActionLabel || i18n("Configure")
......
......@@ -37,7 +37,7 @@ ColumnLayout {
property int notificationType
property bool headerVisible: true
property bool inGroup: false
property alias applicationIconSource: notificationHeading.applicationIconSource
property alias applicationName: notificationHeading.applicationName
......@@ -97,7 +97,8 @@ ColumnLayout {
id: notificationHeading
Layout.fillWidth: true
visible: notificationItem.headerVisible
inGroup: notificationItem.inGroup
parent: inGroup ? inGroupHeaderContainer : defaultHeaderContainer
notificationType: notificationItem.notificationType
jobState: notificationItem.jobState
......@@ -108,6 +109,11 @@ ColumnLayout {
onCloseClicked: notificationItem.closeClicked()
}
RowLayout {
id: defaultHeaderContainer
Layout.fillWidth: true
}
// Notification body
RowLayout {
Layout.fillWidth: true
......@@ -117,6 +123,9 @@ ColumnLayout {
Layout.fillWidth: true
spacing: 0
RowLayout {
Layout.fillWidth: true
PlasmaExtras.Heading {
id: summaryLabel
Layout.fillWidth: true
......@@ -153,6 +162,13 @@ ColumnLayout {
}
}
RowLayout {
// When this notification is grouped, the header is reparented here here
id: inGroupHeaderContainer
Layout.fillHeight: true
}
}
SelectableLabel {
id: bodyLabel
Layout.alignment: Qt.AlignVCenter
......@@ -209,6 +225,7 @@ ColumnLayout {
RowLayout {
Layout.fillWidth: true
visible: actionRepeater.count > 0
// Notification actions
Flow { // it's a Flow so it can wrap if too long
......
......@@ -100,6 +100,7 @@ PlasmaCore.Dialog {
type: PlasmaCore.Dialog.Notification
flags: {
var flags = Qt.WindowDoesNotAcceptFocus;
// FIXME this needs support in KWin somehow...
if (urgency === NotificationManager.Notifications.CriticalUrgency) {
flags |= Qt.WindowStaysOnTopHint;
}
......@@ -185,7 +186,7 @@ PlasmaCore.Dialog {
id: notificationItem
width: parent.width
hovered: area.containsMouse
maximumLineCount: 8 // TODO configurable?
maximumLineCount: 8
bodyCursorShape: notificationPopup.hasDefaultAction ? Qt.PointingHandCursor : 0
thumbnailLeftPadding: -notificationPopup.margins.left
......@@ -196,7 +197,7 @@ PlasmaCore.Dialog {
closable: true
onBodyClicked: {
if (area.acceptedButtons & mouse.button) {
area.clicked(null /*mouse*/)
area.clicked(null /*mouse*/);
}
}
onCloseClicked: notificationPopup.closeClicked()
......
......@@ -178,11 +178,12 @@ QtObject {
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil;
if (!isNaN(inhibitedUntil.getTime())) {
console.log("INH", inhibitedUntil);
inhibited |= (new Date().getTime() < inhibitedUntil.getTime());
}
// TODO check app inhibition
if (notificationSettings.notificationsInhibitedByApplication) {
inhibited |= true;
}
return inhibited;
});
......@@ -248,6 +249,8 @@ QtObject {
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
......@@ -292,6 +295,9 @@ QtObject {
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
notificationType: model.type
......@@ -309,8 +315,7 @@ QtObject {
&& model.jobState !== NotificationManager.Notifications.JobStateStopped
// TODO would be nice to be able to "pin" jobs when they autohide
&& notificationSettings.permanentJobPopups
closable: model.type === NotificationManager.Notifications.NotificationType
|| model.jobState === NotificationManager.Notifications.JobStateStopped
closable: model.closable
summary: model.summary
body: model.body || ""
......@@ -371,13 +376,23 @@ QtObject {
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)
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);
}
onObjectRemoved: Qt.callLater(positionPopups)
}
}
......@@ -25,6 +25,7 @@ import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.kquickcontrolsaddons 2.0
import org.kde.kcoreaddons 1.0 as KCoreAddons
import org.kde.kquickcontrolsaddons 2.0 as KQCAddons
import org.kde.notificationmanager 1.0 as NotificationManager
......@@ -42,8 +43,11 @@ Item {
lines.push(i18np("%1 running job", "%1 running jobs", historyModel.activeJobsCount));
}
if (historyModel.unreadNotificationsCount > 0) {
lines.push(i18np("%1 unread notification", "%1 unread notifications", historyModel.unreadNotificationsCount));
// Any notification that is newer than "lastRead" is "unread"
// since it doesn't know the popup is on screen which makes the user see it
var actualUnread = historyModel.unreadNotificationsCount - Globals.popupNotificationsModel.activeNotificationsCount;
if (actualUnread > 0) {
lines.push(i18np("%1 unread notification", "%1 unread notifications", actualUnread));
}
if (Globals.inhibited) {
......@@ -117,11 +121,30 @@ Item {
showJobs: notificationSettings.jobsInNotifications
sortMode: NotificationManager.Notifications.SortByDate
groupMode: NotificationManager.Notifications.GroupApplicationsFlat
groupLimit: 2
blacklistedDesktopEntries: notificationSettings.historyBlacklistedApplications
blacklistedNotifyRcNames: notificationSettings.historyBlacklistedServices
}
function action_clearHistory() {
historyModel.clear(NotificationManager.Notifications.ClearExpired);
}
function action_openKcm() {