diff --git a/lib/controllers/account_data.dart b/lib/controllers/account_data.dart index 7d6e449..62997b5 100644 --- a/lib/controllers/account_data.dart +++ b/lib/controllers/account_data.dart @@ -8,6 +8,7 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/push_rules.dart"; import "package:nexus/models/requests/update_push_rule.dart"; +import "package:nexus/models/room_notification_level.dart"; import "package:nexus/models/sync_data.dart"; class AccountDataController extends Notifier { @@ -98,6 +99,27 @@ class AccountDataController extends Notifier { (rules) => rules.find(kind, ruleId) == null, ); + /// Replaces the push rules for the room with [roomId] with those [level] + /// needs, deleting them first so disabled ones don't stay disabled. + Future setRoomNotificationLevel( + String roomId, + RoomNotificationLevel level, + ) async { + final rules = state.pushRules.global; + if (RoomNotificationLevel.of(rules, roomId) == level) return; + + return _updatePushRules( + .new([ + ...[PushRuleKind.override, PushRuleKind.room] + .where((kind) => rules.find(kind, roomId) != null) + .map((kind) => .new(kind: kind, ruleId: roomId, action: .delete)), + if (level.ruleFor(roomId) case (:final kind, :final rule)) + .new(kind: kind, ruleId: roomId, action: .put, newContent: rule), + ]), + (rules) => RoomNotificationLevel.of(rules, roomId) == level, + ); + } + /// Sends [requests] in order, then waits until [arrived] is true for the /// synced push rules. Future _updatePushRules( diff --git a/lib/models/push_rules.dart b/lib/models/push_rules.dart index a23d12b..88fb050 100644 --- a/lib/models/push_rules.dart +++ b/lib/models/push_rules.dart @@ -15,18 +15,20 @@ class const PushRules({final PushRuleset global = const .new()}) _$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. +/// Push rules by kind, apart from 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 override = const .empty(), final IList content = const .empty(), + final IList room = const .empty(), final IList underride = const .empty(), }) with _$PushRuleset { IList ofKind(PushRuleKind kind) => switch (kind) { .override => override, .content => content, + .room => room, .underride => underride, }; @@ -46,7 +48,7 @@ class const PushRuleset({ _$PushRulesetFromJson(json); } -enum PushRuleKind { override, content, underride } +enum PushRuleKind { override, content, room, underride } @Freezed(toJson: false, fromJson: false) @JsonSerializable() @@ -60,6 +62,9 @@ class const PushRule({ /// What content rules match in messages' bodies. final String? pattern, + + /// What override and underride rules match. + final IList conditions = const .empty(), }) with _$PushRule { Map toJson() => _$PushRuleToJson(this); @@ -67,6 +72,24 @@ class const PushRule({ _$PushRuleFromJson(json); } +/// Only the fields of event_match conditions are kept. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PushCondition({ + required final String kind, + final String? key, + final String? pattern, +}) with _$PushCondition { + /// Matches events in the room with [roomId]. + factory PushCondition.inRoom(String roomId) => + .new(kind: "event_match", key: "room_id", pattern: roomId); + + Map toJson() => _$PushConditionToJson(this); + + factory PushCondition.fromJson(Map json) => + _$PushConditionFromJson(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) diff --git a/lib/models/room_notification_level.dart b/lib/models/room_notification_level.dart new file mode 100644 index 0000000..36a5029 --- /dev/null +++ b/lib/models/room_notification_level.dart @@ -0,0 +1,49 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/push_rules.dart"; + +enum RoomNotificationLevel( + final String title, + final String description, + final IconData icon, +) { + global( + "Default", + "Follows your global notification rules.", + Icons.notifications, + ), + allMessages( + "All Messages", + "Every message notifies you.", + Icons.notifications_active, + ), + mentionsAndKeywords( + "Mentions & Keywords", + "Only messages that mention you or contain your keywords notify you.", + Icons.alternate_email, + ), + mute("Mute", "Nothing in this room notifies you.", Icons.notifications_off); + + static RoomNotificationLevel of(PushRuleset ruleset, String roomId) => + switch ((ruleset.find(.override, roomId), ruleset.find(.room, roomId))) { + (PushRule(enabled: true, actions: PushActions(notify: false)), _) => + .mute, + (_, PushRule(enabled: true, actions: PushActions(notify: true))) => + .allMessages, + (_, PushRule(enabled: true)) => .mentionsAndKeywords, + _ => .global, + }; + + ({PushRuleKind kind, PushRule rule})? ruleFor(String roomId) => + switch (this) { + .global => null, + .allMessages => ( + kind: .room, + rule: .new(ruleId: roomId, actions: .notifyWithSound), + ), + .mentionsAndKeywords => (kind: .room, rule: .new(ruleId: roomId)), + .mute => ( + kind: .override, + rule: .new(ruleId: roomId, conditions: .new([.inRoom(roomId)])), + ), + }; +} diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index 2f3f156..22d94f9 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -5,7 +5,9 @@ import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_leave_room_sheet.dart"; import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/models/room_notification_level.dart"; import "package:nexus/controllers/room_settings_sections.dart"; import "package:nexus/widgets/settings/settings_layout.dart"; import "package:nexus/controllers/via.dart"; @@ -44,6 +46,44 @@ final class const RoomMenuButton( ), ), if (room != null) ...[ + PopupMenuItem( + onTap: () async { + final roomId = room!.metadata!.id; + final accountData = ref.read( + AccountDataController.provider.notifier, + ); + final current = RoomNotificationLevel.of( + ref.read(AccountDataController.provider).pushRules.global, + roomId, + ); + + final level = await context.showAdaptiveMenu( + RoomNotificationLevel.values + .map( + (level) => PopupMenuItem( + value: level, + child: ListTile( + selected: level == current, + leading: Icon(level.icon), + title: Text(level.title), + subtitle: Text(level.description), + trailing: level == current ? Icon(Icons.check) : null, + ), + ), + ) + .toList(), + ); + if (level == null) return; + await accountData + .setRoomNotificationLevel(roomId, level) + .onError(showError); + }, + child: ListTile( + leading: Icon(Icons.notifications), + title: Text("Notifications"), + trailing: Icon(Icons.expand_more), + ), + ), PopupMenuItem( onTap: () async { final vias = ref.read(ViaController.provider(room!)).viaQuery;