nix-stuff/roles/kde/patches/plasma_workspace-pr4965.patch

915 lines
39 KiB
Diff

diff --git a/appiumtests/applets/CMakeLists.txt b/appiumtests/applets/CMakeLists.txt
index 19229541aa..6715dd1f8f 100644
--- a/appiumtests/applets/CMakeLists.txt
+++ b/appiumtests/applets/CMakeLists.txt
@@ -59,7 +59,7 @@ add_test(
NAME notificationstest
COMMAND selenium-webdriver-at-spi-run ${CMAKE_CURRENT_SOURCE_DIR}/notificationstest.py --failfast
)
-set_tests_properties(notificationstest PROPERTIES TIMEOUT 120)
+set_tests_properties(notificationstest PROPERTIES TIMEOUT 120 ENVIRONMENT "KACTIVITYMANAGERD_PATH=${KDE_INSTALL_FULL_LIBEXECDIR}/kactivitymanagerd;USE_CUSTOM_BUS=1")
add_test(
NAME digitalclocktest
diff --git a/appiumtests/applets/kicker/favoritetest.py b/appiumtests/applets/kicker/favoritetest.py
new file mode 100755
index 0000000000..50613c3db1
--- /dev/null
+++ b/appiumtests/applets/kicker/favoritetest.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+
+# SPDX-License-Identifier: BSD-3-Clause
+# SPDX-FileCopyrightText: 2022-2023 Harald Sitter <sitter@kde.org>
+
+import logging
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+import unittest
+from typing import Final
+
+from gi.repository import Gio, GLib
+
+sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir, "utils"))
+from GLibMainLoopThread import GLibMainLoopThread
+
+KDE_VERSION: Final = 6
+KACTIVITYMANAGERD_SERVICE_NAME: Final = "org.kde.ActivityManager"
+KACTIVITYMANAGERD_PATH: Final = os.getenv("KACTIVITYMANAGERD_PATH", "/usr/libexec/kactivitymanagerd")
+QMLTEST_EXEC: Final = os.getenv("QMLTEST_EXEC", "/usr/bin/qmltestrunner6")
+
+
+def name_has_owner(session_bus: Gio.DBusConnection, name: str) -> bool:
+ """
+ Whether the given name is available on session bus
+ """
+ message: Gio.DBusMessage = Gio.DBusMessage.new_method_call("org.freedesktop.DBus", "/", "org.freedesktop.DBus", "NameHasOwner")
+ message.set_body(GLib.Variant("(s)", [name]))
+ reply, _ = session_bus.send_message_with_reply_sync(message, Gio.DBusSendMessageFlags.NONE, 1000)
+ return reply and reply.get_signature() == 'b' and reply.get_body().get_child_value(0).get_boolean()
+
+
+def build_ksycoca() -> None:
+ subprocess.check_call([f"kbuildsycoca{KDE_VERSION}"], stdout=sys.stderr, stderr=sys.stderr, env=os.environ)
+
+
+def start_kactivitymanagerd() -> subprocess.Popen:
+ session_bus: Gio.DBusConnection = Gio.bus_get_sync(Gio.BusType.SESSION)
+ assert not name_has_owner(session_bus, KACTIVITYMANAGERD_SERVICE_NAME)
+
+ os.makedirs(os.path.join(GLib.get_user_config_dir(), "menus"))
+ shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), "applications.menu"), os.path.join(GLib.get_user_config_dir(), "menus"))
+
+ kactivitymanagerd = subprocess.Popen([KACTIVITYMANAGERD_PATH], stdout=sys.stderr, stderr=sys.stderr, env=os.environ)
+ kactivitymanagerd_started: bool = False
+ for _ in range(10):
+ if name_has_owner(session_bus, KACTIVITYMANAGERD_SERVICE_NAME):
+ kactivitymanagerd_started = True
+ break
+ logging.info("waiting for kactivitymanagerd to appear on the DBus session")
+ time.sleep(1)
+ assert kactivitymanagerd_started
+
+ build_ksycoca()
+
+ return kactivitymanagerd
+
+
+class TestDBusInterface:
+ """
+ D-Bus interface for org.kde.kickertest
+ """
+
+ BUS_NAME: Final = "org.kde.kickertest"
+ OBJECT_PATH: Final = "/test"
+ INTERFACE_NAME: Final = "org.kde.kickertest"
+
+ connection: Gio.DBusConnection
+
+ def __init__(self) -> None:
+ self.reg_id: int = 0
+ self.owner_id: int = Gio.bus_own_name(Gio.BusType.SESSION, self.BUS_NAME, Gio.BusNameOwnerFlags.NONE, self.on_bus_acquired, None, None)
+ assert self.owner_id > 0
+
+ def on_bus_acquired(self, connection: Gio.DBusConnection, name: str, *args) -> None:
+ """
+ The interface is ready, now register objects.
+ """
+ self.connection = connection
+ introspection_data = Gio.DBusNodeInfo.new_for_xml("""
+<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
+ "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
+<node>
+ <interface name="org.kde.kickertest">
+ <method name="DeleteAndRebuildDatabase1"/>
+ <method name="DeleteAndRebuildDatabase2"/>
+ </interface>
+</node>
+""")
+ self.reg_id = connection.register_object(self.OBJECT_PATH, introspection_data.interfaces[0], self.handle_method_call, None, None)
+ assert self.reg_id > 0
+ logging.info("interface registered")
+
+ def handle_method_call(self, connection: Gio.DBusConnection, sender: str, object_path: str, interface_name: str, method_name: str, parameters: GLib.Variant, invocation: Gio.DBusMethodInvocation) -> None:
+ logging.info("method call %s", method_name)
+
+ if method_name == "DeleteAndRebuildDatabase1":
+ os.remove(KickerTest.desktop_entry_1)
+ build_ksycoca()
+ invocation.return_value(None)
+ elif method_name == "DeleteAndRebuildDatabase2":
+ os.remove(KickerTest.desktop_entry_2)
+ build_ksycoca()
+ invocation.return_value(None)
+
+
+class KickerTest(unittest.TestCase):
+ kactivitymanagerd: subprocess.Popen
+ loop_thread: GLibMainLoopThread
+ dbus_interface: TestDBusInterface
+
+ temp_dir: tempfile.TemporaryDirectory
+ desktop_entry_1: str
+ desktop_entry_2: str
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ # Prepare desktop files
+ # 1
+ os.makedirs(os.path.join(GLib.get_user_data_dir(), "applications"))
+ shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), "kickertest.desktop"), os.path.join(GLib.get_user_data_dir(), "applications"))
+ cls.desktop_entry_1 = os.path.join(GLib.get_user_data_dir(), "applications", "kickertest.desktop")
+ # 2
+ cls.temp_dir = tempfile.TemporaryDirectory()
+ os.makedirs(os.path.join(cls.temp_dir.name, "applications"))
+ shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), "kickertest.desktop"), os.path.join(cls.temp_dir.name, "applications"))
+ cls.desktop_entry_2 = os.path.join(cls.temp_dir.name, "applications", "kickertest.desktop")
+
+ os.environ["LC_ALL"] = "en_US.UTF-8"
+ os.environ["QT_LOGGING_RULES"] = "org.kde.plasma.kicker.debug=true;kf.coreaddons.kdirwatch.debug=true"
+ os.environ["XDG_DATA_DIRS"] = os.environ["XDG_DATA_DIRS"] + ":" + cls.temp_dir.name
+
+ cls.kactivitymanagerd = start_kactivitymanagerd()
+
+ cls.loop_thread = GLibMainLoopThread()
+ cls.loop_thread.start()
+ cls.dbus_interface = TestDBusInterface()
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ cls.loop_thread.quit()
+ cls.kactivitymanagerd.kill()
+ cls.kactivitymanagerd.wait(10)
+
+ def test_qml(self) -> None:
+ """
+ 1. Add an entry to Favorites
+ 2. Remove the entry from Favorites
+ 3. Hide invalid entries automatically and don't crash when there are multiple entries with the same desktop name
+ """
+ with subprocess.Popen([QMLTEST_EXEC, "-input", os.path.join(os.path.dirname(os.path.abspath(__file__)), 'favoritetest.qml')], stdout=sys.stderr, stderr=sys.stderr) as process:
+ self.assertEqual(process.wait(60), 0)
+
+
+if __name__ == '__main__':
+ assert "USE_CUSTOM_BUS" in os.environ
+ logging.getLogger().setLevel(logging.INFO)
+ unittest.main()
diff --git a/appiumtests/applets/notificationstest.py b/appiumtests/applets/notificationstest.py
index 8917121a94..cd34a32905 100755
--- a/appiumtests/applets/notificationstest.py
+++ b/appiumtests/applets/notificationstest.py
@@ -5,6 +5,7 @@
import base64
import os
+import shutil
import subprocess
import tempfile
import time
@@ -15,6 +16,7 @@ import gi
from appium import webdriver
from appium.options.common.base import AppiumOptions
from appium.webdriver.common.appiumby import AppiumBy
+from selenium.common.exceptions import (NoSuchElementException, WebDriverException)
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
@@ -22,6 +24,8 @@ gi.require_version('Gdk', '4.0')
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import Gdk, GdkPixbuf, Gio, GLib
+from kicker.favoritetest import start_kactivitymanagerd
+
WIDGET_ID: Final = "org.kde.plasma.notifications"
KDE_VERSION: Final = 6
@@ -48,17 +52,25 @@ class NotificationsTest(unittest.TestCase):
"""
driver: webdriver.Remote
+ kactivitymanagerd: subprocess.Popen
@classmethod
def setUpClass(cls) -> None:
"""
Opens the widget and initialize the webdriver
"""
+ # Make history work
+ cls.kactivitymanagerd = start_kactivitymanagerd()
+
+ os.makedirs(os.path.join(GLib.get_user_data_dir(), "knotifications6"))
+ shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir, "libnotificationmanager", "libnotificationmanager.notifyrc"), os.path.join(GLib.get_user_data_dir(), "knotifications6"))
+
options = AppiumOptions()
options.set_capability("app", f"plasmawindowed -p org.kde.plasma.nano {WIDGET_ID}")
options.set_capability("timeouts", {'implicit': 10000})
options.set_capability("environ", {
"LC_ALL": "en_US.UTF-8",
+ "QT_LOGGING_RULES": "kf.notification*.debug=true;org.kde.plasma.notificationmanager.debug=true",
})
cls.driver = webdriver.Remote(command_executor='http://127.0.0.1:4723', options=options)
@@ -75,6 +87,8 @@ class NotificationsTest(unittest.TestCase):
Make sure to terminate the driver again, lest it dangles.
"""
subprocess.check_call([f"kquitapp{KDE_VERSION}", "plasmawindowed"])
+ cls.kactivitymanagerd.kill()
+ cls.kactivitymanagerd.wait(10)
for _ in range(10):
try:
subprocess.check_call(["pidof", "plasmawindowed"])
@@ -83,6 +97,15 @@ class NotificationsTest(unittest.TestCase):
time.sleep(1)
cls.driver.quit()
+ def close_notifications(self) -> None:
+ wait = WebDriverWait(self.driver, 5)
+ for button in self.driver.find_elements(AppiumBy.XPATH, "//button[@name='Close']"):
+ try:
+ button.click()
+ wait.until_not(lambda _: button.is_displayed())
+ except WebDriverException:
+ pass
+
def test_0_open(self) -> None:
"""
Tests the widget can be opened
@@ -106,6 +129,10 @@ class NotificationsTest(unittest.TestCase):
wait = WebDriverWait(self.driver, 5)
wait.until(EC.presence_of_element_located((AppiumBy.NAME, summary)))
+<<<<<<< HEAD
+=======
+ self.close_notifications()
+>>>>>>> 5145d877d8 (applets/notifications: suppress inhibited notifications after "Do not disturb" is off)
def take_screenshot(self) -> str:
with tempfile.TemporaryDirectory() as temp_dir:
@@ -123,6 +150,7 @@ class NotificationsTest(unittest.TestCase):
partial_pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 16, 16)
colors = (0xff0000ff, 0x00ff00ff, 0x0000ffff)
for color in colors:
+ logging.info(f"Testing color: {color}")
pixbuf.fill(color)
send_notification({
"app_name": "Appium Test",
@@ -145,7 +173,29 @@ class NotificationsTest(unittest.TestCase):
wait.until(EC.presence_of_element_located((AppiumBy.NAME, summary + str(color))))
partial_pixbuf.fill(color)
partial_image = base64.b64encode(Gdk.Texture.new_for_pixbuf(partial_pixbuf).save_to_png_bytes().get_data()).decode()
+<<<<<<< HEAD
self.driver.find_image_occurrence(self.take_screenshot(), partial_image)
+=======
+ try:
+ self.driver.find_image_occurrence(self.take_screenshot(), partial_image)
+ except WebDriverException: # Popup animation
+ self.driver.find_image_occurrence(self.take_screenshot(), partial_image)
+ self.close_notifications()
+
+ def test_2_notification_with_explicit_timeout(self) -> None:
+ """
+ Sends notifications with expire_timeout
+ """
+ summary = "expire_timeout"
+ send_notification({
+ "app_name": "Appium Test",
+ "summary": summary,
+ "body": "Will it disappear automatically?",
+ "timeout": 2000,
+ })
+ element = self.driver.find_element(AppiumBy.NAME, summary)
+ WebDriverWait(self.driver, 5).until_not(lambda _: element.is_displayed())
+>>>>>>> 5145d877d8 (applets/notifications: suppress inhibited notifications after "Do not disturb" is off)
def test_3_accessible_description_html_to_plaintext(self) -> None:
"""
@@ -157,6 +207,202 @@ class NotificationsTest(unittest.TestCase):
})
wait = WebDriverWait(self.driver, 5)
wait.until(EC.presence_of_element_located(("description", "biublinkwww.example.org from Appium Test")))
+<<<<<<< HEAD
+=======
+ self.close_notifications()
+
+ def test_4_actions(self) -> None:
+ """
+ When the "actions" key is set, a notification can provide actions.
+ """
+ loop = GLib.MainLoop()
+ activation_token = False
+ params_1: list[Any] = []
+ action_invoked = False
+ params_2: list[Any] = []
+ notification_closed = False
+ params_3: list[Any] = []
+
+ def notification_signal_handler(d_bus_proxy: Gio.DBusProxy, sender_name: str, signal_name: str, parameters: GLib.Variant) -> None:
+ nonlocal params_2, params_3, params_1, activation_token, action_invoked, notification_closed
+ logging.info(f"received signal {signal_name}")
+ match signal_name:
+ case "ActivationToken":
+ params_1 = parameters.unpack()
+ activation_token = True
+ case "ActionInvoked":
+ params_2 = parameters.unpack()
+ action_invoked = True
+ case "NotificationClosed":
+ params_3 = parameters.unpack()
+ notification_closed = True
+ loop.quit()
+
+ connection_id = self.notification_proxy.connect("g-signal", notification_signal_handler)
+ self.addCleanup(lambda: self.notification_proxy.disconnect(connection_id))
+
+ notification_id = send_notification({
+ "app_name": "Appium Test",
+ "body": "A notification with actions",
+ "actions": ["action1", "FooAction", "action2", "BarAction"],
+ })
+ self.driver.find_element(AppiumBy.NAME, "BarAction")
+ element = self.driver.find_element(AppiumBy.NAME, "FooAction")
+ element.click()
+ loop.run()
+ self.assertTrue(activation_token)
+ self.assertEqual(params_1[0], notification_id)
+ self.assertTrue(action_invoked)
+ self.assertEqual(params_2[0], notification_id)
+ self.assertEqual(params_2[1], "action1")
+ self.assertTrue(notification_closed)
+ self.assertEqual(params_3[0], notification_id)
+ self.assertEqual(params_3[1], 3) # reason: Revoked
+ self.assertFalse(element.is_displayed())
+
+ def test_5_inline_reply(self) -> None:
+ """
+ When the action list has "inline-reply", the notification popup will contain a text field and a reply button.
+ """
+ loop = GLib.MainLoop()
+ notification_replied = False
+ params: list[Any] = [] # id, text
+
+ def notification_signal_handler(d_bus_proxy: Gio.DBusProxy, sender_name: str, signal_name: str, parameters: GLib.Variant) -> None:
+ nonlocal params, notification_replied
+ logging.info(f"received signal {signal_name}")
+ if signal_name == "NotificationReplied":
+ params = parameters.unpack()
+ notification_replied = True
+ loop.quit()
+
+ connection_id = self.notification_proxy.connect("g-signal", notification_signal_handler)
+ self.addCleanup(lambda: self.notification_proxy.disconnect(connection_id))
+
+ # When there is only one action and it is a reply action, show text field right away
+ notification_id = send_notification({
+ "app_name": "Appium Test",
+ "body": "A notification with actions 1",
+ "actions": ["inline-reply", ""], # Use the default label
+ })
+ reply_text = "this is a reply"
+ self.driver.find_element(AppiumBy.NAME, "begin reply").click()
+ self.driver.find_element(AppiumBy.NAME, "Type a reply…").send_keys(reply_text)
+ element = self.driver.find_element(AppiumBy.NAME, "Send")
+ element.click()
+ loop.run()
+ self.assertTrue(notification_replied)
+ self.assertEqual(params[0], notification_id)
+ self.assertEqual(params[1], reply_text)
+ self.assertFalse(element.is_displayed())
+
+ notification_replied = False
+ notification_id = send_notification({
+ "app_name": "Appium Test",
+ "body": "A notification with actions 2",
+ "actions": ["inline-reply", ""],
+ "hints": {
+ "x-kde-reply-submit-button-text": GLib.Variant("s", "Reeply"), # Use a custom label
+ "x-kde-reply-placeholder-text": GLib.Variant("s", "A placeholder"), # Use a custom placeholder
+ },
+ })
+ reply_text = "this is another reply"
+ self.driver.find_element(AppiumBy.NAME, "begin reply").click()
+ self.driver.find_element(AppiumBy.NAME, "A placeholder").send_keys(reply_text)
+ element = self.driver.find_element(AppiumBy.NAME, "Reeply")
+ element.click()
+ loop.run()
+ self.assertTrue(notification_replied)
+ self.assertEqual(params[0], notification_id)
+ self.assertEqual(params[1], reply_text)
+ self.assertFalse(element.is_displayed())
+
+ notification_replied = False
+ notification_id = send_notification({
+ "app_name": "Appium Test",
+ "body": "A notification with actions 3",
+ "actions": ["inline-reply", "Replyy", "foo", "Foo", "bar", "Bar"], # Click to show the text field
+ })
+ self.driver.find_element(AppiumBy.NAME, "Foo")
+ self.driver.find_element(AppiumBy.NAME, "Bar")
+ element = self.driver.find_element(AppiumBy.NAME, "Replyy")
+ element.click()
+ reply_text = "Click Replyy to reply"
+ self.driver.find_element(AppiumBy.NAME, "Type a reply…").send_keys(reply_text)
+ self.assertFalse(element.is_displayed())
+ element = self.driver.find_element(AppiumBy.NAME, "Send")
+ element.click()
+ loop.run()
+ self.assertTrue(notification_replied)
+ self.assertEqual(params[0], notification_id)
+ self.assertEqual(params[1], reply_text)
+
+ def test_6_thumbnail(self) -> None:
+ """
+ When a notification has "x-kde-urls" hint, a thumbnail will be shown for the first url in the list
+ """
+ with tempfile.TemporaryDirectory() as temp_dir:
+ pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 256, 256)
+ colors = (0xff0000ff, 0x00ff00ff, 0x0000ffff)
+ for color in colors:
+ pixbuf.fill(color)
+ pixbuf.savev(os.path.join(temp_dir, f"{str(color)}.png"), "png")
+
+ url_list = [f"file://{os.path.join(temp_dir, path)}" for path in os.listdir(temp_dir)]
+ url_list.sort()
+ send_notification({
+ "app_name": "Appium Test",
+ "body": "Thumbnail",
+ "hints": {
+ "x-kde-urls": GLib.Variant("as", url_list),
+ },
+ "timeout": 10 * 1000,
+ })
+
+ self.driver.find_element(AppiumBy.NAME, "More Options…")
+
+ partial_pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 100, 100)
+ partial_pixbuf.fill(colors[1]) # Green is the first item
+ partial_image = base64.b64encode(Gdk.Texture.new_for_pixbuf(partial_pixbuf).save_to_png_bytes().get_data()).decode()
+
+ def match_image(driver) -> bool:
+ try:
+ self.driver.find_image_occurrence(self.take_screenshot(), partial_image)
+ return True
+ except WebDriverException:
+ return False
+
+ WebDriverWait(self.driver, 10).until(match_image)
+ self.close_notifications()
+
+ def test_7_do_not_disturb(self) -> None:
+ """
+ Suppress inhibited notifications after "Do not disturb" is turned off, and show a summary for unread inhibited notifications.
+ """
+ self.driver.find_element(AppiumBy.NAME, "Do not disturb").click()
+ dnd_button = self.driver.find_element(AppiumBy.XPATH, "//*[@name='Do not disturb' and contains(@states, 'checked')]")
+
+ summary = "Do not disturb me"
+ for i in range(2):
+ send_notification({
+ "app_name": "Appium Test",
+ "summary": summary + str(i),
+ "hints": {
+ "desktop-entry": GLib.Variant("s", "org.kde.plasmashell"),
+ },
+ "timeout": 60 * 1000,
+ })
+ title = self.driver.find_element(AppiumBy.XPATH, f"//heading[starts-with(@name, '{summary}') and contains(@accessibility-id, 'FullRepresentation')]")
+ self.assertRaises(NoSuchElementException, self.driver.find_element, AppiumBy.XPATH, f"//notification[starts-with(@name, '{summary}')]")
+
+ dnd_button.click()
+ self.driver.find_element(AppiumBy.XPATH, "//notification[@name='Unread Notifications' and @description='2 notifications were received while Do Not Disturb was active. from Notification Manager']")
+ self.driver.find_element(AppiumBy.XPATH, "//button[@name='Close' and contains(@accessibility-id, 'NotificationPopup')]").click()
+
+ # Notifications can only be cleared after they are expired, otherwise they will stay in the list
+ self.driver.find_element(AppiumBy.NAME, "Clear All Notifications").click()
+ WebDriverWait(self.driver, 5).until_not(lambda _: title.is_displayed())
+>>>>>>> 5145d877d8 (applets/notifications: suppress inhibited notifications after "Do not disturb" is off)
if __name__ == '__main__':
diff --git a/applets/notifications/package/contents/ui/FullRepresentation.qml b/applets/notifications/package/contents/ui/FullRepresentation.qml
index f949dad46a..33838599f6 100644
--- a/applets/notifications/package/contents/ui/FullRepresentation.qml
+++ b/applets/notifications/package/contents/ui/FullRepresentation.qml
@@ -71,6 +71,14 @@ PlasmaExtras.Representation {
checkable: true
checked: Globals.inhibited
+ Accessible.onPressAction: if (Globals.inhibited) {
+ Globals.revokeInhibitions();
+ } else {
+ let date = new Date();
+ date.setFullYear(date.getFullYear() + 1);
+ notificationSettings.notificationsInhibitedUntil = date;
+ notificationSettings.save();
+ }
KeyNavigation.down: list
KeyNavigation.tab: list
diff --git a/applets/notifications/package/contents/ui/global/Globals.qml b/applets/notifications/package/contents/ui/global/Globals.qml
index c46c32921a..2238653eff 100644
--- a/applets/notifications/package/contents/ui/global/Globals.qml
+++ b/applets/notifications/package/contents/ui/global/Globals.qml
@@ -34,6 +34,10 @@ QtObject {
property bool inhibited: false
onInhibitedChanged: {
+ if (!inhibited) {
+ popupNotificationsModel.showInhibitionSummary();
+ }
+
var pa = pulseAudio.item;
if (!pa) {
return;
@@ -405,6 +409,7 @@ QtObject {
limit: plasmoid ? (Math.ceil(globals.screenRect.height / (Kirigami.Units.gridUnit * 4))) : 0
showExpired: false
showDismissed: false
+ showAddedDuringInhibition: false
blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications
blacklistedNotifyRcNames: notificationSettings.popupBlacklistedServices
whitelistedDesktopEntries: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedApplications : []
@@ -613,9 +618,13 @@ QtObject {
onKillJobClicked: popupNotificationsModel.killJob(popupNotificationsModel.index(index, 0))
// popup width is fixed
- onHeightChanged: positionPopups()
+ onHeightChanged: globals.positionPopups()
Component.onCompleted: {
+ if (globals.inhibited) {
+ model.wasAddedDuringInhibition = false; // Don't count already shown notifications
+ }
+
if (model.type === NotificationManager.Notifications.NotificationType && model.desktopEntry) {
// Register apps that were seen spawning a popup so they can be configured later
// Apps with notifyrc can already be configured anyway
diff --git a/libnotificationmanager/CMakeLists.txt b/libnotificationmanager/CMakeLists.txt
index d04d8a4a24..5c48653ede 100644
--- a/libnotificationmanager/CMakeLists.txt
+++ b/libnotificationmanager/CMakeLists.txt
@@ -84,6 +84,7 @@ target_link_libraries(notificationmanager
KF6::I18n
KF6::WindowSystem
KF6::ItemModels # KDescendantsProxyModel
+ KF6::Notifications # Inhibition summary
KF6::KIOFileWidgets
Plasma::Plasma
KF6::Screen
@@ -135,3 +136,6 @@ install(EXPORT notificationmanagerLibraryTargets
install(FILES plasmanotifyrc
DESTINATION ${KDE_INSTALL_CONFDIR})
+
+install(FILES libnotificationmanager.notifyrc
+ DESTINATION ${KDE_INSTALL_KNOTIFYRCDIR})
diff --git a/libnotificationmanager/abstractnotificationsmodel.cpp b/libnotificationmanager/abstractnotificationsmodel.cpp
index 8bd55bc26c..8307a1e8f7 100644
--- a/libnotificationmanager/abstractnotificationsmodel.cpp
+++ b/libnotificationmanager/abstractnotificationsmodel.cpp
@@ -104,6 +104,7 @@ void AbstractNotificationsModel::Private::onNotificationReplaced(uint replacedId
newNotification.setExpired(oldNotification.expired());
newNotification.setDismissed(oldNotification.dismissed());
newNotification.setRead(oldNotification.read());
+ newNotification.setWasAddedDuringInhibition(Server::self().inhibited());
notifications[row] = newNotification;
const QModelIndex idx = q->index(row, 0);
@@ -378,6 +379,9 @@ QVariant AbstractNotificationsModel::data(const QModelIndex &index, int role) co
case Notifications::TransientRole:
return notification.transient();
+ case Notifications::WasAddedDuringInhibitionRole:
+ return notification.wasAddedDuringInhibition();
+
case Notifications::HasReplyActionRole:
return notification.hasReplyAction();
case Notifications::ReplyActionLabelRole:
@@ -416,6 +420,12 @@ bool AbstractNotificationsModel::setData(const QModelIndex &index, const QVarian
dirty = true;
}
break;
+ case Notifications::WasAddedDuringInhibitionRole:
+ if (bool v = value.toBool(); v != notification.wasAddedDuringInhibition()) {
+ notification.setWasAddedDuringInhibition(v);
+ dirty = true;
+ }
+ break;
}
if (dirty) {
@@ -471,7 +481,7 @@ void AbstractNotificationsModel::clear(Notifications::ClearFlags flags)
for (int i = 0; i < d->notifications.count(); ++i) {
const Notification &notification = d->notifications.at(i);
- if (flags.testFlag(Notifications::ClearExpired) && notification.expired()) {
+ if (flags.testFlag(Notifications::ClearExpired) && (notification.expired() || notification.wasAddedDuringInhibition())) {
close(notification.id());
}
}
diff --git a/libnotificationmanager/libnotificationmanager.notifyrc b/libnotificationmanager/libnotificationmanager.notifyrc
new file mode 100644
index 0000000000..79304e62bc
--- /dev/null
+++ b/libnotificationmanager/libnotificationmanager.notifyrc
@@ -0,0 +1,11 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: None
+
+[Global]
+Name=Notification Manager
+IconName=preferences-desktop-notification-bell
+
+[Event/inhibitionSummary]
+Name=Summary for unread inhibited notifications
+Action=Popup
+Urgency=Low
diff --git a/libnotificationmanager/notification.cpp b/libnotificationmanager/notification.cpp
index 276611310c..320c837617 100644
--- a/libnotificationmanager/notification.cpp
+++ b/libnotificationmanager/notification.cpp
@@ -826,3 +826,13 @@ void Notification::processHints(const QVariantMap &hints)
{
d->processHints(hints);
}
+
+bool Notification::wasAddedDuringInhibition() const
+{
+ return d->wasAddedDuringInhibition;
+}
+
+void Notification::setWasAddedDuringInhibition(bool value)
+{
+ d->wasAddedDuringInhibition = value;
+}
diff --git a/libnotificationmanager/notification.h b/libnotificationmanager/notification.h
index 9f04a4e200..8613534fd2 100644
--- a/libnotificationmanager/notification.h
+++ b/libnotificationmanager/notification.h
@@ -131,6 +131,9 @@ public:
void processHints(const QVariantMap &hints);
+ bool wasAddedDuringInhibition() const;
+ void setWasAddedDuringInhibition(bool value);
+
private:
friend class NotificationsModel;
friend class AbstractNotificationsModel;
diff --git a/libnotificationmanager/notification_p.h b/libnotificationmanager/notification_p.h
index 6cae23c21e..923bde7882 100644
--- a/libnotificationmanager/notification_p.h
+++ b/libnotificationmanager/notification_p.h
@@ -96,6 +96,8 @@ public:
bool resident = false;
bool transient = false;
+
+ bool wasAddedDuringInhibition = false;
};
} // namespace NotificationManager
diff --git a/libnotificationmanager/notificationfilterproxymodel.cpp b/libnotificationmanager/notificationfilterproxymodel.cpp
index 98a32b6645..bb573713af 100644
--- a/libnotificationmanager/notificationfilterproxymodel.cpp
+++ b/libnotificationmanager/notificationfilterproxymodel.cpp
@@ -58,6 +58,20 @@ void NotificationFilterProxyModel::setShowDismissed(bool show)
}
}
+bool NotificationFilterProxyModel::showAddedDuringInhibition() const
+{
+ return m_showDismissed;
+}
+
+void NotificationFilterProxyModel::setShowAddedDuringInhibition(bool show)
+{
+ if (m_showAddedDuringInhibition != show) {
+ m_showAddedDuringInhibition = show;
+ invalidateFilter();
+ Q_EMIT showAddedDuringInhibitionChanged();
+ }
+}
+
QStringList NotificationFilterProxyModel::blacklistedDesktopEntries() const
{
return m_blacklistedDesktopEntries;
@@ -177,5 +191,9 @@ bool NotificationFilterProxyModel::filterAcceptsRow(int source_row, const QModel
}
}
+ if (!m_showAddedDuringInhibition && sourceIdx.data(Notifications::WasAddedDuringInhibitionRole).toBool()) {
+ return false;
+ }
+
return true;
}
diff --git a/libnotificationmanager/notificationfilterproxymodel_p.h b/libnotificationmanager/notificationfilterproxymodel_p.h
index 4029320e8e..af04a9fac1 100644
--- a/libnotificationmanager/notificationfilterproxymodel_p.h
+++ b/libnotificationmanager/notificationfilterproxymodel_p.h
@@ -30,6 +30,9 @@ public:
bool showDismissed() const;
void setShowDismissed(bool show);
+ bool showAddedDuringInhibition() const;
+ void setShowAddedDuringInhibition(bool show);
+
QStringList blacklistedDesktopEntries() const;
void setBlackListedDesktopEntries(const QStringList &blacklist);
@@ -46,6 +49,7 @@ Q_SIGNALS:
void urgenciesChanged();
void showExpiredChanged();
void showDismissedChanged();
+ void showAddedDuringInhibitionChanged();
void blacklistedDesktopEntriesChanged();
void blacklistedNotifyRcNamesChanged();
void whitelistedDesktopEntriesChanged();
@@ -58,6 +62,7 @@ private:
Notifications::Urgencies m_urgencies = Notifications::LowUrgency | Notifications::NormalUrgency | Notifications::CriticalUrgency;
bool m_showDismissed = false;
bool m_showExpired = false;
+ bool m_showAddedDuringInhibition = true;
QStringList m_blacklistedDesktopEntries;
QStringList m_blacklistedNotifyRcNames;
diff --git a/libnotificationmanager/notifications.cpp b/libnotificationmanager/notifications.cpp
index 9b3e18018b..3d5662f926 100644
--- a/libnotificationmanager/notifications.cpp
+++ b/libnotificationmanager/notifications.cpp
@@ -12,6 +12,8 @@
#include <memory>
#include <KDescendantsProxyModel>
+#include <KLocalizedString>
+#include <KNotification>
#include "limitedrowcountproxymodel_p.h"
#include "notificationfilterproxymodel_p.h"
@@ -30,6 +32,7 @@
#include "debug.h"
+using namespace Qt::StringLiterals;
using namespace NotificationManager;
class Q_DECL_HIDDEN Notifications::Private
@@ -166,6 +169,7 @@ void Notifications::Private::initProxyModels()
connect(filterModel, &NotificationFilterProxyModel::urgenciesChanged, q, &Notifications::urgenciesChanged);
connect(filterModel, &NotificationFilterProxyModel::showExpiredChanged, q, &Notifications::showExpiredChanged);
connect(filterModel, &NotificationFilterProxyModel::showDismissedChanged, q, &Notifications::showDismissedChanged);
+ connect(filterModel, &NotificationFilterProxyModel::showAddedDuringInhibitionChanged, q, &Notifications::showAddedDuringInhibitionChanged);
connect(filterModel, &NotificationFilterProxyModel::blacklistedDesktopEntriesChanged, q, &Notifications::blacklistedDesktopEntriesChanged);
connect(filterModel, &NotificationFilterProxyModel::blacklistedNotifyRcNamesChanged, q, &Notifications::blacklistedNotifyRcNamesChanged);
@@ -245,7 +249,7 @@ void Notifications::Private::updateCount()
for (int i = 0; i < filterModel->rowCount(); ++i) {
const QModelIndex idx = filterModel->index(i, 0);
- if (idx.data(Notifications::ExpiredRole).toBool()) {
+ if (idx.data(Notifications::ExpiredRole).toBool() || idx.data(Notifications::WasAddedDuringInhibitionRole).toBool()) {
++expired;
} else {
++active;
@@ -477,6 +481,16 @@ void Notifications::setShowDismissed(bool show)
d->filterModel->setShowDismissed(show);
}
+bool Notifications::showAddedDuringInhibition() const
+{
+ return d->filterModel->showAddedDuringInhibition();
+}
+
+void Notifications::setShowAddedDuringInhibition(bool show)
+{
+ d->filterModel->setShowAddedDuringInhibition(show);
+}
+
QStringList Notifications::blacklistedDesktopEntries() const
{
return d->filterModel->blacklistedDesktopEntries();
@@ -812,6 +826,28 @@ void Notifications::collapseAllGroups()
}
}
+void Notifications::showInhibitionSummary()
+{
+ int inhibited = 0;
+ for (int i = 0, count = d->notificationsAndJobsModel->rowCount(); i < count; ++i) {
+ const QModelIndex idx = d->notificationsAndJobsModel->index(i, 0);
+ if (!idx.data(Notifications::ReadRole).toBool() && idx.data(Notifications::WasAddedDuringInhibitionRole).toBool()) {
+ ++inhibited;
+ }
+ }
+
+ if (!inhibited) {
+ return;
+ }
+
+ KNotification::event(u"inhibitionSummary"_s,
+ i18nc("@title", "Unread Notifications"),
+ i18nc("@info", "%1 notifications were received while Do Not Disturb was active.", QString::number(inhibited)),
+ u"preferences-desktop-notification-bell"_s,
+ KNotification::CloseOnTimeout,
+ u"libnotificationmanager"_s);
+}
+
QVariant Notifications::data(const QModelIndex &index, int role) const
{
return QSortFilterProxyModel::data(index, role);
diff --git a/libnotificationmanager/notifications.h b/libnotificationmanager/notifications.h
index edb898988f..ef500b0c7b 100644
--- a/libnotificationmanager/notifications.h
+++ b/libnotificationmanager/notifications.h
@@ -61,6 +61,15 @@ class NOTIFICATIONMANAGER_EXPORT Notifications : public QSortFilterProxyModel, p
*/
Q_PROPERTY(bool showDismissed READ showDismissed WRITE setShowDismissed NOTIFY showDismissedChanged)
+ /**
+ * Whether to show notifications added during inhibition.
+ *
+ * If set to @c false, notifications are suppressed even after leaving "Do not disturb" mode.
+ *
+ * Default is @c true.
+ */
+ Q_PROPERTY(bool showAddedDuringInhibition READ showAddedDuringInhibition WRITE setShowAddedDuringInhibition NOTIFY showAddedDuringInhibitionChanged)
+
/**
* A list of desktop entries for which no notifications should be shown.
*
@@ -285,6 +294,8 @@ public:
///< notification in a certain way, or group notifications of similar types. @since 5.21
ResidentRole, ///< Whether the notification should keep its actions even when they were invoked. @since 5.22
TransientRole, ///< Whether the notification is transient and should not be kept in history. @since 5.22
+
+ WasAddedDuringInhibitionRole, ///< Whether the notification was added while inhibition was active. @since 6.3
};
Q_ENUM(Roles)
@@ -371,6 +382,9 @@ public:
bool showDismissed() const;
void setShowDismissed(bool show);
+ bool showAddedDuringInhibition() const;
+ void setShowAddedDuringInhibition(bool show);
+
QStringList blacklistedDesktopEntries() const;
void setBlacklistedDesktopEntries(const QStringList &blacklist);
@@ -529,6 +543,11 @@ public:
Q_INVOKABLE void collapseAllGroups();
+ /**
+ * Shows a notification to report the number of unread inhibited notifications.
+ */
+ Q_INVOKABLE void showInhibitionSummary();
+
QVariant data(const QModelIndex &index, int role) const override;
bool setData(const QModelIndex &index, const QVariant &value, int role) override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
@@ -541,6 +560,7 @@ Q_SIGNALS:
void limitChanged();
void showExpiredChanged();
void showDismissedChanged();
+ void showAddedDuringInhibitionChanged();
void blacklistedDesktopEntriesChanged();
void blacklistedNotifyRcNamesChanged();
void whitelistedDesktopEntriesChanged();
diff --git a/libnotificationmanager/server_p.cpp b/libnotificationmanager/server_p.cpp
index 84fe37afa9..66cd621033 100644
--- a/libnotificationmanager/server_p.cpp
+++ b/libnotificationmanager/server_p.cpp
@@ -164,6 +164,7 @@ uint ServerPrivate::Notify(const QString &app_name,
notification.setActions(actions);
notification.setTimeout(timeout);
+ notification.setWasAddedDuringInhibition(m_inhibited);
// might override some of the things we set above (like application name)
notification.d->processHints(hints);