Add popup to edit push rules

This commit is contained in:
miruka
2021-02-22 11:32:34 -04:00
parent 71cd509a9d
commit 765ce46aeb
24 changed files with 919 additions and 92 deletions

71
src/gui/Base/HSpinBox.qml Normal file
View File

@@ -0,0 +1,71 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Controls 2.12
SpinBox {
id: box
property var defaultValue: null
readonly property bool changed: value !== (defaultValue || 0)
function reset() { value = Qt.binding(() => defaultValue || 0) }
// XXX TODO: default binding break
value: defaultValue || 0
implicitHeight: theme.baseElementsHeight
padding: 0
editable: true
background: null
contentItem: HTextField {
id: textField
height: parent.height
implicitWidth: 90 * theme.uiScale
radius: 0
horizontalAlignment: Qt.AlignHCenter
verticalAlignment: Qt.AlignVCenter
// FIXME
text: box.textFromValue(box.value, box.locale)
readOnly: ! box.editable
validator: box.validator
inputMethodHints: Qt.ImhFormattedNumbersOnly
onTextChanged: if (text && text !== "-") box.value = text
}
down.indicator: HButton {
x: box.mirrored ? parent.width - width : 0
height: parent.height
font.pixelSize: theme.fontSize.biggest
text: qsTr("-")
autoRepeat: true
autoRepeatInterval: 50
onPressed: box.decrease()
}
up.indicator: HButton {
x: box.mirrored ? 0 : parent.width - width
height: parent.height
font.pixelSize: theme.fontSize.biggest
text: qsTr("+")
autoRepeat: true
autoRepeatInterval: 50
onPressed: box.increase()
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: textField.hovered ? Qt.IBeamCursor : Qt.ArrowCursor
onWheel: wheel => {
wheel.angleDelta.y < 0 ? box.decrease() : box.increase()
wheel.accepted()
}
}
}

View File

