diff --git a/docs/TODO.md b/docs/TODO.md index c9f2cca1..c5a152b3 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -4,15 +4,20 @@ - PCN error handling - Change docs linking to dev branch back to master +- fix DeviceSection padding - Implement fallback QML notifications, usable if dbus isn't available - annoying tooltips when menu open - profiles missing in notifications - add http_proxy support +- image viewer: can't expand image in reduced window layout - Encrypted rooms don't show invites in member list after Mirage restart - Room display name not updated when someone removes theirs - Fix right margin of own `\n` messages +- option to use plaintext notifications +- warn on ambiguously activated shortcut +- SSO device delete? - filter > enter > room list is always scrolled to top - session list: prevent tab-focusing the delegates - refresh server list button diff --git a/src/backend/backend.py b/src/backend/backend.py index 2f123325..b9fa23f3 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -66,6 +66,9 @@ class Backend: - `"accounts"`: logged-in accounts; + - `("", "pushrules")`: push rules configured for our + account `user_id`. + - `("", "rooms")`: rooms our account `user_id` is part of; - `("", "transfers")`: ongoing or failed file diff --git a/src/backend/models/items.py b/src/backend/models/items.py index 2208bf02..a842680b 100644 --- a/src/backend/models/items.py +++ b/src/backend/models/items.py @@ -85,7 +85,51 @@ class Account(ModelItem): return (self.order, self.id) < (other.order, other.id) +class PushRuleKind(AutoStrEnum): + Override = auto() + Content = auto() + Room = auto() + Sender = auto() + Underride = auto() + + @dataclass(eq=False) +class PushRule(ModelItem): + """A push rule configured for one of our account.""" + + id: str = field() + kind: PushRuleKind = field() + order: int = field() + default: bool = field() + enabled: bool = True + pattern: str = "" + notify: bool = False + highlight: bool = False + bubble: bool = False + sound: bool = False + urgency_hint: bool = False + + def __lt__(self, other: "PushRule") -> bool: + """Sort by `kind`, then `order`.""" + + return ( + self.kind is PushRuleKind.Underride, + self.kind is PushRuleKind.Sender, + self.kind is PushRuleKind.Room, + self.kind is PushRuleKind.Content, + self.kind is PushRuleKind.Override, + self.order, + ) < ( + other.kind is PushRuleKind.Underride, + other.kind is PushRuleKind.Sender, + other.kind is PushRuleKind.Room, + other.kind is PushRuleKind.Content, + other.kind is PushRuleKind.Override, + other.order, + ) + + +@dataclass class Room(ModelItem): """A matrix room we are invited to, are or were member of.""" diff --git a/src/backend/nio_callbacks.py b/src/backend/nio_callbacks.py index 40b1739b..5cfc15df 100644 --- a/src/backend/nio_callbacks.py +++ b/src/backend/nio_callbacks.py @@ -15,7 +15,7 @@ import nio from .html_markdown import HTML_PROCESSOR from .media_cache import Media -from .models.items import TypeSpecifier +from .models.items import PushRule, PushRuleKind, TypeSpecifier from .presence import Presence from .pyotherside_events import DevicesUpdated from .utils import classes_defined_in, plain2html @@ -53,22 +53,23 @@ class NioCallbacks: if method: self.client.add_response_callback(method, response_class) - for name, event_class in classes_defined_in(nio.events).items(): + for name, ev_class in classes_defined_in(nio.events).items(): method = getattr(self, f"on{name}", None) if not method: continue - if issubclass(event_class, nio.EphemeralEvent): - self.client.add_ephemeral_callback(method, event_class) - elif issubclass(event_class, nio.ToDeviceEvent): - self.client.add_to_device_callback(method, event_class) - elif issubclass(event_class, nio.AccountDataEvent): - self.client.add_room_account_data_callback(method, event_class) - elif issubclass(event_class, nio.PresenceEvent): - self.client.add_presence_callback(method, event_class) + if issubclass(ev_class, nio.EphemeralEvent): + self.client.add_ephemeral_callback(method, ev_class) + elif issubclass(ev_class, nio.ToDeviceEvent): + self.client.add_to_device_callback(method, ev_class) + elif issubclass(ev_class, nio.AccountDataEvent): + self.client.add_global_account_data_callback(method, ev_class) + self.client.add_room_account_data_callback(method, ev_class) + elif issubclass(ev_class, nio.PresenceEvent): + self.client.add_presence_callback(method, ev_class) else: - self.client.add_event_callback(method, event_class) + self.client.add_event_callback(method, ev_class) @property @@ -779,6 +780,54 @@ class NioCallbacks: ev.read_by_count = len(ev.last_read_by) + # Account data callbacks + + async def onPushRulesEvent(self, ev: nio.PushRulesEvent) -> None: + model = self.models[self.user_id, "pushrules"] + model.clear() + + kinds: Dict[PushRuleKind, List[nio.PushRule]] = { + PushRuleKind.Override: ev.global_rules.override, + PushRuleKind.Content: ev.global_rules.content, + PushRuleKind.Room: ev.global_rules.room, + PushRuleKind.Sender: ev.global_rules.sender, + PushRuleKind.Underride: ev.global_rules.underride, + } + + for kind, rules in kinds.items(): + for order, rule in enumerate(rules): + tweaks = { + action.tweak: action.value for action in rule.actions + if isinstance(action, nio.PushSetTweak) + } + + # Note: The `dont_notify` action does nothing. + # As of now (sept 2020), `coalesce` is just a `notify` synonym. + notify = any( + isinstance(action, (nio.PushNotify, nio.PushCoalesce)) + for action in rule.actions + ) + + high = tweaks.get("highlight", False) is not False + bubble = tweaks.get("bubble", notify) is not False + sound = tweaks.get("sound", False) is not False + hint = tweaks.get("urgency_hint", high) is not False + + model[rule.id] = PushRule( + id = rule.id, + kind = kind, + order = order, + default = rule.default, + enabled = rule.enabled, + pattern = rule.pattern, + notify = notify, + highlight = high, + bubble = bubble, + sound = sound, + urgency_hint = hint, + ) + + # Presence event callbacks async def onPresenceEvent(self, ev: nio.PresenceEvent) -> None: diff --git a/src/gui/Pages/AccountSettings/AccountSettings.qml b/src/gui/Pages/AccountSettings/AccountSettings.qml index b1c19c86..9c317154 100644 --- a/src/gui/Pages/AccountSettings/AccountSettings.qml +++ b/src/gui/Pages/AccountSettings/AccountSettings.qml @@ -18,11 +18,14 @@ HPage { height: Math.min(implicitHeight, page.availableHeight) header: HTabBar { + currentIndex: 1 // XXX HTabButton { text: qsTr("Account") } + HTabButton { text: qsTr("Notifications") } HTabButton { text: qsTr("Security") } } Account { userId: page.userId } + Notifications { userId: page.userId } Security { userId: page.userId } } } diff --git a/src/gui/Pages/AccountSettings/NotificationRuleButton.qml b/src/gui/Pages/AccountSettings/NotificationRuleButton.qml new file mode 100644 index 00000000..68b68916 --- /dev/null +++ b/src/gui/Pages/AccountSettings/NotificationRuleButton.qml @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import "../../Base" + +HButton { + property bool on: true + + opacity: on ? 1 : theme.disabledElementsOpacity + hoverEnabled: true + backgroundColor: "transparent" +} diff --git a/src/gui/Pages/AccountSettings/NotificationRuleDelegate.qml b/src/gui/Pages/AccountSettings/NotificationRuleDelegate.qml new file mode 100644 index 00000000..27f4f1e2 --- /dev/null +++ b/src/gui/Pages/AccountSettings/NotificationRuleDelegate.qml @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import "../.." +import "../../Base" +import "../../Base/Buttons" +import "../../Base/HTile" +import "../../MainPane" +import "../../PythonBridge" +import "../../ShortcutBundles" + +HTile { + id: root + + property string userId + + readonly property QtObject matchingRoom: + model.kind === "Room" ? + ModelStore.get(userId, "rooms").find(model.id) : + null + + + contentOpacity: model.enabled ? 1 : theme.disabledElementsOpacity + hoverEnabled: false + + contentItem: HColumnLayout { + spacing: root.spacing / 2 + + TitleLabel { + opacity: model.enabled ? 1 : theme.disabledElementsOpacity + elide: Text.ElideNone + wrapMode: HLabel.Wrap + + textFormat: + model.id === ".m.rule.contains_user_name" || + model.id === ".m.rule.roomnotif" || + model.kind === "Sender" ? + HLabel.StyledText : + HLabel.PlainText + + text: + model.id === ".m.rule.master" ? + qsTr("Any message") : + + model.id === ".m.rule.suppress_notices" ? + qsTr("Messages sent by bots") : + + model.id === ".m.rule.invite_for_me" ? + qsTr("Received room invites") : + + model.id === ".m.rule.member_event" ? + qsTr("Membership, name & avatar changes") : + + model.id === ".m.rule.contains_display_name" ? + qsTr("Messages containing my display name") : + + model.id === ".m.rule.tombstone" ? + qsTr("Room migration alerts") : + + model.id === ".m.rule.reaction" ? + qsTr("Emoji reactions") : + + model.id === ".m.rule.roomnotif" ? + qsTr("Messages containing %1").arg( + utils.htmlColorize("@room", theme.colors.accentText), + ) : + + model.id === ".m.rule.contains_user_name" ? + qsTr("Contains %1").arg(utils.coloredNameHtml( + "", userId, userId.split(":")[0].substring(1), + )): + + model.id === ".m.rule.call" ? + qsTr("Incoming audio calls") : + + model.id === ".m.rule.encrypted_room_one_to_one" ? + qsTr("Encrypted 1-to-1 messages") : + + model.id === ".m.rule.room_one_to_one" ? + qsTr("Unencrypted 1-to-1 messages") : + + model.id === ".m.rule.message" ? + qsTr("Unencrypted group messages") : + + model.id === ".m.rule.encrypted" ? + qsTr("Encrypted group messages") : + + model.kind === "Content" ? + qsTr('Contains "%1"').arg(model.pattern) : + + model.kind === "Sender" ? + utils.coloredNameHtml("", model.id) : + + matchingRoom && matchingRoom.display_name ? + matchingRoom.display_name : + + model.id + + Layout.fillWidth: true + } + + HRowLayout { + NotificationRuleButton { + on: model.notify + + contentItem: MessageIndicator { + indicatorTheme: + theme.mainPane.listView.room.unreadIndicator + unreads: 1 + text: "+1" + font.pixelSize: theme.fontSize.normal + topPadding: leftPadding / 3 + bottomPadding: topPadding + } + } + + NotificationRuleButton { + on: model.highlight + + contentItem: MessageIndicator { + indicatorTheme: + theme.mainPane.listView.room.unreadIndicator + + unreads: 1 + highlights: 1 + text: "+1" + font.pixelSize: theme.fontSize.normal + topPadding: leftPadding / 3 + bottomPadding: topPadding + } + } + + NotificationRuleButton { + icon.name: "pushrule-action-bubble" + on: model.bubble + } + + NotificationRuleButton { + icon.name: "pushrule-action-sound" + on: model.sound + } + + NotificationRuleButton { + icon.name: "pushrule-action-urgency-hint" + on: model.urgency_hint + } + + HSpacer {} + + NotificationRuleButton { + icon.name: "pushrule-edit" + } + } + } +} diff --git a/src/gui/Pages/AccountSettings/Notifications.qml b/src/gui/Pages/AccountSettings/Notifications.qml new file mode 100644 index 00000000..6d0b1c17 --- /dev/null +++ b/src/gui/Pages/AccountSettings/Notifications.qml @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import "../.." +import "../../Base" +import "../../Base/Buttons" +import "../../PythonBridge" +import "../../ShortcutBundles" + +HListView { + id: root + + property string userId + + property bool enableFlickShortcuts: + SwipeView ? SwipeView.isCurrentItem : true + + function takeFocus() { + // deviceList.headerItem.exportButton.forceActiveFocus() + } + + + clip: true + model: ModelStore.get(userId, "pushrules") + bottomMargin: theme.spacing + implicitHeight: Math.min(window.height, contentHeight + bottomMargin) + + section.property: "kind" + section.delegate: HLabel { + width: root.width + topPadding: padding * (section === "Override" ? 1 : 1.5) + padding: theme.spacing + font.pixelSize: theme.fontSize.big + text: + section === "Override" ? qsTr("High-priority general rules") : + section === "Content" ? qsTr("Message text rules") : + section === "Room" ? qsTr("Room rules") : + section === "Sender" ? qsTr("Sender rules") : + qsTr("General rules") + } + + delegate: NotificationRuleDelegate { + userId: root.userId + width: root.width + } + + Layout.fillWidth: true + Layout.fillHeight: true + + FlickShortcuts { + flickable: root + active: ! mainUI.debugConsole.visible && root.enableFlickShortcuts + } +} diff --git a/src/icons/thin/pushrule-action-bubble.svg b/src/icons/thin/pushrule-action-bubble.svg new file mode 100644 index 00000000..1e56e891 --- /dev/null +++ b/src/icons/thin/pushrule-action-bubble.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/thin/pushrule-action-sound.svg b/src/icons/thin/pushrule-action-sound.svg new file mode 100644 index 00000000..de026c7a --- /dev/null +++ b/src/icons/thin/pushrule-action-sound.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/icons/thin/pushrule-action-urgency-hint.svg b/src/icons/thin/pushrule-action-urgency-hint.svg new file mode 100644 index 00000000..a8cd49bd --- /dev/null +++ b/src/icons/thin/pushrule-action-urgency-hint.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/thin/pushrule-edit.svg b/src/icons/thin/pushrule-edit.svg new file mode 100644 index 00000000..aec81dd8 --- /dev/null +++ b/src/icons/thin/pushrule-edit.svg @@ -0,0 +1,7 @@ + + + + + + +