diff --git a/applets/notifications/package/contents/ui/NotificationDelegate.qml b/applets/notifications/package/contents/ui/NotificationDelegate.qml index 88a0a893e..d77d601df 100644 --- a/applets/notifications/package/contents/ui/NotificationDelegate.qml +++ b/applets/notifications/package/contents/ui/NotificationDelegate.qml @@ -1,145 +1,162 @@ /* * Copyright 2011 Marco Martin * * 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, 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 program; if not, write to the * Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import QtQuick 2.0 import QtQuick.Controls.Private 1.0 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 PlasmaComponents.ListItem { id: notificationItem width: popupFlickable.width property int layoutSpacing: units.smallSpacing property int toolIconSize: units.iconSizes.smallMedium opacity: 1-Math.abs(x)/width Timer { interval: 10*60*1000 repeat: false running: !idleTimeSource.idle onTriggered: { if (!notificationsModel.inserting) notificationsModel.remove(index) } } MouseArea { width: parent.width height: childrenRect.height drag { target: notificationItem axis: Drag.XAxis //kind of an hack over Column being too smart minimumX: -parent.width + 1 maximumX: parent.width - 1 } onReleased: { if (notificationItem.x < -notificationItem.width/2) { removeAnimation.exitFromRight = false removeAnimation.running = true } else if (notificationItem.x > notificationItem.width/2 ) { removeAnimation.exitFromRight = true removeAnimation.running = true } else { resetAnimation.running = true } } SequentialAnimation { id: removeAnimation property bool exitFromRight: true NumberAnimation { target: notificationItem properties: "x" to: removeAnimation.exitFromRight ? notificationItem.width-1 : 1-notificationItem.width duration: units.longDuration easing.type: Easing.InOutQuad } NumberAnimation { target: notificationItem properties: "height" to: 0 duration: units.longDuration easing.type: Easing.InOutQuad } ScriptAction { script: { closeNotification(model.source); notificationsModel.remove(index); } } } SequentialAnimation { id: resetAnimation NumberAnimation { target: notificationItem properties: "x" to: 0 duration: units.longDuration easing.type: Easing.InOutQuad } } NotificationItem { id: notification width: parent.width compact: true icon: appIcon image: model.image summary: model.summary body: model.body configurable: model.configurable && !Settings.isMobile // model.actions JS array is implicitly turned into a ListModel which we can assign directly actions: model.actions created: model.created + urls: { + // QML ListModel tries to be smart and turns our urls Array into a dict with index as key... + var urls = [] + + var modelUrls = model.urls + if (modelUrls) { + for (var key in modelUrls) { + urls.push(modelUrls[key]) + } + } + + return urls + } onClose: { if (notificationsModel.count > 1) { removeAnimation.running = true } else { closeNotification(model.source) notificationsModel.remove(index) } } onConfigure: { plasmoid.expanded = false configureNotification(model.appRealName, model.eventId) } onAction: { executeAction(source, actionId) actions.clear() } + onOpenUrl: { + plasmoid.expanded = false + Qt.openUrlExternally(url) + } } } //MouseArea Component.onCompleted: { mainScrollArea.height = mainScrollArea.implicitHeight } Component.onDestruction: { mainScrollArea.height = mainScrollArea.implicitHeight } } diff --git a/applets/notifications/package/contents/ui/NotificationItem.qml b/applets/notifications/package/contents/ui/NotificationItem.qml index 25f470f56..cadb26ed5 100644 --- a/applets/notifications/package/contents/ui/NotificationItem.qml +++ b/applets/notifications/package/contents/ui/NotificationItem.qml @@ -1,314 +1,330 @@ /* * Copyright 2011 Marco Martin * Copyright 2014 Kai Uwe Broulik * * 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, 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 program; if not, write to the * Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import QtQuick 2.0 import QtQuick.Layouts 1.1 import QtQuick.Controls.Private 1.0 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 Item { id: notificationItem width: parent.width implicitHeight: Math.max(appIconItem.visible || imageItem.visible ? units.iconSizes.large : 0, mainLayout.height) // We need to clip here because we support displaying images through // and if we don't clip, they will be painted over the borders of the dialog/item clip: true signal close signal configure signal action(string actionId) + signal openUrl(url url) property bool compact: false property alias icon: appIconItem.source property alias image: imageItem.image property alias summary: summaryLabel.text property alias body: bodyText.text property alias configurable: settingsButton.visible property var created + property var urls: [] property int maximumTextHeight: -1 property ListModel actions: ListModel { } function pressedAction() { for (var i = 0, count = actionRepeater.count; i < count; ++i) { var item = actionRepeater.itemAt(i) if (item.pressed) { return item } } + if (thumbnailStripLoader.item) { + var item = thumbnailStripLoader.item.pressedAction() + if (item) { + return item + } + } + if (settingsButton.pressed) { return settingsButton } return null } function updateTimeLabel() { if (!created || created.getTime() <= 0) { timeLabel.text = "" return } var currentTime = new Date().getTime() var createdTime = created.getTime() var d = (currentTime - createdTime) / 1000 if (d < 10) { timeLabel.text = i18nc("notification was just added, keep short", "Just now") } else if (d < 20) { timeLabel.text = i18nc("10 seconds ago, keep short", "10 s ago"); } else if (d < 40) { timeLabel.text = i18nc("30 seconds ago, keep short", "30 s ago"); } else if (d < 60 * 60) { timeLabel.text = i18ncp("minutes ago, keep short", "%1 min ago", "%1 min ago", Math.round(d / 60)) } else if (d <= 60 * 60 * 23) { timeLabel.text = Qt.formatTime(created, Qt.locale().timeFormat(Locale.ShortFormat).replace(/.ss?/i, "")) } else { var yesterday = new Date() yesterday.setDate(yesterday.getDate() - 1) // this will wrap yesterday.setHours(0) yesterday.setMinutes(0) yesterday.setSeconds(0) if (createdTime > yesterday.getTime()) { timeLabel.text = i18nc("notification was added yesterday, keep short", "Yesterday"); } else { timeLabel.text = i18ncp("notification was added n days ago, keep short", "%1 day ago", "%1 days ago", Math.round((currentTime - yesterday.getTime()) / 1000 / 3600 / 24)); } } } Timer { interval: 15000 running: plasmoid.expanded repeat: true triggeredOnStart: true onTriggered: updateTimeLabel() } PlasmaCore.IconItem { id: appIconItem width: units.iconSizes.large height: units.iconSizes.large anchors { top: parent.top left: parent.left } visible: !imageItem.visible && valid animated: false } QImageItem { id: imageItem anchors.fill: appIconItem smooth: true visible: nativeWidth > 0 } ColumnLayout { id: mainLayout anchors { top: parent.top left: appIconItem.visible || imageItem.visible ? appIconItem.right : parent.left right: parent.right leftMargin: units.smallSpacing * 2 } spacing: units.smallSpacing RowLayout { id: titleBar spacing: units.smallSpacing height: units.iconSizes.smallMedium PlasmaExtras.Heading { id: summaryLabel Layout.fillWidth: true Layout.fillHeight: true verticalAlignment: Text.AlignVCenter level: 4 elide: Text.ElideRight wrapMode: Text.NoWrap } PlasmaExtras.Heading { id: timeLabel Layout.fillHeight: true level: 5 visible: text !== "" verticalAlignment: Text.AlignVCenter PlasmaCore.ToolTipArea { anchors.fill: parent subText: Qt.formatDateTime(created, Qt.DefaultLocaleLongDate) } } PlasmaComponents.ToolButton { id: settingsButton width: units.iconSizes.smallMedium height: width visible: false iconSource: "configure" onClicked: configure() } PlasmaComponents.ToolButton { id: closeButton width: units.iconSizes.smallMedium height: width flat: compact iconSource: "window-close" onClicked: close() } } RowLayout { id: bottomPart Layout.alignment: Qt.AlignTop // Force the whole thing to collapse if the children are invisible // If there is a big notification followed by a small one, the height // of the popup does not always shrink back, so this forces it to // height=0 when those are invisible. -1 means "default to implicitHeight" Layout.maximumHeight: bodyText.visible || actionsColumn.visible ? -1 : 0 MouseArea { id: contextMouseArea Layout.alignment: Qt.AlignTop Layout.fillWidth: true anchors { leftMargin: units.smallSpacing * 2 rightMargin: units.smallSpacing * 2 } implicitHeight: maximumTextHeight > 0 ? Math.min(maximumTextHeight, bodyText.paintedHeight) : bodyText.paintedHeight acceptedButtons: Qt.RightButton preventStealing: true onPressed: contextMenu.open(mouse.x, mouse.y) PlasmaComponents.ContextMenu { id: contextMenu visualParent: contextMouseArea PlasmaComponents.MenuItem { text: i18n("Copy") onClicked: bodyText.copy() } PlasmaComponents.MenuItem { text: i18n("Select All") onClicked: bodyText.selectAll() } } PlasmaExtras.ScrollArea { id: bodyTextScrollArea anchors.fill: parent visible: bodyText.length > 0 flickableItem.boundsBehavior: Flickable.StopAtBounds flickableItem.flickableDirection: Flickable.VerticalFlick horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff TextEdit { id: bodyText width: bodyTextScrollArea.width enabled: !Settings.isMobile color: PlasmaCore.ColorScope.textColor selectedTextColor: theme.viewBackgroundColor selectionColor: theme.viewFocusColor font.capitalization: theme.defaultFont.capitalization font.family: theme.defaultFont.family font.italic: theme.defaultFont.italic font.letterSpacing: theme.defaultFont.letterSpacing font.pointSize: theme.defaultFont.pointSize font.strikeout: theme.defaultFont.strikeout font.underline: theme.defaultFont.underline font.weight: theme.defaultFont.weight font.wordSpacing: theme.defaultFont.wordSpacing renderType: Text.NativeRendering selectByMouse: true readOnly: true wrapMode: Text.Wrap textFormat: TextEdit.RichText onLinkActivated: Qt.openUrlExternally(link) // ensure selecting text scrolls the view as needed... onCursorRectangleChanged: { var flick = bodyTextScrollArea.flickableItem if (flick.contentY >= cursorRectangle.y) { flick.contentY = cursorRectangle.y } else if (flick.contentY + flick.height <= cursorRectangle.y + cursorRectangle.height) { flick.contentY = cursorRectangle.y + cursorRectangle.height - flick.height } } } } } ColumnLayout { id: actionsColumn Layout.alignment: Qt.AlignTop Layout.maximumWidth: theme.mSize(theme.defaultFont).width * (compact ? 8 : 12) spacing: units.smallSpacing visible: notificationItem.actions && notificationItem.actions.count > 0 Repeater { id: actionRepeater model: notificationItem.actions PlasmaComponents.Button { Layout.maximumWidth: actionsColumn.Layout.maximumWidth text: model.text onClicked: notificationItem.action(model.id) } } } } - } + Loader { + id: thumbnailStripLoader + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + source: "ThumbnailStrip.qml" + active: notificationItem.urls.length > 0 + } + } } diff --git a/applets/notifications/package/contents/ui/NotificationPopup.qml b/applets/notifications/package/contents/ui/NotificationPopup.qml index 8690d1034..1ac0b4870 100644 --- a/applets/notifications/package/contents/ui/NotificationPopup.qml +++ b/applets/notifications/package/contents/ui/NotificationPopup.qml @@ -1,139 +1,146 @@ /* * Copyright 2014 Martin Klapetek * * 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, 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 program; if not, write to the * Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import QtQuick 2.0 import QtQuick.Controls.Private 1.0 import QtQuick.Layouts 1.1 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 import org.kde.kquickcontrolsaddons 2.0 as KQuickControlsAddons PlasmaCore.Dialog { id: notificationPopup location: PlasmaCore.Types.Floating type: PlasmaCore.Dialog.Notification flags: Qt.WindowDoesNotAcceptFocus property var notificationProperties signal notificationTimeout() onVisibleChanged: { if (!visible) { notificationTimer.stop(); } } onYChanged: { if (visible) { notificationTimer.restart(); } } function populatePopup(notification) { notificationProperties = notification notificationTimer.interval = notification.expireTimeout notificationTimer.restart() // notification.actions is a JS array, but we can easily append that to our model notificationItem.actions.clear() notificationItem.actions.append(notificationProperties.actions) } Behavior on y { NumberAnimation { duration: units.longDuration easing.type: Easing.OutQuad } } mainItem: KQuickControlsAddons.MouseEventListener { id: root LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft LayoutMirroring.childrenInherit: true width: notificationItem.width + 2 * notificationItem.x height: notificationItem.implicitHeight + 2 * notificationItem.y hoverEnabled: true onClicked: { // the MEL would close the notification before the action button // onClicked handler would fire effectively breaking notification actions if (notificationItem.pressedAction()) { return } closeNotification(notificationProperties.source) notificationPopup.hide() } onContainsMouseChanged: { if (containsMouse) { notificationTimer.stop() } else if (!containsMouse && visible) { notificationTimer.restart() } } Timer { id: notificationTimer onTriggered: { if (!notificationProperties.isPersistent) { expireNotification(notificationProperties.source) } notificationPopup.notificationTimeout(); } } NotificationItem { id: notificationItem summary: notificationProperties ? notificationProperties.summary: "" body: notificationProperties ? notificationProperties.body : "" icon: notificationProperties ? notificationProperties.appIcon : "" image: notificationProperties ? notificationProperties.image : undefined configurable: (notificationProperties ? notificationProperties.configurable : false) && !Settings.isMobile + urls: notificationProperties ? notificationProperties.urls : [] x: units.smallSpacing y: units.smallSpacing width: Math.round(23 * units.gridUnit) maximumTextHeight: theme.mSize(theme.defaultFont).height * 10 onClose: { closeNotification(notificationProperties.source) notificationPopup.hide() } onConfigure: { configureNotification(notificationProperties.appRealName, notificationProperties.eventId) notificationPopup.hide() } onAction: { executeAction(notificationProperties.source, actionId) actions.clear() notificationPopup.hide() } + onOpenUrl: { + Qt.openUrlExternally(url) + // we want to close the notification popup when opening a URL but + // it should not be removed from the history, hence pretending it expired + notificationTimer.triggered() + } } } } diff --git a/applets/notifications/package/contents/ui/ThumbnailStrip.qml b/applets/notifications/package/contents/ui/ThumbnailStrip.qml new file mode 100644 index 000000000..414de990d --- /dev/null +++ b/applets/notifications/package/contents/ui/ThumbnailStrip.qml @@ -0,0 +1,187 @@ +/* + * Copyright 2016 Kai Uwe Broulik + * + * 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, 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 program; if not, write to the + * Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import QtQuick 2.0 +import QtQuick.Layouts 1.1 + +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 + +import org.kde.plasma.private.notifications 1.0 as Notifications + +ListView { + id: previewList + + readonly property int itemSquareSize: units.gridUnit * 4 + + // if it's only one file, show a larger preview + // however if no preview could be generated, which we only know after we tried, + // the delegate will use itemSquareSize instead as there's no point in showing a huge file icon + readonly property int itemWidth: previewList.count === 1 ? width : itemSquareSize + readonly property int itemHeight: previewList.count === 1 ? Math.round(width / 3) : itemSquareSize + + // by the time the "model" is populated, the Layout isn't finished yet, causing ListView to have a 0 width + // hence it's based on the mainLayout.width instead + readonly property int maximumItemCount: Math.floor(mainLayout.width / itemSquareSize) + + model: { + var urls = notificationItem.urls + if (urls.length <= maximumItemCount) { + return urls + } + // if it wouldn't fit, remove one item in favor of the "+n" badge + return urls.slice(0, maximumItemCount - 1) + } + orientation: ListView.Horizontal + spacing: units.smallSpacing + interactive: false + + footer: notificationItem.urls.length > maximumItemCount ? moreBadge : null + + function pressedAction() { + for (var i = 0; i < count; ++i) { + var item = itemAtIndex(i) + if (item.pressed) { + return item + } + } + } + + // HACK ListView only provides itemAt(x,y) methods but since we don't scroll + // we can make assumptions on what our layout looks like... + function itemAtIndex(index) { + return itemAt(index * (itemSquareSize + spacing), 0) + } + + Component { + id: moreBadge + + // if there's more urls than we can display, show a "+n" badge + Item { + width: moreLabel.width + height: previewList.height + + PlasmaExtras.Heading { + id: moreLabel + anchors { + left: parent.left + // ListView doesn't add spacing before the footer + leftMargin: previewList.spacing + top: parent.top + bottom: parent.bottom + } + level: 3 + verticalAlignment: Text.AlignVCenter + text: i18nc("Indicator that there are more urls in the notification than previews shown", "+%1", notificationItem.urls.length - previewList.count) + } + } + } + + delegate: MouseArea { + id: previewDelegate + + // clip is expensive, only clip if the QPixmapItem would leak outside + clip: previewPixmap.height > height + + width: thumbnailer.hasPreview ? previewList.itemWidth : previewList.itemSquareSize + height: thumbnailer.hasPreview ? previewList.itemHeight : previewList.itemSquareSize + + preventStealing: true + cursorShape: Qt.OpenHandCursor + + onClicked: notificationItem.openUrl(modelData) + + // cannot drag itself, hence dragging the Pixmap instead + drag.target: previewPixmap + + Drag.dragType: Drag.Automatic + Drag.active: previewDelegate.drag.active + Drag.mimeData: ({ + "text/uri-list": modelData, + "text/plain": modelData + }) + + // first item determins the ListView height + Binding { + target: previewList + property: "implicitHeight" + value: previewDelegate.height + when: index === 0 + } + + Notifications.Thumbnailer { + id: thumbnailer + + readonly property real ratio: pixmapSize.height ? pixmapSize.width / pixmapSize.height : 1 + + url: modelData + size: Qt.size(previewList.itemWidth, previewList.itemHeight) + } + + QPixmapItem { + id: previewPixmap + + anchors.centerIn: parent + + width: parent.width + height: width / thumbnailer.ratio + pixmap: thumbnailer.pixmap + } + + PlasmaCore.IconItem { + anchors.fill: parent + source: thumbnailer.iconName + usesPlasmaTheme: false + } + + Rectangle { + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + color: theme.textColor + opacity: 0.6 + height: fileNameLabel.contentHeight + + PlasmaComponents.Label { + id: fileNameLabel + anchors { + fill: parent + leftMargin: units.smallSpacing + rightMargin: units.smallSpacing + } + wrapMode: Text.NoWrap + height: implicitHeight // unset Label default height + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + font.pointSize: theme.smallestFont.pointSize + color: theme.backgroundColor + text: { + var splitUrl = modelData.split("/") + return splitUrl[splitUrl.length - 1] + } + } + } + } +} diff --git a/applets/notifications/plugin/CMakeLists.txt b/applets/notifications/plugin/CMakeLists.txt index 6ffaef25d..cbb45224a 100644 --- a/applets/notifications/plugin/CMakeLists.txt +++ b/applets/notifications/plugin/CMakeLists.txt @@ -1,14 +1,17 @@ set(notificationshelper_SRCS notificationshelper.cpp notificationshelperplugin.cpp + thumbnailer.cpp ) add_library(notificationshelperplugin SHARED ${notificationshelper_SRCS}) target_link_libraries(notificationshelperplugin Qt5::Core Qt5::Gui Qt5::Qml - Qt5::Quick) + Qt5::Quick + KF5::KIOWidgets # PreviewJob + ) install(TARGETS notificationshelperplugin DESTINATION ${KDE_INSTALL_QMLDIR}/org/kde/plasma/private/notifications) install(FILES qmldir DESTINATION ${KDE_INSTALL_QMLDIR}/org/kde/plasma/private/notifications) diff --git a/applets/notifications/plugin/notificationshelperplugin.cpp b/applets/notifications/plugin/notificationshelperplugin.cpp index 2bb210e0e..32c684e2d 100644 --- a/applets/notifications/plugin/notificationshelperplugin.cpp +++ b/applets/notifications/plugin/notificationshelperplugin.cpp @@ -1,89 +1,92 @@ /* Copyright (C) 2014 Martin Klapetek 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) any later version. 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, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ #include "notificationshelperplugin.h" #include "notificationshelper.h" +#include "thumbnailer.h" #include #include #include #include class NoAccessNetworkAccessManagerFactory : public QQmlNetworkAccessManagerFactory { public: QNetworkAccessManager *create(QObject *parent) override { QNetworkAccessManager *manager = new QNetworkAccessManager(parent); manager->setNetworkAccessible(QNetworkAccessManager::NotAccessible); return manager; } }; class UrlHelper : public QObject { Q_OBJECT public: Q_INVOKABLE bool isUrlValid(const QString &url) const { return QUrl::fromUserInput(url).isValid(); } }; static QObject *urlcheck_singletontype_provider(QQmlEngine *engine, QJSEngine *scriptEngine) { Q_UNUSED(engine) Q_UNUSED(scriptEngine) return new UrlHelper(); } class ProcessRunner : public QObject { Q_OBJECT public: Q_INVOKABLE void runNotificationsKCM() const { QProcess::startDetached(QStringLiteral("kcmshell5"), QStringList() << QStringLiteral("kcmnotify")); } }; static QObject *processrunner_singleton_provider(QQmlEngine *engine, QJSEngine *scriptEngine) { Q_UNUSED(engine) Q_UNUSED(scriptEngine) return new ProcessRunner(); } void NotificationsHelperPlugin::registerTypes(const char *uri) { Q_ASSERT(uri == QLatin1String("org.kde.plasma.private.notifications")); qmlRegisterType(uri, 1, 0, "NotificationsHelper"); + qmlRegisterType(uri, 1, 0, "Thumbnailer"); + qmlRegisterSingletonType(uri, 1, 0, "UrlHelper", urlcheck_singletontype_provider); qmlRegisterSingletonType(uri, 1, 0, "ProcessRunner", processrunner_singleton_provider); } void NotificationsHelperPlugin::initializeEngine(QQmlEngine *engine, const char *uri) { Q_ASSERT(uri == QLatin1String("org.kde.plasma.private.notifications")); auto oldFactory = engine->networkAccessManagerFactory(); engine->setNetworkAccessManagerFactory(nullptr); delete oldFactory; engine->setNetworkAccessManagerFactory(new NoAccessNetworkAccessManagerFactory); } #include "notificationshelperplugin.moc" diff --git a/applets/notifications/plugin/thumbnailer.cpp b/applets/notifications/plugin/thumbnailer.cpp new file mode 100644 index 000000000..83188b309 --- /dev/null +++ b/applets/notifications/plugin/thumbnailer.cpp @@ -0,0 +1,130 @@ +/* + Copyright (C) 2016 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) any later version. + + 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, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "thumbnailer.h" + +#include + +#include +#include + +Thumbnailer::Thumbnailer(QObject *parent) : QObject(parent) +{ + +} + +Thumbnailer::~Thumbnailer() = default; + +void Thumbnailer::classBegin() +{ + +} + +void Thumbnailer::componentComplete() +{ + m_inited = true; + generatePreview(); +} + +QUrl Thumbnailer::url() const +{ + return m_url; +} + +void Thumbnailer::setUrl(const QUrl &url) +{ + if (m_url != url) { + m_url = url; + emit urlChanged(); + + generatePreview(); + } +} + +QSize Thumbnailer::size() const +{ + return m_size; +} + +void Thumbnailer::setSize(const QSize &size) +{ + if (m_size != size) { + m_size = size; + emit sizeChanged(); + + generatePreview(); + } +} + +bool Thumbnailer::hasPreview() const +{ + return !m_pixmap.isNull(); +} + +QPixmap Thumbnailer::pixmap() const +{ + return m_pixmap; +} + +QSize Thumbnailer::pixmapSize() const +{ + return m_pixmap.size(); +} + +QString Thumbnailer::iconName() const +{ + return m_iconName; +} + +void Thumbnailer::generatePreview() +{ + if (!m_inited) { + return; + } + + if (!m_url.isValid() || !m_url.isLocalFile() || !m_size.isValid()) { + return; + } + + KIO::PreviewJob *job = KIO::filePreview(KFileItemList({KFileItem(m_url)}), m_size); + job->setIgnoreMaximumSize(true); + + connect(job, &KIO::PreviewJob::gotPreview, this, [this](const KFileItem &item, const QPixmap &preview) { + Q_UNUSED(item); + m_pixmap = preview; + emit pixmapChanged(); + + if (!m_iconName.isEmpty()) { + m_iconName.clear(); + emit iconNameChanged(); + } + }); + + connect(job, &KIO::PreviewJob::failed, this, [this](const KFileItem &item) { + m_pixmap = QPixmap(); + emit pixmapChanged(); + + const QString &iconName = item.determineMimeType().iconName(); + if (m_iconName != iconName) { + m_iconName = iconName; + emit iconNameChanged(); + } + }); + + job->start(); +} diff --git a/applets/notifications/plugin/thumbnailer.h b/applets/notifications/plugin/thumbnailer.h new file mode 100644 index 000000000..7bce2cad6 --- /dev/null +++ b/applets/notifications/plugin/thumbnailer.h @@ -0,0 +1,79 @@ +/* + Copyright (C) 2016 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) any later version. + + 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, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#pragma once + +#include +#include + +#include +#include +#include + +class Thumbnailer : public QObject, public QQmlParserStatus +{ + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + + Q_PROPERTY(QUrl url READ url WRITE setUrl NOTIFY urlChanged) + Q_PROPERTY(QSize size READ size WRITE setSize NOTIFY sizeChanged) + + Q_PROPERTY(bool hasPreview READ hasPreview NOTIFY pixmapChanged) + Q_PROPERTY(QPixmap pixmap READ pixmap NOTIFY pixmapChanged) + Q_PROPERTY(QSize pixmapSize READ pixmapSize NOTIFY pixmapChanged) + + Q_PROPERTY(QString iconName READ iconName NOTIFY iconNameChanged) + +public: + explicit Thumbnailer(QObject *parent = nullptr); + ~Thumbnailer() override; + + QUrl url() const; + void setUrl(const QUrl &url); + + QSize size() const; + void setSize(const QSize &size); + + bool hasPreview() const; + QPixmap pixmap() const; + QSize pixmapSize() const; + + QString iconName() const; + + void classBegin() override; + void componentComplete() override; + +signals: + void urlChanged(); + void sizeChanged(); + void pixmapChanged(); + void iconNameChanged(); + +private: + void generatePreview(); + + bool m_inited = false; + + QUrl m_url; + QSize m_size; + + QPixmap m_pixmap; + + QString m_iconName; + +}; diff --git a/dataengines/notifications/notificationsengine.cpp b/dataengines/notifications/notificationsengine.cpp index 2bc4dc25e..d0d218a87 100644 --- a/dataengines/notifications/notificationsengine.cpp +++ b/dataengines/notifications/notificationsengine.cpp @@ -1,410 +1,413 @@ /* * Copyright (C) 2008 Dmitry Suzdalev * * 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 "notificationsengine.h" #include "notificationservice.h" #include "notificationsadaptor.h" #include #include #include #include #include #include #include #include #include #include #include // for ::kill #include NotificationsEngine::NotificationsEngine( QObject* parent, const QVariantList& args ) : Plasma::DataEngine( parent, args ), m_nextId( 1 ), m_alwaysReplaceAppsList({QStringLiteral("Clementine"), QStringLiteral("Spotify"), QStringLiteral("Amarok")}) { new NotificationsAdaptor(this); if (!registerDBusService()) { QDBusConnection dbus = QDBusConnection::sessionBus(); // Retrieve the pid of the current o.f.Notifications service QDBusReply pidReply = dbus.interface()->servicePid(QStringLiteral("org.freedesktop.Notifications")); uint pid = pidReply.value(); // Check if it's not the same app as our own if (pid != qApp->applicationPid()) { QDBusReply plasmaPidReply = dbus.interface()->servicePid(QStringLiteral("org.kde.plasmashell")); // It's not the same but check if it isn't plasma, // we don't want to kill Plasma if (pid != plasmaPidReply.value()) { qDebug() << "Terminating current Notification service with pid" << pid; // Now finally terminate the service and register our own ::kill(pid, SIGTERM); // Wait 3 seconds and then try registering it again QTimer::singleShot(3000, this, &NotificationsEngine::registerDBusService); } } } // Read additional single-notification-popup-only from a config file KConfig singlePopupConfig(QStringLiteral("plasma_single_popup_notificationrc")); KConfigGroup singlePopupConfigGroup(&singlePopupConfig, "General"); m_alwaysReplaceAppsList += QSet::fromList(singlePopupConfigGroup.readEntry("applications", QStringList())); } NotificationsEngine::~NotificationsEngine() { QDBusConnection dbus = QDBusConnection::sessionBus(); dbus.unregisterService( QStringLiteral("org.freedesktop.Notifications") ); } void NotificationsEngine::init() { } bool NotificationsEngine::registerDBusService() { QDBusConnection dbus = QDBusConnection::sessionBus(); bool so = dbus.registerService(QStringLiteral("org.freedesktop.Notifications")); if (so) { bool ro = dbus.registerObject(QStringLiteral("/org/freedesktop/Notifications"), this); if (ro) { qDebug() << "Notifications service registered"; return true; } else { dbus.unregisterService(QStringLiteral("org.freedesktop.Notifications")); } } qDebug() << "Failed to register Notifications service"; return false; } inline void 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]); } } inline void 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]); } } static QImage 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(); //qDebug() << width << height << rowStride << hasAlpha << bitsPerSample << channels; #define SANITY_CHECK(condition) \ if (!(condition)) { \ qWarning() << "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 QImage::Format format = QImage::Format_Invalid; void (*fcn)(QRgb*, const char*, int) = 0; 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) { qWarning() << "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) { qWarning() << "Image data is incomplete. y:" << y << "height:" << height; break; } fcn((QRgb*)image.scanLine(y), ptr, width); } return image; } static QString findImageForSpecImagePath(const QString &_path) { QString path = _path; if (path.startsWith(QLatin1String("file:"))) { QUrl url(path); path = url.toLocalFile(); } return KIconLoader::global()->iconPath(path, -KIconLoader::SizeHuge, true /* canReturnNull */); } uint NotificationsEngine::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) { uint partOf = 0; const QString appRealName = hints[QStringLiteral("x-kde-appname")].toString(); const QString eventId = hints[QStringLiteral("x-kde-eventId")].toString(); const bool skipGrouping = hints[QStringLiteral("x-kde-skipGrouping")].toBool(); + const QStringList &urls = hints[QStringLiteral("x-kde-urls")].toStringList(); // group notifications that have the same title coming from the same app // or if they are on the "blacklist", honor the skipGrouping hint sent if (!replaces_id && m_activeNotifications.values().contains(app_name + summary) && !skipGrouping && !m_alwaysReplaceAppsList.contains(app_name)) { // cut off the "notification " from the source name partOf = m_activeNotifications.key(app_name + summary).midRef(13).toUInt(); } qDebug() << "Currrent active notifications:" << m_activeNotifications; qDebug() << "Guessing partOf as:" << partOf; qDebug() << " New Notification: " << summary << body << timeout << "& Part of:" << partOf; QString _body; if (partOf > 0) { const QString source = QStringLiteral("notification %1").arg(partOf); Plasma::DataContainer *container = containerForSource(source); if (container) { // append the body text _body = container->data()[QStringLiteral("body")].toString(); if (_body != body) { _body.append("\n").append(body); } else { _body = body; } replaces_id = partOf; // remove the old notification and replace it with the new one // TODO: maybe just update the current notification? CloseNotification(partOf); } } uint id = replaces_id ? replaces_id : m_nextId++; // If the current app is in the "blacklist"... if (m_alwaysReplaceAppsList.contains(app_name)) { // ...check if we already have a notification from that particular // app and if yes, use its id to replace it if (m_notificationsFromReplaceableApp.contains(app_name)) { id = m_notificationsFromReplaceableApp.value(app_name); } else { m_notificationsFromReplaceableApp.insert(app_name, id); } } QString appname_str = app_name; if (appname_str.isEmpty()) { appname_str = i18n("Unknown Application"); } bool isPersistent = timeout == 0; const int AVERAGE_WORD_LENGTH = 6; const int WORD_PER_MINUTE = 250; int count = summary.length() + body.length(); // -1 is "server default", 0 is persistent with "server default" display time, // anything more should honor the setting if (timeout <= 0) { timeout = 60000 * count / AVERAGE_WORD_LENGTH / WORD_PER_MINUTE; // Add two seconds for the user to notice the notification, and ensure // it last at least five seconds, otherwise all the user see is a // flash timeout = 2000 + qMax(timeout, 3000); } const QString source = QStringLiteral("notification %1").arg(id); QString bodyFinal = (partOf == 0 ? body : _body); // First trim whitespace from beginning and end bodyFinal = bodyFinal.trimmed(); // Now replace all \ns with
bodyFinal = bodyFinal.replace(QLatin1String("\n"), QLatin1String("
")); // Now remove all inner whitespace (\ns are already
s bodyFinal = bodyFinal.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 bodyFinal.replace(QRegularExpression(QStringLiteral("
\\s*
(\\s|
)*")), QLatin1String("
")); // This fancy RegExp escapes every occurence 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 bodyFinal.replace(QRegularExpression(QStringLiteral("&(?!(?:apos|quot|[gl]t|amp);|#)")), QLatin1String("&")); // 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. bodyFinal.replace(QLatin1String("'"), QChar('\'')); Plasma::DataEngine::Data notificationData; notificationData.insert(QStringLiteral("id"), QString::number(id)); notificationData.insert(QStringLiteral("eventId"), eventId); notificationData.insert(QStringLiteral("appName"), appname_str); notificationData.insert(QStringLiteral("appIcon"), app_icon); notificationData.insert(QStringLiteral("summary"), summary); notificationData.insert(QStringLiteral("body"), bodyFinal); notificationData.insert(QStringLiteral("actions"), actions); notificationData.insert(QStringLiteral("isPersistent"), isPersistent); notificationData.insert(QStringLiteral("expireTimeout"), timeout); bool configurable = false; if (!appRealName.isEmpty()) { if (m_configurableApplications.contains(appRealName)) { configurable = m_configurableApplications.value(appRealName); } else { // Check whether the application actually has notifications we can configure QScopedPointer config(new KConfig(appRealName + QStringLiteral(".notifyrc"), KConfig::NoGlobals)); config->addConfigSources(QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, QStringLiteral("knotifications5/") + appRealName + QStringLiteral(".notifyrc"))); const QRegularExpression regexp(QStringLiteral("^Event/([^/]*)$")); configurable = !config->groupList().filter(regexp).isEmpty(); m_configurableApplications.insert(appRealName, configurable); } } notificationData.insert(QStringLiteral("appRealName"), appRealName); notificationData.insert(QStringLiteral("configurable"), configurable); QImage image; // 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. if (hints.contains(QStringLiteral("image-data"))) { QDBusArgument arg = hints[QStringLiteral("image-data")].value(); image = decodeNotificationSpecImageHint(arg); } else if (hints.contains(QStringLiteral("image_data"))) { QDBusArgument arg = hints[QStringLiteral("image_data")].value(); image = decodeNotificationSpecImageHint(arg); } else if (hints.contains(QStringLiteral("image-path"))) { QString path = findImageForSpecImagePath(hints[QStringLiteral("image-path")].toString()); if (!path.isEmpty()) { image.load(path); } } else if (hints.contains(QStringLiteral("image_path"))) { QString path = findImageForSpecImagePath(hints[QStringLiteral("image_path")].toString()); if (!path.isEmpty()) { image.load(path); } } else if (hints.contains(QStringLiteral("icon_data"))) { // 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. QDBusArgument arg = hints[QStringLiteral("icon_data")].value(); image = decodeNotificationSpecImageHint(arg); } notificationData.insert(QStringLiteral("image"), image.isNull() ? QVariant() : image); if (hints.contains(QStringLiteral("urgency"))) { notificationData.insert(QStringLiteral("urgency"), hints[QStringLiteral("urgency")].toInt()); } + notificationData.insert(QStringLiteral("urls"), urls); + setData(source, notificationData); m_activeNotifications.insert(source, app_name + summary); return id; } void NotificationsEngine::CloseNotification(uint id) { removeNotification(id, 3); } void NotificationsEngine::removeNotification(uint id, uint closeReason) { const QString source = QStringLiteral("notification %1").arg(id); // if we don't have that notification in our local list, // it has already been closed so don't notify a second time if (m_activeNotifications.remove(source) > 0) { removeSource(source); emit NotificationClosed(id, closeReason); } } Plasma::Service* NotificationsEngine::serviceForSource(const QString& source) { return new NotificationService(this, source); } QStringList NotificationsEngine::GetCapabilities() { return QStringList() << QStringLiteral("body") << QStringLiteral("body-hyperlinks") << QStringLiteral("body-markup") << QStringLiteral("icon-static") << QStringLiteral("actions") ; } // FIXME: Signature is ugly QString NotificationsEngine::GetServerInformation(QString& vendor, QString& version, QString& specVersion) { vendor = QLatin1String("KDE"); version = QLatin1String("2.0"); // FIXME specVersion = QLatin1String("1.1"); return QStringLiteral("Plasma"); } int NotificationsEngine::createNotification(const QString &appName, const QString &appIcon, const QString &summary, const QString &body, int timeout, const QStringList &actions, const QVariantMap &hints) { Notify(appName, 0, appIcon, summary, body, actions, hints, timeout); return m_nextId; } void NotificationsEngine::configureNotification(const QString &appName, const QString &eventId) { KNotifyConfigWidget *widget = KNotifyConfigWidget::configure(nullptr, appName); if (!eventId.isEmpty()) { widget->selectEvent(eventId); } } K_EXPORT_PLASMA_DATAENGINE_WITH_JSON(notifications, NotificationsEngine, "plasma-dataengine-notifications.json") #include "notificationsengine.moc"