@@ -1,3 +1,4 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
@@ -5,19 +6,15 @@ import QtQuick.Layouts 1.12
import "../.."
import "../../Base"
import "../../Base/HTile"
import "../../Base/Buttons"
import "../../MainPane"
import "../../Popups"
HTile {
id: root
property Item page
readonly property QtObject matchingRoom:
model.kind === "room" ?
ModelStore.get(page.userId, "rooms").find(model.rule_id) :
null
contentOpacity: model.enabled ? 1 : theme.disabledElementsOpacity
hoverEnabled: false
leftPadding: theme.spacing / 4
@@ -30,71 +27,8 @@ HTile {
opacity: model.enabled ? 1 : theme.disabledElementsOpacity
elide: Text.ElideNone
wrapMode: HLabel.Wrap
textFormat:
model.rule_id === ".m.rule.contains_user_name" ||
model.rule_id === ".m.rule.roomnotif" ||
model.kind === "sender" ?
HLabel.StyledText :
HLabel.PlainText
text:
model.rule_id === ".m.rule.master" ?
qsTr("Any message") :
model.rule_id === ".m.rule.suppress_notices" ?
qsTr("Messages sent by bots") :
model.rule_id === ".m.rule.invite_for_me" ?
qsTr("Received room invites") :
model.rule_id === ".m.rule.member_event" ?
qsTr("Membership, name & avatar changes") :
model.rule_id === ".m.rule.contains_display_name" ?
qsTr("Messages containing my display name") :
model.rule_id === ".m.rule.tombstone" ?
qsTr("Room migration alerts") :
model.rule_id === ".m.rule.reaction" ?
qsTr("Emoji reactions") :
model.rule_id === ".m.rule.roomnotif" ?
qsTr("Messages containing %1").arg(
utils.htmlColorize("@room", theme.colors.accentText),
) :
model.rule_id === ".m.rule.contains_user_name" ?
qsTr("Contains %1").arg(utils.coloredNameHtml(
"", page.userId, page.userId.split(":")[0].substring(1),
)):
model.rule_id === ".m.rule.call" ?
qsTr("Incoming audio calls") :
model.rule_id === ".m.rule.encrypted_room_one_to_one" ?
qsTr("Encrypted 1-to-1 messages") :
model.rule_id === ".m.rule.room_one_to_one" ?
qsTr("Unencrypted 1-to-1 messages") :
model.rule_id === ".m.rule.message" ?
qsTr("Unencrypted group messages") :
model.rule_id === ".m.rule.encrypted" ?
qsTr("Encrypted group messages") :
model.kind === "content" ?
qsTr('Contains "%1"').arg(model.pattern) :
model.kind === "sender" ?
utils.coloredNameHtml("", model.rule_id) :
matchingRoom && matchingRoom.display_name ?
matchingRoom.display_name :
model.rule_id
textFormat: HLabel.StyledText
text: utils.formatPushRuleName(page.userId, model)
Layout.fillWidth: true
Layout.leftMargin: theme.spacing
@@ -161,6 +95,10 @@ HTile {
NotificationRuleButton {
icon.name: "pushrule-edit"
onClicked: window.makePopup(
"Popups/PushRuleSettingsPopup/PushRuleSettingsPopup.qml",
{userId: page.userId, rule: model},
)
}
}
}

View File

@@ -73,11 +73,11 @@ HListView {
padding: theme.spacing
font.pixelSize: theme.fontSize.big
text:
section === "override" ? qsTr("High-priority general rules") :
section === "content" ? qsTr("Message text rules") :
section === "override" ? qsTr("High priority general rules") :
section === "content" ? qsTr("Message content rules") :
section === "room" ? qsTr("Room rules") :
section === "sender" ? qsTr("Sender rules") :
qsTr("General rules")
qsTr("Low priority general rules")
}
delegate: NotificationRuleDelegate {

View File

@@ -0,0 +1,20 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
import "../../Base/Buttons"
HColumnLayout {
readonly property alias idField: idField
HLabeledItem {
// TODO: globbing explanation & do space works?
label.text: qsTr("Word:")
Layout.fillWidth: true
HTextField {
id: idField
width: parent.width
defaultText: rule.kind === "content" ? rule.pattern : ""
}
}
}

View File

@@ -0,0 +1,14 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HLabel {
opacity: enabled ? 1 : theme.disabledElementsOpacity
wrapMode: HLabel.Wrap
Layout.fillWidth: true
Behavior on opacity { HNumberAnimation {} }
}

View File

@@ -0,0 +1,119 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
import "../../Base/Buttons"
HColumnLayout {
readonly property alias idField: idField
readonly property var matrixConditions: {
const results = []
for (let i = 0; i < conditionRepeater.count; i++) {
results.push(conditionRepeater.itemAt(i).control.matrixObject)
}
return results
}
spacing: theme.spacing / 2
HLabeledItem {
label.text: rule.default ? qsTr("Rule ID:") : qsTr("Rule name:")
Layout.fillWidth: true
HTextField {
id: idField
width: parent.width
defaultText: rule.rule_id
// TODO: minimum length, check no dupe
}
}
HRowLayout {
Layout.topMargin: theme.spacing / 2
CustomLabel {
text: qsTr("Conditions for a message to trigger this rule:")
}
PositiveButton {
icon.name: "pushrule-condition-add"
iconItem.small: true
Layout.fillHeight: true
Layout.fillWidth: false
onClicked: addConditionMenu.open()
HMenu {
id: addConditionMenu
x: -width + parent.width
y: parent.height
HMenuItem {
text: qsTr("Room has a certain number of members")
}
HMenuItem {
text: qsTr("Message property matches value")
}
HMenuItem {
text: qsTr("Message contains my display name")
}
HMenuItem {
text: qsTr(
"Sender has permission to trigger special notification"
)
}
HMenuItem {
text: qsTr("Custom JSON condition")
}
}
}
}
CustomLabel {
text: qsTr("No conditions added, all messages will match")
color: theme.colors.dimText
visible: Layout.preferredHeight > 0
Layout.preferredHeight: conditionRepeater.count ? 0 : implicitHeight
Behavior on Layout.preferredHeight { HNumberAnimation {} }
}
Repeater {
id: conditionRepeater
model: JSON.parse(rule.conditions)
HRowLayout {
readonly property Item control: loader.item
spacing: theme.spacing
HLoader {
id: loader
readonly property var condition: modelData
readonly property string filename:
modelData.kind === "event_match" ?
"PushEventMatch" :
modelData.kind === "contains_display_name" ?
"PushContainsDisplayName" :
modelData.kind === "room_member_count" ?
"PushRoomMemberCount" :
modelData.kind === "sender_notification_permission" ?
"PushSenderNotificationPermission" :
"PushUnknownCondition"
asynchronous: false
source: "PushConditions/" + filename + ".qml"
Layout.fillWidth: true
}
NegativeButton {
icon.name: "pushrule-condition-remove"
iconItem.small: true
Layout.fillHeight: true
Layout.fillWidth: false
}
}
}
}

View File

@@ -0,0 +1,11 @@
import QtQuick 2.12
import "../../../Base"
HFlow {
spacing: theme.spacing / 2
// transitions break CustomLabel opacity for some reason
populate: null
add: null
move: null
}

View File

@@ -0,0 +1,9 @@
import QtQuick 2.12
import ".."
import "../../../Base"
CustomLabel {
readonly property var matrixObject: ({kind: "contains_display_name"})
text: qsTr("Message contains my display name")
}

View File

@@ -0,0 +1,58 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import ".."
import "../../../Base"
CustomFlow {
readonly property var matrixObject: ({
kind: "event_match",
key: keyCombo.editText,
pattern: patternField.text,
})
CustomLabel {
text: qsTr("Message")
verticalAlignment: CustomLabel.AlignVCenter
height: keyCombo.height
}
HComboBox {
id: keyCombo
width: Math.min(implicitWidth, parent.width)
editText: condition.key
editable: true
currentIndex: model.indexOf(condition.key)
model: [...new Set([
"content.body",
"content.msgtype",
"room_id",
"sender",
"state_key",
"type",
condition.key,
])].sort()
}
CustomLabel {
text: keyCombo.editText === "content.body" ? qsTr("has") : qsTr("is")
verticalAlignment: CustomLabel.AlignVCenter
height: keyCombo.height
}
HTextField {
id: patternField
defaultText: condition.pattern
width: Math.min(implicitWidth, parent.width)
placeholderText: ({
"content.body": qsTr("text..."),
"content.msgtype": qsTr("e.g. m.image"),
"room_id": qsTr("!room:example.org"),
"sender": qsTr("@user:example.org"),
"state_key": qsTr("@user:example.org"),
"type": qsTr("e.g. m.room.message"),
}[keyCombo.editText] || qsTr("value"))
}
}

View File

@@ -0,0 +1,48 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import ".."
import "../../../Base"
CustomFlow {
readonly property var matrixObject: ({
kind: "room_member_count",
is: operatorCombo.operators[operatorCombo.currentIndex]
.replace("==", "") + countSpin.value,
})
CustomLabel {
text: qsTr("Room has")
verticalAlignment: CustomLabel.AlignVCenter
height: operatorCombo.height
}
HComboBox {
readonly property var operators: ["==", ">=", "<=", ">", "<"]
id: operatorCombo
width: Math.min(implicitWidth, parent.width)
currentIndex: operators.indexOf(/[=<>]+/.exec(condition.is + "==")[0])
model: [
qsTr("exactly"),
qsTr("at least"),
qsTr("at most"),
qsTr("more than"),
qsTr("less than"),
]
}
HSpinBox {
id: countSpin
width: Math.min(implicitWidth, parent.width)
defaultValue: parseInt(condition.is.replace(/[=<>]/, ""), 10)
}
CustomLabel {
text: qsTr("members")
verticalAlignment: CustomLabel.AlignVCenter
height: operatorCombo.height
}
}

View File

@@ -0,0 +1,35 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import ".."
import "../../../Base"
CustomFlow {
readonly property var matrixObject: ({
kind: "sender_notification_permission",
key: keyCombo.editText,
})
CustomLabel {
text: qsTr("Sender has permission to send")
verticalAlignment: CustomLabel.AlignVCenter
height: keyCombo.height
}
HComboBox {
id: keyCombo
width: Math.min(implicitWidth, parent.width)
editable: true
editText: condition.key
currentIndex: model.indexOf(condition.key)
model: [...new Set(["room", condition.key])].sort()
}
CustomLabel {
text: qsTr("notifications")
verticalAlignment: CustomLabel.AlignVCenter
height: keyCombo.height
}
}

View File

@@ -0,0 +1,36 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import ".."
import "../../../Base"
AutoDirectionLayout {
readonly property var matrixObject: {
try {
JSON.parse(jsonField.text)
} catch (e) {
// TODO
return condition.condition
}
}
rowSpacing: theme.spacing / 2
columnSpacing: rowSpacing
CustomLabel {
text: qsTr("Custom JSON:")
verticalAlignment: CustomLabel.AlignVCenter
Layout.fillWidth: false
Layout.fillHeight: true
}
HTextField {
// TODO: validate the JSON
id: jsonField
font.family: theme.fontFamily.mono
defaultText: JSON.stringify(condition.condition)
Layout.fillWidth: true
}
}

View File

@@ -0,0 +1,243 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import ".."
import "../.."
import "../../Base"
import "../../Base/Buttons"
HFlickableColumnPopup {
id: root
property string userId
// A rule item from ModelStore.get(userId, "pushrules")
property var rule
readonly property bool generalChecked:
overrideRadio.checked || underrideRadio.checked
readonly property string checkedKind:
overrideRadio.checked ? "override" :
contentRadio.checked ? "content" :
roomRadio.checked ? "room" :
senderRadio.checked ? "sender" :
"underride"
function save() {
const details = swipeView.currentItem
const isBefore = positionCombo.currentIndex === 0
const position =
positionCombo.visible && ! positionCombo.isCurrent ?
positionCombo.model[positionCombo.currentIndex].rule_id :
undefined
const args = [
checkedKind,
details.idField.text,
rule.kind,
rule.rule_id,
isBefore && position ? position : undefined,
! isBefore && position ? position : undefined,
enableCheck.checked,
generalChecked ? details.matrixConditions : undefined,
contentRadio.checked ? details.idField.text : undefined,
]
py.callClientCoro(userId, "edit_pushrule", args, root.close)
}
page.implicitWidth: Math.min(maximumPreferredWidth, 550 * theme.uiScale)
page.footer: AutoDirectionLayout {
ApplyButton {
text: qsTr("Save changes")
enabled: true // TODO
onClicked: root.save()
}
CancelButton {
text: qsTr("Cancel changes")
onClicked: root.close()
}
NegativeButton {
icon.name: "pushrule-remove"
text: qsTr("Remove rule")
enabled: ! root.rule.default
}
}
CustomLabel {
visible: root.rule.default
text: qsTr("Some settings cannot be changed for default server rules")
color: theme.colors.warningText
}
HColumnLayout {
enabled: ! root.rule.default
spacing: theme.spacing / 2
CustomLabel {
text: qsTr("Rule type:")
}
HRadioButton {
id: overrideRadio
text: "High priority general rule"
subtitle.text: qsTr(
"Control notifications for messages matching certain " +
"conditions"
)
defaultChecked: root.rule.kind === "override"
Layout.fillWidth: true
}
HRadioButton {
id: contentRadio
text: "Message content rule"
subtitle.text: qsTr(
"Control notifications for text messages containing a " +
"certain word"
)
defaultChecked: root.rule.kind === "content"
Layout.fillWidth: true
}
HRadioButton {
id: roomRadio
text: "Room rule"
subtitle.text: qsTr(
"Control notifications for all messages received in a " +
"certain room"
)
defaultChecked: root.rule.kind === "room"
Layout.fillWidth: true
}
HRadioButton {
id: senderRadio
text: "Sender rule"
subtitle.text: qsTr(
"Control notifications for all messages sent by a " +
"certain user"
)
defaultChecked: root.rule.kind === "sender"
Layout.fillWidth: true
}
HRadioButton {
id: underrideRadio
text: "Low priority general rule"
subtitle.text: qsTr(
"A general rule tested only after every other rule types"
)
defaultChecked: root.rule.kind === "underride"
Layout.fillWidth: true
}
}
SwipeView {
id: swipeView
enabled: ! root.rule.default
clip: true
interactive: false
currentIndex:
overrideRadio.checked ? 0 :
contentRadio.checked ? 1 :
roomRadio.checked ? 2 :
senderRadio.checked ? 3 :
4
Layout.fillWidth: true
Behavior on implicitHeight { HNumberAnimation {} }
GeneralRule { enabled: SwipeView.isCurrentItem }
ContentRule { enabled: SwipeView.isCurrentItem }
RoomRule { enabled: SwipeView.isCurrentItem }
SenderRule { enabled: SwipeView.isCurrentItem }
GeneralRule { enabled: SwipeView.isCurrentItem }
}
HLabeledItem {
visible: ! rule.default && positionCombo.model.length > 1
label.text: qsTr("Position:")
Layout.fillWidth: true
HComboBox {
id: positionCombo
property int currentPosition: 0
readonly property string name:
! model.length ? "" : utils.stripHtmlTags(
utils.formatPushRuleName(root.userId, model[currentIndex])
)
readonly property bool isCurrent:
model.length &&
currentIndex === currentPosition &&
root.rule.kind === root.checkedKind
width: parent.width
currentIndex: currentPosition
displayText:
! model.length ? "" :
isCurrent ? qsTr("Current") :
currentIndex === 0 ? qsTr('Before "%1"').arg(name) :
qsTr('After "%1"').arg(name)
model: {
currentPosition = 0
const choices = []
const rules = ModelStore.get(userId, "pushrules")
for (let i = 0; i < rules.count; i++) {
const item = rules.get(i)
const isCurrent =
item.kind === root.checkedKind &&
item.rule_id === root.rule.rule_id
if (isCurrent && choices.length)
currentPosition = choices.length - 1
if (item.kind === root.checkedKind && ! item.default) {
if (! choices.length) choices.push(item)
if (! isCurrent) choices.push(item)
}
}
return choices
}
delegate: HMenuItem {
readonly property string name:
utils.formatPushRuleName(root.userId, modelData)
label.textFormat: HLabel.StyledText
text:
model.index === positionCombo.currentPosition &&
root.rule.kind === root.checkedKind ?
qsTr("Current") :
model.index === 0 ?
qsTr('Before "%1"').arg(name) :
qsTr('After "%1"').arg(name)
onTriggered: positionCombo.currentIndex = model.index
}
}
}
HCheckBox {
id: enableCheck
text: qsTr("Enable this rule")
defaultChecked: root.rule.enabled
Layout.fillWidth: true
}
}

View File

@@ -0,0 +1,21 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
import "../../Base/Buttons"
HColumnLayout {
readonly property alias idField: idField
HLabeledItem {
label.text: qsTr("Room ID:")
Layout.fillWidth: true
HTextField {
id: idField
width: parent.width
defaultText: rule.kind === "room" ? rule.rule_id : ""
placeholderText: qsTr("!room:example.org")
maximumLength: 255
}
}
}

View File

@@ -0,0 +1,21 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
import "../../Base/Buttons"
HColumnLayout {
readonly property alias idField: idField
HLabeledItem {
label.text: qsTr("User ID:")
Layout.fillWidth: true
HTextField {
id: idField
width: parent.width
defaultText: rule.kind === "sender" ? rule.rule_id : ""
placeholderText: qsTr("@alice:example.org")
maximumLength: 255
}
}
}

View File

@@ -535,6 +535,7 @@ QtObject {
return {word, start, end: seen}
}
function getClassPathRegex(obj) {
const regexParts = []
let parent = obj
@@ -557,4 +558,79 @@ QtObject {
return new RegExp("^" + regexParts.reverse().join("") + "$")
}
function formatPushRuleName(userId, rule) {
// rule: item from ModelStore.get(<userId>, "pushrules")
const roomColor = theme.colors.accentText
const room = ModelStore.get(userId, "rooms").find(rule.rule_id)
return (
rule.rule_id === ".m.rule.master" ?
qsTr("Any message") :
rule.rule_id === ".m.rule.suppress_notices" ?
qsTr("Messages sent by bots") :
rule.rule_id === ".m.rule.invite_for_me" ?
qsTr("Received room invites") :
rule.rule_id === ".m.rule.member_event" ?
qsTr("Membership, name & avatar changes") :
rule.rule_id === ".m.rule.contains_display_name" ?
qsTr("Messages containing my display name") :
rule.rule_id === ".m.rule.tombstone" ?
qsTr("Room migration alerts") :
rule.rule_id === ".m.rule.reaction" ?
qsTr("Emoji reactions") :
rule.rule_id === ".m.rule.roomnotif" ?
qsTr("Messages containing %1").arg(
htmlColorize("@room", roomColor),
) :
rule.rule_id === ".m.rule.contains_user_name" ?
qsTr("Contains %1").arg(coloredNameHtml(
"", userId, userId.split(":")[0].substring(1),
)):
rule.rule_id === ".m.rule.call" ?
qsTr("Incoming audio calls") :
rule.rule_id === ".m.rule.encrypted_room_one_to_one" ?
qsTr("Encrypted 1-to-1 messages") :
rule.rule_id === ".m.rule.room_one_to_one" ?
qsTr("Unencrypted 1-to-1 messages") :
rule.rule_id === ".m.rule.message" ?
qsTr("Unencrypted group messages") :
rule.rule_id === ".m.rule.encrypted" ?
qsTr("Encrypted group messages") :
rule.rule_id === ".im.vector.jitsi" ?
qsTr("Incoming Jitsi calls") :
rule.kind === "content" ?
qsTr('Contains "%1"').arg(rule.pattern) :
rule.kind === "sender" ?
coloredNameHtml("", rule.rule_id) :
room && room.display_name && rule.kind !== "room" ?
qsTr("Messages in room %1").arg(
htmlColorize(escapeHtml(room.display_name), roomColor)
) :
room && room.display_name ?
escapeHtml(room.display_name) :
escapeHtml(rule.rule_id)
)
}
}