add push rule settings

Fixes #126
This commit is contained in:
Henry Hiles 2026-10-31 15:22:13 -04:00
commit c9172e92eb
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
4 changed files with 596 additions and 15 deletions

View file

@ -1,5 +1,6 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/push_rules.dart";
part "account_data.freezed.dart";
part "account_data.g.dart";
@ -22,12 +23,15 @@ class const AccountData({
final ImagePackRooms? imagePackRooms,
@JsonKey(name: AccountData.legacyImagePackRoomsKey)
final ImagePackRooms? legacyImagePackRooms,
@JsonKey(name: AccountData.pushRulesKey)
final PushRules pushRules = const .new(),
}) with _$AccountData {
static const invitePermissionConfigKey = "m.invite_permission_config";
static const directKey = "m.direct";
static const recentEmojiKey = "m.recent_emoji";
static const imagePackRoomsKey = "m.image_pack.rooms";
static const legacyImagePackRoomsKey = "im.ponies.emote_rooms";
static const pushRulesKey = "m.push_rules";
ImagePackRooms get enabledImagePacks =>
imagePackRooms ?? legacyImagePackRooms ?? const .new();

View file

@ -0,0 +1,150 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/push_rules.dart";
enum PushRuleSection(
final String title,
final String description,
final IconData icon,
) {
messages("Messages", "Which messages notify you.", Icons.chat),
otherEvents("Other Events", "Events other than messages.", Icons.more_horiz),
}
/// Default push rules turned on or off together, as some match the same events
/// in different ways, like encrypted ones, which the server can't read.
enum PushRuleSwitch(
final PushRuleSection section,
final String title,
final String description,
final IconData icon,
/// What the rules do when on.
final PushActions actions,
/// Whether turning it off disables the rules, so the next rule that matches
/// decides, rather than not notifying.
final bool disableWhenOff,
) {
mentions(
.messages,
"Mentions",
"Messages that mention you, or contain your name.",
Icons.alternate_email,
.highlightWithSound,
true,
),
roomMentions(
.messages,
"Room Mentions",
"Messages that notify everyone in the room with @room.",
Icons.campaign,
.highlightWithSound,
true,
),
directMessages(
.messages,
"Direct Messages",
"Messages in rooms with just you and one other person.",
Icons.person,
.notifyWithSound,
false,
),
groupMessages(
.messages,
"Group Messages",
"Messages in rooms with more people, rather than only mentions and keywords.",
Icons.group,
.notifyWithSound,
false,
),
invites(
.otherEvents,
"Invites",
"Invites for you to join rooms.",
Icons.mail,
.notifyWithSound,
false,
),
calls(
.otherEvents,
"Calls",
"Incoming calls.",
Icons.call,
.new(notify: true, sound: "ring"),
false,
),
tombstones(
.otherEvents,
"Room Tombstones",
"Rooms being upgraded or locked.",
Icons.lock,
.notifyWithSound,
false,
);
IList<({PushRuleKind kind, String ruleId})> get rules => .new(switch (this) {
.mentions => [
(kind: .override, ruleId: ".m.rule.is_user_mention"),
(kind: .override, ruleId: ".m.rule.contains_display_name"),
(kind: .content, ruleId: ".m.rule.contains_user_name"),
],
.roomMentions => [
(kind: .override, ruleId: ".m.rule.is_room_mention"),
(kind: .override, ruleId: ".m.rule.roomnotif"),
],
.directMessages => [
(kind: .underride, ruleId: ".m.rule.room_one_to_one"),
(kind: .underride, ruleId: ".m.rule.encrypted_room_one_to_one"),
],
.groupMessages => [
(kind: .underride, ruleId: ".m.rule.message"),
(kind: .underride, ruleId: ".m.rule.encrypted"),
],
.invites => [(kind: .override, ruleId: ".m.rule.invite_for_me")],
.calls => [(kind: .underride, ruleId: ".m.rule.call")],
.tombstones => [(kind: .override, ruleId: ".m.rule.tombstone")],
});
/// The rules in [ruleset], as servers may not have them all.
IList<({PushRuleKind kind, PushRule rule})> rulesIn(PushRuleset ruleset) =>
rules
.map(
(rule) => switch (ruleset.find(rule.kind, rule.ruleId)) {
final found? => (kind: rule.kind, rule: found),
null => null,
},
)
.nonNulls
.toIList();
/// Whether the rules in [ruleset] are on or off, or null if they're set up
/// some other way.
bool? valueIn(PushRuleset ruleset) => switch (rulesIn(ruleset)
.map((entry) => entry.rule)) {
final rules
when rules.every((rule) => rule.enabled && rule.actions == actions) =>
true,
final rules
when rules.every((rule) => !rule.enabled || !rule.actions.notify) =>
false,
_ => null,
};
/// The rules in [ruleset], turned on or off.
IList<({PushRuleKind kind, PushRule rule})> changesIn(
PushRuleset ruleset,
bool value,
) => rulesIn(ruleset)
.map(
(entry) => (
kind: entry.kind,
rule: value
? entry.rule.copyWith(enabled: true, actions: actions)
: disableWhenOff
? entry.rule.copyWith(enabled: false)
: entry.rule.copyWith(enabled: true, actions: const .new()),
),
)
.toIList();
}

105
lib/models/push_rules.dart Normal file
View file

@ -0,0 +1,105 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "push_rules.freezed.dart";
part "push_rules.g.dart";
/// The content of the m.push_rules account data.
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PushRules({final PushRuleset global = const .new()})
with _$PushRules {
Map<String, Object?> toJson() => _$PushRulesToJson(this);
factory PushRules.fromJson(Map<String, Object?> json) =>
_$PushRulesFromJson(json);
}
/// Push rules by kind, apart from room and sender rules, which Nexus doesn't
/// use. The first rule that matches decides what happens.
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PushRuleset({
final IList<PushRule> override = const .empty(),
final IList<PushRule> content = const .empty(),
final IList<PushRule> underride = const .empty(),
}) with _$PushRuleset {
IList<PushRule> ofKind(PushRuleKind kind) => switch (kind) {
.override => override,
.content => content,
.underride => underride,
};
PushRule? find(PushRuleKind kind, String ruleId) =>
ofKind(kind).where((rule) => rule.ruleId == ruleId).firstOrNull;
/// Mutes everything when enabled, as it matches every event.
PushRule? get master => find(.override, ".m.rule.master");
/// The content rules the user added, which match these words.
IList<PushRule> get keywords =>
content.where((rule) => !rule.isDefault).toIList();
Map<String, Object?> toJson() => _$PushRulesetToJson(this);
factory PushRuleset.fromJson(Map<String, Object?> json) =>
_$PushRulesetFromJson(json);
}
enum PushRuleKind { override, content, underride }
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PushRule({
required final String ruleId,
final PushActions actions = const .new(),
/// Whether the server made the rule, so it can't be deleted.
@JsonKey(name: "default") final bool isDefault = false,
final bool enabled = true,
/// What content rules match in messages' bodies.
final String? pattern,
}) with _$PushRule {
Map<String, Object?> toJson() => _$PushRuleToJson(this);
factory PushRule.fromJson(Map<String, Object?> json) =>
_$PushRuleFromJson(json);
}
/// What happens when a push rule matches. Actions Nexus doesn't support are
/// dropped, so they're replaced when these are changed.
@Freezed(toJson: false, fromJson: false)
class const PushActions({
final bool notify = false,
final bool highlight = false,
final String? sound,
}) with _$PushActions {
static const defaultSound = "default";
static const notifyWithSound = PushActions(notify: true, sound: defaultSound);
static const highlightWithSound = PushActions(
notify: true,
highlight: true,
sound: defaultSound,
);
List<Object?> toJson() => [
if (notify) "notify",
if (highlight) {"set_tweak": "highlight"},
if (sound != null) {"set_tweak": "sound", "value": sound},
];
factory PushActions.fromJson(List<Object?> json) => json.fold(
const .new(),
(actions, action) => switch (action) {
"notify" => actions.copyWith(notify: true),
{"set_tweak": "highlight", "value": final bool highlight} =>
actions.copyWith(highlight: highlight),
{"set_tweak": "highlight"} => actions.copyWith(highlight: true),
{"set_tweak": "sound", "value": final String sound} => actions.copyWith(
sound: sound,
),
_ => actions,
},
);
}

View file

@ -0,0 +1,27 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/push_rules.dart";
part "update_push_rule.freezed.dart";
part "update_push_rule.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const UpdatePushRuleRequest({
required final PushRuleKind kind,
required final String ruleId,
required final UpdatePushRuleAction action,
/// The new rule, when putting it. Only its actions and pattern are used.
final PushRule? newContent,
/// The new actions, when putting only them.
final PushActions? actions,
}) with _$UpdatePushRuleRequest {
Map<String, Object?> toJson() => _$UpdatePushRuleRequestToJson(this);
factory UpdatePushRuleRequest.fromJson(Map<String, Object?> json) =>
_$UpdatePushRuleRequestFromJson(json);
}
@JsonEnum(fieldRename: .snake)
enum UpdatePushRuleAction { enable, disable, put, putActions, delete }