Commit 54b4fc09 authored by Kai Uwe Broulik's avatar Kai Uwe Broulik 🍇

Implement do not disturb mode, more history work, cleanup

- Add basic do not disturb mode
  Can set a time until it enabled, persisted across reboots
  Whitelist for apps missing right not
  Inhibition API not wired up yet
- d-pointer JobDetails
- Use KFilePlacesModel for prettier destUrl reporting "Copying to Home"
- Expose default action in history as button
- Improved right-to-left language support
- Let NotificationServer just lurk (without registering a service)
- Catch when plasmoid is deleted and stick to another one
parent e93bba28
......@@ -57,22 +57,22 @@ ColumnLayout {
property alias actionNames: notificationItem.actionNames
property alias actionLabels: notificationItem.actionLabels
property alias separatorSvg: lineSvgItem.svg
property alias separatorVisible: lineSvgItem.visible
signal configureClicked
signal dismissClicked
signal closeClicked
//signal defaultActionInvoked
signal actionInvoked(string actionName)
signal openUrl(string url)
signal fileActionInvoked
signal suspendJobClicked
signal resumeJobClicked
signal killJobClicked
// FIXME
property alias svg: lineSvgItem.svg
spacing: 0
spacing: units.smallSpacing
NotificationItem {
id: notificationItem
......@@ -86,6 +86,7 @@ ColumnLayout {
onActionInvoked: delegate.actionInvoked(actionName)
onOpenUrl: delegate.openUrl(url)
onFileActionInvoked: delegate.fileActionInvoked()
onSuspendJobClicked: delegate.suspendJobClicked()
onResumeJobClicked: delegate.resumeJobClicked()
......@@ -96,6 +97,5 @@ ColumnLayout {
id: lineSvgItem
elementId: "horizontal-line"
Layout.fillWidth: true
// TODO hide for last notification
}
}
......@@ -30,6 +30,8 @@ import org.kde.notificationmanager 1.0 as NotificationManager
import org.kde.kcoreaddons 1.0 as KCoreAddons
import "global"
RowLayout {
id: notificationHeading
......@@ -54,6 +56,7 @@ RowLayout {
signal dismissClicked
signal closeClicked
// notification created/updated time changed
onTimeChanged: updateAgoText()
function updateAgoText() {
......@@ -63,16 +66,11 @@ RowLayout {
spacing: units.smallSpacing
Layout.preferredHeight: Math.max(applicationNameLabel.implicitHeight, units.iconSizes.small)
// TODO this timer should probably be at a central location
// so every notification updates simultaneously
Timer {
interval: 60000
repeat: true
running: notificationHeading.visible
&& notificationHeading.Window.window
&& notificationHeading.Window.window.visible
triggeredOnStart: true
onTriggered: notificationHeading.updateAgoText()
Connections {
target: Globals
// clock time changed
// TODO should we do this only when actually visible/expanded?
onTimeChanged: notificationHeading.updateAgoText()
}
PlasmaCore.IconItem {
......@@ -101,7 +99,7 @@ RowLayout {
text: generateRemainingText() || agoText
function generateAgoText() {
if (!time || isNaN(time.getTime())) {
if (!time || isNaN(time.getTime()) || notificationHeading.jobState === NotificationManager.Notifications.JobStateRunning) {
return "";
}
......
......@@ -219,15 +219,27 @@ ColumnLayout {
Repeater {
id: actionRepeater
// HACK We want the actions to be right-aligned but Flow also reverses
// the order of items, so we manually reverse it here
model: (notificationItem.actionNames || []).reverse()
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
text: notificationItem.actionLabels[actionRepeater.count - index - 1]
// why does it spit "cannot assign undefined to string" when a notification becomes expired?
text: modelData.label || ""
Layout.preferredWidth: minimumWidth
onClicked: notificationItem.actionInvoked(modelData)
onClicked: notificationItem.actionInvoked(modelData.actionName)
}
}
}
......
......@@ -31,6 +31,8 @@ import ".."
PlasmaCore.Dialog {
id: notificationPopup
property int popupWidth
property alias notificationType: notificationItem.notificationType
//readonly property bool isNotification: notificationType === NotificationManager.Notifications.NotificationType
//readonly property bool isJob: notificationType === NotificationManager.Notifications.JobType
......@@ -115,7 +117,7 @@ PlasmaCore.Dialog {
mainItem: MouseArea {
id: area
width: popupHandler.popupWidth
width: notificationPopup.popupWidth
height: notificationItem.implicitHeight
hoverEnabled: true
......@@ -124,6 +126,9 @@ PlasmaCore.Dialog {
onClicked: notificationPopup.defaultActionInvoked()
LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft
LayoutMirroring.childrenInherit: true
Timer {
id: timer
interval: notificationPopup.effectiveTimeout
......
......@@ -31,45 +31,39 @@ 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: popupHandler
id: globals
// Some parts of the code rely on plasmoid.nativeInterface and since we're in a singleton here
// this is named "plasmoid", TODO fix?
property QtObject plasmoid: plasmoids[0]
// all notification plasmoids
property var plasmoids: []
// Listened to by "ago" label in NotificationHeader to update all of them in unison
signal timeChanged
// This heuristic tries to find a suitable plasmoid to follow when placing popups
function plasmoidScore(plasmoid) {
if (!plasmoid) {
return 0;
}
property bool inhibited: false
var score = 0;
// Reset the expire limiter so we don't get a flood of non-expired notifications
onInhibitedChanged: {
// 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;
}
// 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]
// Prefer plasmoids on primary screen
if (plasmoid.nativeInterface && plasmoid.nativeInterface.isPrimaryScreen(plasmoid.screenGeometry)) {
++score;
// 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();
}
return score;
}
// all notification plasmoids
property var plasmoids: []
property int popupLocation: {
switch (notificationSettings.popupPosition) {
// Auto-determine location based on plasmoid location
......@@ -81,10 +75,12 @@ QtObject {
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.AlignRight;
alignment |= Qt.application.layoutDirection === Qt.RightToLeft ? Qt.AlignLeft : Qt.AlignRight;
}
if (plasmoid.location === PlasmaCore.Types.TopEdge) {
alignment |= Qt.AlignTop;
......@@ -121,9 +117,46 @@ QtObject {
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.push(plasmoid);
newPlasmoids.sort(function (a, b) {
var scoreA = plasmoidScore(a);
var scoreB = plasmoidScore(b);
......@@ -136,8 +169,23 @@ QtObject {
return 0;
}
});
globals.plasmoids = newPlasmoids;
}
function checkInhibition() {
globals.inhibited = Qt.binding(function() {
var inhibited = false;
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil;
if (!isNaN(inhibitedUntil.getTime())) {
console.log("INH", inhibitedUntil);
inhibited |= (new Date().getTime() < inhibitedUntil.getTime());
}
// TODO check app inhibition
popupHandler.plasmoids = newPlasmoids;
return inhibited;
});
}
function positionPopups() {
......@@ -195,7 +243,7 @@ QtObject {
}
property QtObject popupNotificationsModel: NotificationManager.Notifications {
limit: Math.ceil(popupHandler.screenRect.height / (theme.mSize(theme.defaultFont).height * 4))
limit: Math.ceil(globals.screenRect.height / (theme.mSize(theme.defaultFont).height * 4))
showExpired: false
showDismissed: false
blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications
......@@ -204,19 +252,48 @@ QtObject {
sortMode: NotificationManager.Notifications.SortByTypeAndUrgency
groupMode: NotificationManager.Notifications.GroupDisabled
urgencies: {
var urgencies = NotificationManager.Notifications.NormalUrgency | NotificationManager.Notifications.CriticalUrgency;
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
if (notificationSettings.lowPriorityPopups) {
urgencies |=NotificationManager.Notifications.LowUrgency;
}
return urgencies;
}
}
property QtObject notificationSettings: NotificationManager.Settings { }
property QtObject notificationSettings: NotificationManager.Settings {
onNotificationsInhibitedUntilChanged: globals.checkInhibition()
}
// This periodically checks whether do not disturb mode timed out
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 {
popupWidth: globals.popupWidth
notificationType: model.type
applicationName: model.applicationName
......
......@@ -24,9 +24,11 @@ import org.kde.plasma.plasmoid 2.0
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.notificationmanager 1.0 as NotificationManager
import "popups" as Popups
import "global"
Item {
id: root
......@@ -44,8 +46,15 @@ Item {
lines.push(i18np("%1 unread notification", "%1 unread notifications", historyModel.unreadNotificationsCount));
}
if (notificationSettings.notificationsInhibited) {
lines.push(i18n("Do not disturb mode enabled"));
if (Globals.inhibited) {
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil
var inhibitedUntilValid = !isNaN(inhibitedUntil.getTime());
// TODO check app inhibition, too
if (inhibitedUntilValid) {
lines.push(i18n("Do not disturb until %1",
KCoreAddons.Format.formatRelativeDateTime(inhibitedUntil, Locale.ShortFormat)));
}
} else if (lines.length === 0) {
lines.push("No unread notificatons");
}
......@@ -61,13 +70,13 @@ Item {
}
Plasmoid.compactRepresentation: CompactRepresentation {
activeCount: Popups.PopupHandler.popupNotificationsModel.activeNotificationsCount
activeCount: Globals.popupNotificationsModel.activeNotificationsCount
unreadCount: historyModel.unreadNotificationsCount
jobsCount: historyModel.activeJobsCount
jobsPercentage: historyModel.jobsPercentage
inhibited: notificationSettings.notificationsInhibited
inhibited: Globals.inhibited
}
Plasmoid.fullRepresentation: FullRepresentation {
......@@ -78,9 +87,9 @@ Item {
Timer {
id: updateStatusTimer
readonly property int targetStatus: historyModel.activeJobsCount > 0
|| Popups.PopupHandler.popupNotificationsModel.activeNotificationsCount > 0
|| notificationSettings.notificationsInhibited ? PlasmaCore.Types.ActiveStatus
: PlasmaCore.Types.PassiveStatus
|| Globals.popupNotificationsModel.activeNotificationsCount > 0
|| Globals.inhibited ? PlasmaCore.Types.ActiveStatus
: PlasmaCore.Types.PassiveStatus
interval: 2000
onTargetStatusChanged: {
......@@ -113,6 +122,6 @@ Item {
}
Component.onCompleted: {
Popups.PopupHandler.adopt(plasmoid)
Globals.adopt(plasmoid)
}
}
......@@ -56,11 +56,12 @@ target_link_libraries(notificationmanager
PRIVATE
Qt5::DBus
KF5::ConfigGui
KF5::Service
KF5::Plasma
KF5::I18n
KF5::IconThemes
KF5::KIOFileWidgets
KF5::Plasma
KF5::ProcessCore
KF5::Service
)
set_target_properties(notificationmanager PROPERTIES
......
......@@ -27,6 +27,7 @@
#include "notifications.h"
#include "jobdetails.h"
#include "jobdetails_p.h"
#include <QQmlEngine>
......@@ -163,18 +164,15 @@ QVector<int> Job::processData(const QVariantMap/*Plasma::DataEngine::Data*/ &dat
processField(data, QStringLiteral("errorText"), m_errorText, Notifications::ErrorTextRole, dirtyRoles);
processField(data, QStringLiteral("error"), m_error, Notifications::ErrorRole, dirtyRoles);
// should we provide some custom error messages for stuff like OWNER_DIED?
QString errorText;
if (m_errorText.isEmpty()) {
switch (m_error) {
}
}
/*if (m_errorText.isEmpty() && m_error) {
m_errorText = KIO::buildErrorString(m_error);
dirtyRoles.append(Notifications::ErrorTextRole);
}*/
processField(data, QStringLiteral("killable"), m_killable, Notifications::KillableRole, dirtyRoles);
processField(data, QStringLiteral("suspendable"), m_suspendable, Notifications::SuspendableRole, dirtyRoles);
auto it = data.find("appName"); // TODO should we monitor icon change too?
auto it = data.find("appName");
if (it != end) {
const QString appName = it->toString();
if (m_appName != appName) {
......@@ -225,7 +223,7 @@ QVector<int> Job::processData(const QVariantMap/*Plasma::DataEngine::Data*/ &dat
}
}
m_details->processData(data);
m_details->d->processData(data);
return dirtyRoles;
}
......
......@@ -88,9 +88,9 @@ public:
bool operator==(const Job &other) const;
private:
friend class JobsModel;
private:
QString m_sourceName;
QDateTime m_created;
......
......@@ -23,33 +23,78 @@
#include <QDir>
#include <QDebug>
#include <KFilePlacesModel>
#include <KLocalizedString>
#include "jobdetails_p.h"
using namespace NotificationManager;
JobDetails::JobDetails(QObject *parent) : QObject(parent)
JobDetails::Private::Private(JobDetails *q)
: q(q)
, m_placesModel(createPlacesModel())
{
}
JobDetails::~JobDetails() = default;
JobDetails::Private::~Private() = default;
QString JobDetails::text() const
QSharedPointer<KFilePlacesModel> JobDetails::Private::createPlacesModel()
{
const QString currentFileName = descriptionUrl().fileName();
static QWeakPointer<KFilePlacesModel> s_instance;
if (!s_instance) {
QSharedPointer<KFilePlacesModel> ptr(new KFilePlacesModel());
s_instance = ptr.toWeakRef();
return ptr;
}
return s_instance.toStrongRef();
}
// Tries to return a more user-friendly displayed destination
// - if it is a place, show the name, e.g. "Downloads"
// - if it is inside home, abbreviate that to tilde ~/foo
// - otherwise print URL (without password)
QString JobDetails::Private::prettyDestUrl() const
{
if (!m_destUrl.isValid()) {
return QString();
}
if (!m_placesModel) {
m_placesModel = createPlacesModel();
}
// If we copy into a "place", show its pretty name instead of a URL/path
for (int row = 0; row < m_placesModel->rowCount(); ++row) {
const QModelIndex idx = m_placesModel->index(row, 0);
if (m_placesModel->isHidden(idx)) {
continue;
}
if (m_placesModel->url(idx).matches(m_destUrl, QUrl::StripTrailingSlash)) {
return m_placesModel->text(idx);
}
}
QString destUrlString;
if (m_destUrl.isLocalFile()) {
destUrlString = m_destUrl.toLocalFile();
QString destUrlString = m_destUrl.toLocalFile();
const QString homePath = QDir::homePath();
if (destUrlString.startsWith(homePath)) {
destUrlString = QStringLiteral("~") + destUrlString.mid(homePath.length());
}
} else {
destUrlString = m_destUrl.toDisplayString(); // strips password
return destUrlString;
}
return m_destUrl.toDisplayString(); // strips password
}
QString JobDetails::Private::text() const
{
const QString currentFileName = descriptionUrl().fileName();
const QString destUrlString = prettyDestUrl();
qDebug() << "JOB DETAILS" << "current file name" << currentFileName << "desturl" << destUrlString
<< "processed files" << m_processedFiles << "total files" << m_totalFiles
<< "processed dirs" << m_processedDirectories << "total dirs" << m_totalDirectories
......@@ -88,15 +133,6 @@ QString JobDetails::text() const
return QString();
}
QUrl JobDetails::descriptionUrl() const
{
QUrl url = QUrl::fromUserInput(m_descriptionValue2, QString(), QUrl::AssumeLocalFile);
if (!url.isValid()) {
url = QUrl::fromUserInput(m_descriptionValue1, QString(), QUrl::AssumeLocalFile);
}
return url;
}
template<typename T> bool processField(const QVariantMap/*Plasma::DataEngine::Data*/ &data,
const QString &field,
T &target,
......@@ -116,7 +152,7 @@ template<typename T> bool processField(const QVariantMap/*Plasma::DataEngine::Da
}
void JobDetails::processData(const QVariantMap &data)
void JobDetails::Private::processData(const QVariantMap &data)
{
bool textDirty = false;
bool urlDirty = false;
......@@ -127,28 +163,28 @@ void JobDetails::processData(const QVariantMap &data)
if (m_destUrl != destUrl) {