add per-room notif overrides

Fixes #163
This commit is contained in:
Henry Hiles 2026-11-09 15:40:17 -05:00
commit 8aada9a895
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
4 changed files with 137 additions and 3 deletions

View file

@ -8,6 +8,7 @@ import "package:nexus/controllers/client.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/push_rules.dart"; import "package:nexus/models/push_rules.dart";
import "package:nexus/models/requests/update_push_rule.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"; import "package:nexus/models/sync_data.dart";
class AccountDataController extends Notifier<AccountData> { class AccountDataController extends Notifier<AccountData> {
@ -98,6 +99,27 @@ class AccountDataController extends Notifier<AccountData> {
(rules) => rules.find(kind, ruleId) == null, (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<void> 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 /// Sends [requests] in order, then waits until [arrived] is true for the
/// synced push rules. /// synced push rules.
Future<void> _updatePushRules( Future<void> _updatePushRules(

View file

@ -15,18 +15,20 @@ class const PushRules({final PushRuleset global = const .new()})
_$PushRulesFromJson(json); _$PushRulesFromJson(json);
} }
/// Push rules by kind, apart from room and sender rules, which Nexus doesn't /// Push rules by kind, apart from sender rules, which Nexus doesn't use. The
/// use. The first rule that matches decides what happens. /// first rule that matches decides what happens.
@Freezed(toJson: false, fromJson: false) @Freezed(toJson: false, fromJson: false)
@JsonSerializable() @JsonSerializable()
class const PushRuleset({ class const PushRuleset({
final IList<PushRule> override = const .empty(), final IList<PushRule> override = const .empty(),
final IList<PushRule> content = const .empty(), final IList<PushRule> content = const .empty(),
final IList<PushRule> room = const .empty(),
final IList<PushRule> underride = const .empty(), final IList<PushRule> underride = const .empty(),
}) with _$PushRuleset { }) with _$PushRuleset {
IList<PushRule> ofKind(PushRuleKind kind) => switch (kind) { IList<PushRule> ofKind(PushRuleKind kind) => switch (kind) {
.override => override, .override => override,
.content => content, .content => content,
.room => room,
.underride => underride, .underride => underride,
}; };
@ -46,7 +48,7 @@ class const PushRuleset({
_$PushRulesetFromJson(json); _$PushRulesetFromJson(json);
} }
enum PushRuleKind { override, content, underride } enum PushRuleKind { override, content, room, underride }
@Freezed(toJson: false, fromJson: false) @Freezed(toJson: false, fromJson: false)
@JsonSerializable() @JsonSerializable()
@ -60,6 +62,9 @@ class const PushRule({
/// What content rules match in messages' bodies. /// What content rules match in messages' bodies.
final String? pattern, final String? pattern,
/// What override and underride rules match.
final IList<PushCondition> conditions = const .empty(),
}) with _$PushRule { }) with _$PushRule {
Map<String, Object?> toJson() => _$PushRuleToJson(this); Map<String, Object?> toJson() => _$PushRuleToJson(this);
@ -67,6 +72,24 @@ class const PushRule({
_$PushRuleFromJson(json); _$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<String, Object?> toJson() => _$PushConditionToJson(this);
factory PushCondition.fromJson(Map<String, Object?> json) =>
_$PushConditionFromJson(json);
}
/// What happens when a push rule matches. Actions Nexus doesn't support are /// What happens when a push rule matches. Actions Nexus doesn't support are
/// dropped, so they're replaced when these are changed. /// dropped, so they're replaced when these are changed.
@Freezed(toJson: false, fromJson: false) @Freezed(toJson: false, fromJson: false)

View file

@ -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)])),
),
};
}

View file

@ -5,7 +5,9 @@ import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/show_leave_room_sheet.dart"; import "package:nexus/helpers/extensions/show_leave_room_sheet.dart";
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.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/controllers/room_settings_sections.dart";
import "package:nexus/widgets/settings/settings_layout.dart"; import "package:nexus/widgets/settings/settings_layout.dart";
import "package:nexus/controllers/via.dart"; import "package:nexus/controllers/via.dart";
@ -44,6 +46,44 @@ final class const RoomMenuButton(
), ),
), ),
if (room != null) ...[ 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( PopupMenuItem(
onTap: () async { onTap: () async {
final vias = ref.read(ViaController.provider(room!)).viaQuery; final vias = ref.read(ViaController.provider(room!)).viaQuery;