diff --git a/src/notifications/ContentRules.js b/src/notifications/ContentRules.js new file mode 100644 index 0000000000..25a7bac96e --- /dev/null +++ b/src/notifications/ContentRules.js @@ -0,0 +1,125 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +var PushRuleVectorState = require('./PushRuleVectorState'); + +module.exports = { + /** + * Extract the keyword rules from a list of rules, and parse them + * into a form which is useful for Vector's UI. + * + * Returns an object containing: + * rules: the primary list of keyword rules + * vectorState: a PushRuleVectorState indicating whether those rules are + * OFF/ON/LOUD + * externalRules: a list of other keyword rules, with states other than + * vectorState + */ + parseContentRules: function(rulesets) { + // first categorise the keyword rules in terms of their actions + var contentRules = this._categoriseContentRules(rulesets); + + // Decide which content rules to display in Vector UI. + // Vector displays a single global rule for a list of keywords + // whereas Matrix has a push rule per keyword. + // Vector can set the unique rule in ON, LOUD or OFF state. + // Matrix has enabled/disabled plus a combination of (highlight, sound) tweaks. + + // The code below determines which set of user's content push rules can be + // displayed by the vector UI. + // Push rules that does not fit, ie defined by another Matrix client, ends + // in externalRules. + // There is priority in the determination of which set will be the displayed one. + // The set with rules that have LOUD tweaks is the first choice. Then, the ones + // with ON tweaks (no tweaks). + + if (contentRules.loud.length) { + return { + vectorState: PushRuleVectorState.LOUD, + rules: contentRules.loud, + externalRules: [].concat(contentRules.loud_but_disabled, contentRules.on, contentRules.on_but_disabled, contentRules.other), + }; + } + else if (contentRules.loud_but_disabled.length) { + return { + vectorState: PushRuleVectorState.OFF, + rules: contentRules.loud_but_disabled, + externalRules: [].concat(contentRules.on, contentRules.on_but_disabled, contentRules.other), + }; + } + else if (contentRules.on.length) { + return { + vectorState: PushRuleVectorState.ON, + rules: contentRules.on, + externalRules: [].concat(contentRules.on_but_disabled, contentRules.other), + }; + } + else if (contentRules.on_but_disabled.length) { + return { + vectorState: PushRuleVectorState.OFF, + rules: contentRules.on_but_disabled, + externalRules: contentRules.other, + } + } else { + return { + vectorState: PushRuleVectorState.ON, + rules: [], + externalRules: contentRules.other, + } + } + }, + + _categoriseContentRules: function(rulesets) { + var contentRules = {on: [], on_but_disabled:[], loud: [], loud_but_disabled: [], other: []}; + for (var kind in rulesets.global) { + for (var i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) { + var r = rulesets.global[kind][i]; + + // check it's not a default rule + if (r.rule_id[0] === '.' || kind !== 'content') { + continue; + } + + r.kind = kind; // is this needed? not sure + + switch (PushRuleVectorState.contentRuleVectorStateKind(r)) { + case PushRuleVectorState.ON: + if (r.enabled) { + contentRules.on.push(r); + } + else { + contentRules.on_but_disabled.push(r); + } + break; + case PushRuleVectorState.LOUD: + if (r.enabled) { + contentRules.loud.push(r); + } + else { + contentRules.loud_but_disabled.push(r); + } + break; + default: + contentRules.other.push(r); + break; + } + } + } + return contentRules; + }, +}; diff --git a/src/notifications/NotificationUtils.js b/src/notifications/NotificationUtils.js new file mode 100644 index 0000000000..c8aeb46854 --- /dev/null +++ b/src/notifications/NotificationUtils.js @@ -0,0 +1,89 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +module.exports = { + // Encodes a dictionary of { + // "notify": true/false, + // "sound": string or undefined, + // "highlight: true/false, + // } + // to a list of push actions. + encodeActions: function(action) { + var notify = action.notify; + var sound = action.sound; + var highlight = action.highlight; + if (notify) { + var actions = ["notify"]; + if (sound) { + actions.push({"set_tweak": "sound", "value": sound}); + } + if (highlight) { + actions.push({"set_tweak": "highlight"}); + } else { + actions.push({"set_tweak": "highlight", "value": false}); + } + return actions; + } else { + return ["dont_notify"]; + } + }, + + // Decode a list of actions to a dictionary of { + // "notify": true/false, + // "sound": string or undefined, + // "highlight: true/false, + // } + // If the actions couldn't be decoded then returns null. + decodeActions: function(actions) { + var notify = false; + var sound = null; + var highlight = false; + + for (var i = 0; i < actions.length; ++i) { + var action = actions[i]; + if (action === "notify") { + notify = true; + } else if (action === "dont_notify") { + notify = false; + } else if (typeof action === 'object') { + if (action.set_tweak === "sound") { + sound = action.value + } else if (action.set_tweak === "highlight") { + highlight = action.value; + } else { + // We don't understand this kind of tweak, so give up. + return null; + } + } else { + // We don't understand this kind of action, so give up. + return null; + } + } + + if (highlight === undefined) { + // If a highlight tweak is missing a value then it defaults to true. + highlight = true; + } + + var result = {notify: notify, highlight: highlight}; + if (sound !== null) { + result.sound = sound; + } + return result; + }, +}; diff --git a/src/notifications/PushRuleVectorState.js b/src/notifications/PushRuleVectorState.js new file mode 100644 index 0000000000..c838aa20ed --- /dev/null +++ b/src/notifications/PushRuleVectorState.js @@ -0,0 +1,94 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +var StandardActions = require('./StandardActions'); +var NotificationUtils = require('./NotificationUtils'); + +var states = { + /** The push rule is disabled */ + OFF: "off", + + /** The user will receive push notification for this rule */ + ON: "on", + + /** The user will receive push notification for this rule with sound and + highlight if this is legitimate */ + LOUD: "loud", +}; + + +module.exports = { + /** + * Enum for state of a push rule as defined by the Vector UI. + * @readonly + * @enum {string} + */ + states: states, + + /** + * Convert a PushRuleVectorState to a list of actions + * + * @return [object] list of push-rule actions + */ + actionsFor: function(pushRuleVectorState) { + if (pushRuleVectorState === this.ON) { + return StandardActions.ACTION_NOTIFY; + } + else if (pushRuleVectorState === this.LOUD) { + return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND; + } + }, + + /** + * Convert a pushrule's actions to a PushRuleVectorState. + * + * Determines whether a content rule is in the PushRuleVectorState.ON + * category or in PushRuleVectorState.LOUD, regardless of its enabled + * state. Returns null if it does not match these categories. + */ + contentRuleVectorStateKind: function(rule) { + var decoded = NotificationUtils.decodeActions(rule.actions); + + if (!decoded) { + return null; + } + + // Count tweaks to determine if it is a ON or LOUD rule + var tweaks = 0; + if (decoded.sound) { + tweaks++; + } + if (decoded.highlight) { + tweaks++; + } + var stateKind = null; + switch (tweaks) { + case 0: + stateKind = this.ON; + break; + case 2: + stateKind = this.LOUD; + break; + } + return stateKind; + }, +}; + +for (var k in states) { + module.exports[k] = states[k]; +}; diff --git a/src/notifications/StandardActions.js b/src/notifications/StandardActions.js new file mode 100644 index 0000000000..22a8f1db40 --- /dev/null +++ b/src/notifications/StandardActions.js @@ -0,0 +1,30 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +var NotificationUtils = require('./NotificationUtils'); + +var encodeActions = NotificationUtils.encodeActions; + +module.exports = { + ACTION_NOTIFY: encodeActions({notify: true}), + ACTION_NOTIFY_DEFAULT_SOUND: encodeActions({notify: true, sound: "default"}), + ACTION_NOTIFY_RING_SOUND: encodeActions({notify: true, sound: "ring"}), + ACTION_HIGHLIGHT_DEFAULT_SOUND: encodeActions({notify: true, sound: "default", highlight: true}), + ACTION_DONT_NOTIFY: encodeActions({notify: false}), + ACTION_DISABLED: null, +}; diff --git a/src/notifications/VectorPushRulesDefinitions.js b/src/notifications/VectorPushRulesDefinitions.js new file mode 100644 index 0000000000..6f72164265 --- /dev/null +++ b/src/notifications/VectorPushRulesDefinitions.js @@ -0,0 +1,146 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +import { _td } from 'matrix-react-sdk/lib/languageHandler'; + +var StandardActions = require('./StandardActions'); +var PushRuleVectorState = require('./PushRuleVectorState'); + +class VectorPushRuleDefinition { + constructor(opts) { + this.kind = opts.kind; + this.description = opts.description; + this.vectorStateToActions = opts.vectorStateToActions; + } + + // Translate the rule actions and its enabled value into vector state + ruleToVectorState(rule) { + var enabled = false; + var actions = null; + if (rule) { + enabled = rule.enabled; + actions = rule.actions; + } + + for (var stateKey in PushRuleVectorState.states) { + var state = PushRuleVectorState.states[stateKey]; + var vectorStateToActions = this.vectorStateToActions[state]; + + if (!vectorStateToActions) { + // No defined actions means that this vector state expects a disabled (or absent) rule + if (!enabled) { + return state; + } + } else { + // The actions must match to the ones expected by vector state + if (enabled && JSON.stringify(rule.actions) === JSON.stringify(vectorStateToActions)) { + return state; + } + } + } + + console.error("Cannot translate rule actions into Vector rule state. Rule: " + + JSON.stringify(rule)); + return undefined; + } +}; + +/** + * The descriptions of rules managed by the Vector UI. + */ +module.exports = { + // Messages containing user's display name + ".m.rule.contains_display_name": new VectorPushRuleDefinition({ + kind: "override", + description: _td("Messages containing my display name"), // passed through _t() translation in src/components/views/settings/Notifications.js + vectorStateToActions: { // The actions for each vector state, or null to disable the rule. + on: StandardActions.ACTION_NOTIFY, + loud: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND, + off: StandardActions.ACTION_DISABLED + } + }), + + // Messages containing user's username (localpart/MXID) + ".m.rule.contains_user_name": new VectorPushRuleDefinition({ + kind: "override", + description: _td("Messages containing my user name"), // passed through _t() translation in src/components/views/settings/Notifications.js + vectorStateToActions: { // The actions for each vector state, or null to disable the rule. + on: StandardActions.ACTION_NOTIFY, + loud: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND, + off: StandardActions.ACTION_DISABLED + } + }), + + // Messages just sent to the user in a 1:1 room + ".m.rule.room_one_to_one": new VectorPushRuleDefinition({ + kind: "underride", + description: _td("Messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js + vectorStateToActions: { + on: StandardActions.ACTION_NOTIFY, + loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, + off: StandardActions.ACTION_DONT_NOTIFY + } + }), + + // Messages just sent to a group chat room + // 1:1 room messages are catched by the .m.rule.room_one_to_one rule if any defined + // By opposition, all other room messages are from group chat rooms. + ".m.rule.message": new VectorPushRuleDefinition({ + kind: "underride", + description: _td("Messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js + vectorStateToActions: { + on: StandardActions.ACTION_NOTIFY, + loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, + off: StandardActions.ACTION_DONT_NOTIFY + } + }), + + // Invitation for the user + ".m.rule.invite_for_me": new VectorPushRuleDefinition({ + kind: "underride", + description: _td("When I'm invited to a room"), // passed through _t() translation in src/components/views/settings/Notifications.js + vectorStateToActions: { + on: StandardActions.ACTION_NOTIFY, + loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, + off: StandardActions.ACTION_DISABLED + } + }), + + // Incoming call + ".m.rule.call": new VectorPushRuleDefinition({ + kind: "underride", + description: _td("Call invitation"), // passed through _t() translation in src/components/views/settings/Notifications.js + vectorStateToActions: { + on: StandardActions.ACTION_NOTIFY, + loud: StandardActions.ACTION_NOTIFY_RING_SOUND, + off: StandardActions.ACTION_DISABLED + } + }), + + // Notifications from bots + ".m.rule.suppress_notices": new VectorPushRuleDefinition({ + kind: "override", + description: _td("Messages sent by bot"), // passed through _t() translation in src/components/views/settings/Notifications.js + vectorStateToActions: { + // .m.rule.suppress_notices is a "negative" rule, we have to invert its enabled value for vector UI + on: StandardActions.ACTION_DISABLED, + loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, + off: StandardActions.ACTION_DONT_NOTIFY, + } + }), +}; diff --git a/src/notifications/index.js b/src/notifications/index.js new file mode 100644 index 0000000000..8ed77e9d41 --- /dev/null +++ b/src/notifications/index.js @@ -0,0 +1,24 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +module.exports = { + NotificationUtils: require('./NotificationUtils'), + PushRuleVectorState: require('./PushRuleVectorState'), + VectorPushRulesDefinitions: require('./VectorPushRulesDefinitions'), + ContentRules: require('./ContentRules'), +};