Add persistent notifications for priority 10 messages
Some checks failed
build / build-pip (push) Failing after 1m13s
build / build-win64 (push) Has been cancelled
build / build-macos (push) Has been cancelled

- Implement custom PersistentNotification widget with flashing background
- Add settings for persistent priority 10 notifications and sound control
- Modify notification logic to show persistent pop-ups for priority 10
- Allow closing all persistent notifications via tray icon click
- Add AGENTS.md with type checking guidelines
- Configure pyright to suppress PyQt6 false positives
- Update UI in settings dialog for new options
- Add notification sound file
This commit is contained in:
kdusek
2025-11-26 15:10:50 +01:00
parent 4e4fd9cdc9
commit 2108568f50
10 changed files with 673 additions and 125 deletions

View File

@@ -8,6 +8,7 @@ import tempfile
from gotify_tray import gotify
from gotify_tray.__version__ import __title__
from gotify_tray.database import Downloader, Settings
from .widgets.PersistentNotification import PersistentNotification
from gotify_tray.tasks import (
ClearCacheTask,
DeleteApplicationMessagesTask,
@@ -22,6 +23,7 @@ from gotify_tray.tasks import (
from gotify_tray.gui.themes import set_theme
from gotify_tray.utils import get_icon, verify_server
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtMultimedia import QSoundEffect
from ..__version__ import __title__
from .models import (
@@ -47,7 +49,9 @@ def init_logger(logger: logging.Logger):
else:
logging.disable()
logdir = QtCore.QStandardPaths.standardLocations(QtCore.QStandardPaths.StandardLocation.AppDataLocation)[0]
logdir = QtCore.QStandardPaths.standardLocations(
QtCore.QStandardPaths.StandardLocation.AppDataLocation
)[0]
if not os.path.exists(logdir):
os.mkdir(logdir)
logging.basicConfig(
@@ -57,6 +61,28 @@ def init_logger(logger: logging.Logger):
class MainApplication(QtWidgets.QApplication):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize notification sound effect
self.notification_sound = QSoundEffect()
sound_path = os.path.join(
os.path.dirname(__file__), "images", "notification.wav"
)
self.notification_sound.setSource(QtCore.QUrl.fromLocalFile(sound_path))
self.notification_sound.setVolume(0.5) # Set volume (0.0 to 1.0)
self.persistent_notifications = []
self.next_y_offset = 0
def close_all_persistent_notifications(self):
for notification in self.persistent_notifications:
notification.close()
self.persistent_notifications.clear()
self.next_y_offset = 0
def _on_tray_activated(self, reason):
if reason == QtWidgets.QSystemTrayIcon.ActivationReason.Trigger:
self.close_all_persistent_notifications()
def init_ui(self):
self.gotify_client = gotify.GotifyClient(
settings.value("Server/url", type=str),
@@ -69,7 +95,9 @@ class MainApplication(QtWidgets.QApplication):
self.application_model = ApplicationModel()
self.application_proxy_model = ApplicationProxyModel(self.application_model)
self.main_window = MainWindow(self.application_model, self.application_proxy_model, self.messages_model)
self.main_window = MainWindow(
self.application_model, self.application_proxy_model, self.messages_model
)
self.main_window.show() # The initial .show() is necessary to get the correct sizes when adding MessageWigets
QtCore.QTimer.singleShot(0, self.main_window.hide)
@@ -77,6 +105,7 @@ class MainApplication(QtWidgets.QApplication):
self.tray = Tray()
self.tray.show()
self.tray.activated.connect(self._on_tray_activated)
self.first_connect = True
@@ -100,17 +129,30 @@ class MainApplication(QtWidgets.QApplication):
self.application_model.setItem(0, 0, ApplicationAllMessagesItem())
self.get_applications_task = GetApplicationsTask(self.gotify_client)
self.get_applications_task.success.connect(self.get_applications_success_callback)
self.get_applications_task.started.connect(self.main_window.disable_applications)
self.get_applications_task.finished.connect(self.main_window.enable_applications)
self.get_applications_task.success.connect(
self.get_applications_success_callback
)
self.get_applications_task.started.connect(
self.main_window.disable_applications
)
self.get_applications_task.finished.connect(
self.main_window.enable_applications
)
self.get_applications_task.start()
def get_applications_success_callback(
self, applications: list[gotify.GotifyApplicationModel],
self,
applications: list[gotify.GotifyApplicationModel],
):
for i, application in enumerate(applications):
icon = QtGui.QIcon(self.downloader.get_filename(f"{self.gotify_client.url}/{application.image}"))
self.application_model.setItem(i + 1, 0, ApplicationModelItem(application, icon))
icon = QtGui.QIcon(
self.downloader.get_filename(
f"{self.gotify_client.url}/{application.image}"
)
)
self.application_model.setItem(
i + 1, 0, ApplicationModelItem(application, icon)
)
def update_last_id(self, i: int):
if i > settings.value("message/last", type=int):
@@ -170,19 +212,28 @@ class MainApplication(QtWidgets.QApplication):
task.message.disconnect()
except TypeError:
pass
for task in aborted_tasks:
task.wait()
def application_selection_changed_callback(self, item: ApplicationModelItem | ApplicationAllMessagesItem):
def application_selection_changed_callback(
self, item: ApplicationModelItem | ApplicationAllMessagesItem
):
self.main_window.disable_buttons()
self.abort_get_messages_task()
self.messages_model.clear()
if isinstance(item, ApplicationModelItem):
self.get_application_messages_task = GetApplicationMessagesTask(item.data(ApplicationItemDataRole.ApplicationRole).id, self.gotify_client)
self.get_application_messages_task.message.connect(self.messages_model.append_message)
self.get_application_messages_task.finished.connect(self.main_window.enable_buttons)
self.get_application_messages_task = GetApplicationMessagesTask(
item.data(ApplicationItemDataRole.ApplicationRole).id,
self.gotify_client,
)
self.get_application_messages_task.message.connect(
self.messages_model.append_message
)
self.get_application_messages_task.finished.connect(
self.main_window.enable_buttons
)
self.get_application_messages_task.start()
elif isinstance(item, ApplicationAllMessagesItem):
@@ -191,21 +242,29 @@ class MainApplication(QtWidgets.QApplication):
self.get_messages_task.finished.connect(self.main_window.enable_buttons)
self.get_messages_task.start()
def add_message_to_model(self, message: gotify.GotifyMessageModel, process: bool = True):
def add_message_to_model(
self, message: gotify.GotifyMessageModel, process: bool = True
):
if self.application_model.itemFromId(message.appid):
application_index = self.main_window.currentApplicationIndex()
if selected_application_item := self.application_model.itemFromIndex(self.application_proxy_model.mapToSource(application_index)):
if selected_application_item := self.application_model.itemFromIndex(
self.application_proxy_model.mapToSource(application_index)
):
def insert_message_helper():
if isinstance(selected_application_item, ApplicationModelItem):
# A single application is selected
# -> Only insert the message if the appid matches the selected appid
if (
message.appid
== selected_application_item.data(ApplicationItemDataRole.ApplicationRole).id
message.appid
== selected_application_item.data(
ApplicationItemDataRole.ApplicationRole
).id
):
self.messages_model.insert_message(0, message)
elif isinstance(selected_application_item, ApplicationAllMessagesItem):
elif isinstance(
selected_application_item, ApplicationAllMessagesItem
):
# "All messages' is selected
self.messages_model.insert_message(0, message)
@@ -216,10 +275,14 @@ class MainApplication(QtWidgets.QApplication):
else:
insert_message_helper()
else:
logger.error(f"App id {message.appid} could not be found. Refreshing applications.")
logger.error(
f"App id {message.appid} could not be found. Refreshing applications."
)
self.refresh_applications()
def new_message_callback(self, message: gotify.GotifyMessageModel, process: bool = True):
def new_message_callback(
self, message: gotify.GotifyMessageModel, process: bool = True
):
self.add_message_to_model(message, process=process)
# Don't show a notification if it's low priority or the window is active
@@ -237,20 +300,51 @@ class MainApplication(QtWidgets.QApplication):
self.tray.set_icon_unread()
# Get the application icon
if (
settings.value("tray/notifications/icon/show", type=bool)
and (application_item := self.application_model.itemFromId(message.appid))
if settings.value("tray/notifications/icon/show", type=bool) and (
application_item := self.application_model.itemFromId(message.appid)
):
icon = application_item.icon()
else:
icon = QtWidgets.QSystemTrayIcon.MessageIcon.Information
self.tray.showMessage(
message.title,
message.message,
icon,
msecs=settings.value("tray/notifications/duration_ms", type=int),
)
# Show notification
if message.priority == 10 and settings.value(
"tray/notifications/priority10_persistent", type=bool
):
# Create persistent notification
notification = PersistentNotification(
message.title or "",
message.message or "",
icon,
y_offset=self.next_y_offset,
flash=True,
)
notification.close_all_requested.connect(
self.close_all_persistent_notifications
)
self.persistent_notifications.append(notification)
notification.show()
self.next_y_offset += notification.height() + 10
else:
# Use system tray notification
msecs = settings.value("tray/notifications/duration_ms", type=int)
self.tray.showMessage(
message.title,
message.message,
icon,
msecs=msecs,
)
# Play notification sound
if (
not settings.value("tray/notifications/sound_only_priority10", type=bool)
or message.priority == 10
):
if self.notification_sound.isLoaded():
self.notification_sound.play()
else:
# Try to play anyway (QSoundEffect will queue if not loaded yet)
self.notification_sound.play()
def delete_message_callback(self, message_item: MessagesModelItem):
self.delete_message_task = DeleteMessageTask(
@@ -269,9 +363,9 @@ class MainApplication(QtWidgets.QApplication):
)
self.delete_application_messages_task.start()
elif isinstance(item, ApplicationAllMessagesItem):
self.clear_cache_task = ClearCacheTask()
self.clear_cache_task = ClearCacheTask()
self.clear_cache_task.start()
self.delete_all_messages_task = DeleteAllMessagesTask(self.gotify_client)
self.delete_all_messages_task.start()
else:
@@ -299,7 +393,11 @@ class MainApplication(QtWidgets.QApplication):
# Update the message widget icons
for r in range(self.messages_model.rowCount()):
message_widget: MessageWidget = self.main_window.listView_messages.indexWidget(self.messages_model.index(r, 0))
message_widget: MessageWidget = (
self.main_window.listView_messages.indexWidget(
self.messages_model.index(r, 0)
)
)
message_widget.set_icons()
def settings_callback(self):
@@ -341,15 +439,21 @@ class MainApplication(QtWidgets.QApplication):
self.main_window.refresh.connect(self.refresh_applications)
self.main_window.delete_all.connect(self.delete_all_messages_callback)
self.main_window.application_selection_changed.connect(self.application_selection_changed_callback)
self.main_window.application_selection_changed.connect(
self.application_selection_changed_callback
)
self.main_window.delete_message.connect(self.delete_message_callback)
self.main_window.image_popup.connect(self.image_popup_callback)
self.main_window.hidden.connect(self.main_window_hidden_callback)
self.main_window.activated.connect(self.tray.revert_icon)
self.styleHints().colorSchemeChanged.connect(self.theme_change_requested_callback)
self.messages_model.rowsInserted.connect(self.main_window.display_message_widgets)
self.styleHints().colorSchemeChanged.connect(
self.theme_change_requested_callback
)
self.messages_model.rowsInserted.connect(
self.main_window.display_message_widgets
)
self.gotify_client.opened.connect(self.listener_opened_callback)
self.gotify_client.closed.connect(self.listener_closed_callback)
@@ -366,7 +470,9 @@ class MainApplication(QtWidgets.QApplication):
def acquire_lock(self) -> bool:
temp_dir = tempfile.gettempdir()
lock_filename = os.path.join(temp_dir, __title__ + "-" + getpass.getuser() + ".lock")
lock_filename = os.path.join(
temp_dir, __title__ + "-" + getpass.getuser() + ".lock"
)
self.lock_file = QtCore.QLockFile(lock_filename)
self.lock_file.setStaleLockTime(0)
return self.lock_file.tryLock()