parent
cb983701ca
commit
c9172e92eb
11 changed files with 596 additions and 15 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -12,6 +12,7 @@
|
||||||
"prefs",
|
"prefs",
|
||||||
"recents",
|
"recents",
|
||||||
"unban",
|
"unban",
|
||||||
|
"underride",
|
||||||
"unifiedpush",
|
"unifiedpush",
|
||||||
"unredact",
|
"unredact",
|
||||||
"webpush"
|
"webpush"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import "package:flutter/foundation.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
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/requests/update_push_rule.dart";
|
||||||
|
|
||||||
class AccountDataController extends Notifier<AccountData> {
|
class AccountDataController extends Notifier<AccountData> {
|
||||||
@override
|
@override
|
||||||
|
|
@ -24,24 +26,15 @@ class AccountDataController extends Notifier<AccountData> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> setEnabledImagePacks(ImagePackRooms packs) async {
|
Future<void> setEnabledImagePacks(ImagePackRooms packs) {
|
||||||
ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries
|
ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries
|
||||||
.expand((entry) => entry.value.keys.map((key) => (entry.key, key)))
|
.expand((entry) => entry.value.keys.map((key) => (entry.key, key)))
|
||||||
.toISet();
|
.toISet();
|
||||||
final keys = keysOf(packs);
|
final keys = keysOf(packs);
|
||||||
bool arrived(AccountData data) {
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
final current = keysOf(data.enabledImagePacks);
|
|
||||||
return current.length == keys.length && current.containsAll(keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
final completer = Completer<void>();
|
return _sendAndWait(
|
||||||
final removeListener = listenSelf((_, data) {
|
() => .wait([
|
||||||
if (arrived(data) && !completer.isCompleted) completer.complete();
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
|
||||||
await Future.wait([
|
|
||||||
if (state.imagePackRooms != null)
|
if (state.imagePackRooms != null)
|
||||||
client.setAccountData(
|
client.setAccountData(
|
||||||
.new(type: AccountData.imagePackRoomsKey, content: packs),
|
.new(type: AccountData.imagePackRoomsKey, content: packs),
|
||||||
|
|
@ -49,8 +42,65 @@ class AccountDataController extends Notifier<AccountData> {
|
||||||
client.setAccountData(
|
client.setAccountData(
|
||||||
.new(type: AccountData.legacyImagePackRoomsKey, content: packs),
|
.new(type: AccountData.legacyImagePackRoomsKey, content: packs),
|
||||||
),
|
),
|
||||||
]);
|
]),
|
||||||
|
(data) {
|
||||||
|
final current = keysOf(data.enabledImagePacks);
|
||||||
|
return current.length == keys.length && current.containsAll(keys);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves [rule] of [kind], creating it if it's new, or otherwise changing only
|
||||||
|
/// its actions and whether it's enabled.
|
||||||
|
Future<void> savePushRule(PushRuleKind kind, PushRule rule) {
|
||||||
|
UpdatePushRuleRequest request(UpdatePushRuleAction action) =>
|
||||||
|
.new(kind: kind, ruleId: rule.ruleId, action: action);
|
||||||
|
|
||||||
|
return _updatePushRules(
|
||||||
|
.new(switch (state.pushRules.global.find(kind, rule.ruleId)) {
|
||||||
|
null => [request(.put).copyWith(newContent: rule)],
|
||||||
|
final previous => [
|
||||||
|
if (previous.actions != rule.actions)
|
||||||
|
request(.putActions).copyWith(actions: rule.actions),
|
||||||
|
if (previous.enabled != rule.enabled)
|
||||||
|
request(rule.enabled ? .enable : .disable),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
(rules) => rules.find(kind, rule.ruleId) == rule,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deletePushRule(PushRuleKind kind, String ruleId) =>
|
||||||
|
_updatePushRules(
|
||||||
|
.new([.new(kind: kind, ruleId: ruleId, action: .delete)]),
|
||||||
|
(rules) => rules.find(kind, ruleId) == null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Sends [requests] in order, then waits until [arrived] is true for the
|
||||||
|
/// synced push rules.
|
||||||
|
Future<void> _updatePushRules(
|
||||||
|
IList<UpdatePushRuleRequest> requests,
|
||||||
|
bool Function(PushRuleset rules) arrived,
|
||||||
|
) => _sendAndWait(
|
||||||
|
() => .forEach(
|
||||||
|
requests,
|
||||||
|
ref.read(ClientController.provider.notifier).updatePushRule,
|
||||||
|
),
|
||||||
|
(data) => arrived(data.pushRules.global),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Runs [send], then waits until [arrived] is true for the synced data.
|
||||||
|
Future<void> _sendAndWait(
|
||||||
|
Future<void> Function() send,
|
||||||
|
bool Function(AccountData data) arrived,
|
||||||
|
) async {
|
||||||
|
final completer = Completer<void>();
|
||||||
|
final removeListener = listenSelf((_, data) {
|
||||||
|
if (arrived(data) && !completer.isCompleted) completer.complete();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await send();
|
||||||
if (!arrived(state)) {
|
if (!arrived(state)) {
|
||||||
await completer.future.timeout(
|
await completer.future.timeout(
|
||||||
const .new(seconds: 10),
|
const .new(seconds: 10),
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import "package:nexus/models/requests/send_message.dart";
|
||||||
import "package:nexus/models/requests/set_account_data.dart";
|
import "package:nexus/models/requests/set_account_data.dart";
|
||||||
import "package:nexus/models/requests/set_membership.dart";
|
import "package:nexus/models/requests/set_membership.dart";
|
||||||
import "package:nexus/models/requests/set_state.dart";
|
import "package:nexus/models/requests/set_state.dart";
|
||||||
|
import "package:nexus/models/requests/update_push_rule.dart";
|
||||||
import "package:nexus/models/requests/upload_media.dart";
|
import "package:nexus/models/requests/upload_media.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:nexus/models/room_summary.dart";
|
import "package:nexus/models/room_summary.dart";
|
||||||
|
|
@ -333,6 +334,9 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<void> setAccountData(SetAccountDataRequest request) =>
|
Future<void> setAccountData(SetAccountDataRequest request) =>
|
||||||
_sendCommand("set_account_data", request.toJson());
|
_sendCommand("set_account_data", request.toJson());
|
||||||
|
|
||||||
|
Future<void> updatePushRule(UpdatePushRuleRequest request) =>
|
||||||
|
_sendCommand("update_push_rule", request.toJson());
|
||||||
|
|
||||||
Future<void> registerPusher(RegisterPusherRequest request) =>
|
Future<void> registerPusher(RegisterPusherRequest request) =>
|
||||||
_sendCommand("register_homeserver_push", request.toJson());
|
_sendCommand("register_homeserver_push", request.toJson());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:intl/intl.dart";
|
import "package:intl/intl.dart";
|
||||||
import "package:m3e_buttons/m3e_buttons.dart";
|
import "package:m3e_buttons/m3e_buttons.dart";
|
||||||
|
import "package:multi_state_switch/multi_state_switch.dart";
|
||||||
import "package:nexus/controllers/account_data.dart";
|
import "package:nexus/controllers/account_data.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
|
|
@ -25,15 +26,18 @@ import "package:nexus/models/account_data.dart";
|
||||||
import "package:nexus/models/client_state.dart";
|
import "package:nexus/models/client_state.dart";
|
||||||
import "package:nexus/models/image_pack.dart";
|
import "package:nexus/models/image_pack.dart";
|
||||||
import "package:nexus/models/name_color.dart";
|
import "package:nexus/models/name_color.dart";
|
||||||
|
import "package:nexus/models/push_rule_switch.dart";
|
||||||
import "package:nexus/models/room_order.dart";
|
import "package:nexus/models/room_order.dart";
|
||||||
import "package:nexus/models/setting.dart";
|
import "package:nexus/models/setting.dart";
|
||||||
import "package:nexus/models/settings.dart";
|
import "package:nexus/models/settings.dart";
|
||||||
import "package:nexus/models/settings_category.dart";
|
import "package:nexus/models/settings_category.dart";
|
||||||
import "package:nexus/models/show_and_send.dart";
|
import "package:nexus/models/show_and_send.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
|
import "package:nexus/widgets/divider_text.dart";
|
||||||
import "package:nexus/widgets/image_pack_avatar.dart";
|
import "package:nexus/widgets/image_pack_avatar.dart";
|
||||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||||
import "package:nexus/widgets/settings/enable_image_packs_sheet.dart";
|
import "package:nexus/widgets/settings/enable_image_packs_sheet.dart";
|
||||||
|
import "package:nexus/widgets/settings/keyword_field.dart";
|
||||||
import "package:nexus/widgets/user_bottom_sheet.dart";
|
import "package:nexus/widgets/user_bottom_sheet.dart";
|
||||||
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
|
|
@ -95,6 +99,67 @@ class SettingsSectionsController
|
||||||
.onError(showError),
|
.onError(showError),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final pushRules = ref.watch(
|
||||||
|
AccountDataController.provider.select((data) => data.pushRules.global),
|
||||||
|
);
|
||||||
|
final accountData = ref.read(AccountDataController.provider.notifier);
|
||||||
|
|
||||||
|
Setting header(String title, String description, IconData icon) => .new(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
icon: icon,
|
||||||
|
builder: (title, _, _) => Padding(
|
||||||
|
padding: .symmetric(horizontal: 16),
|
||||||
|
child: DividerText(title),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A switch for push rules, which is disabled while [onChanged] saves. A
|
||||||
|
/// null [value] is shown in the middle, for rules set up some other way.
|
||||||
|
Setting pushSwitchSetting({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required IconData icon,
|
||||||
|
required bool? value,
|
||||||
|
required Future<void> Function(bool value) onChanged,
|
||||||
|
}) => .new(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
icon: icon,
|
||||||
|
builder: (title, description, icon) => HookBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
final saving = Saving.use();
|
||||||
|
final position = switch (value) {
|
||||||
|
false => 0,
|
||||||
|
null => 1,
|
||||||
|
true => 2,
|
||||||
|
};
|
||||||
|
void change(bool value) =>
|
||||||
|
saving.run(() => onChanged(value)).onError(showError);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
enabled: !saving.active,
|
||||||
|
leading: Icon(icon),
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: Text(description),
|
||||||
|
trailing: MultiStateSwitch(
|
||||||
|
states: 3,
|
||||||
|
// As wide as a regular switch, which has 2 states 20 apart
|
||||||
|
stepWidth: 10,
|
||||||
|
value: position,
|
||||||
|
semanticFormatterCallback: (position) =>
|
||||||
|
["Off", "Custom", "On"][position],
|
||||||
|
onChanged: saving.active
|
||||||
|
? null
|
||||||
|
: (newPosition) => change(newPosition > position),
|
||||||
|
),
|
||||||
|
// Tapping turns it off only when it's fully on
|
||||||
|
onTap: () => change(value != true),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return .new({
|
return .new({
|
||||||
"General": .new([
|
"General": .new([
|
||||||
.new(
|
.new(
|
||||||
|
|
@ -412,7 +477,7 @@ class SettingsSectionsController
|
||||||
icon: Icons.notifications,
|
icon: Icons.notifications,
|
||||||
settings: .new([
|
settings: .new([
|
||||||
.new(
|
.new(
|
||||||
title: "Push notifications",
|
title: "Push Notifications",
|
||||||
description: "Enable push notifications using Web Push, and choose which UnifiedPush distributor to use.",
|
description: "Enable push notifications using Web Push, and choose which UnifiedPush distributor to use.",
|
||||||
builder: (title, description, icon) => HookConsumer(
|
builder: (title, description, icon) => HookConsumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
|
|
@ -509,6 +574,102 @@ class SettingsSectionsController
|
||||||
),
|
),
|
||||||
icon: Icons.notification_add,
|
icon: Icons.notification_add,
|
||||||
),
|
),
|
||||||
|
if (pushRules.master case final master?)
|
||||||
|
pushSwitchSetting(
|
||||||
|
title: "Mute All",
|
||||||
|
description: "Don't notify for anything, without changing your other rules.",
|
||||||
|
icon: Icons.notifications_off,
|
||||||
|
value: master.enabled,
|
||||||
|
onChanged: (mute) => accountData.savePushRule(
|
||||||
|
.override,
|
||||||
|
master.copyWith(enabled: mute),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...PushRuleSection.values.expand(
|
||||||
|
(section) => [
|
||||||
|
header(section.title, section.description, section.icon),
|
||||||
|
...PushRuleSwitch.values
|
||||||
|
.where(
|
||||||
|
(option) =>
|
||||||
|
option.section == section &&
|
||||||
|
option.rulesIn(pushRules).isNotEmpty,
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(option) => pushSwitchSetting(
|
||||||
|
title: option.title,
|
||||||
|
description: option.description,
|
||||||
|
icon: option.icon,
|
||||||
|
value: option.valueIn(pushRules),
|
||||||
|
onChanged: (value) => Future.wait(
|
||||||
|
option
|
||||||
|
.changesIn(pushRules, value)
|
||||||
|
.map(
|
||||||
|
(change) => accountData.savePushRule(
|
||||||
|
change.kind,
|
||||||
|
change.rule,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
header(
|
||||||
|
"Keywords",
|
||||||
|
"Messages containing these words, which notify with a highlight and sound.",
|
||||||
|
Icons.text_fields,
|
||||||
|
),
|
||||||
|
...pushRules.keywords.map(
|
||||||
|
(rule) => Setting(
|
||||||
|
title: rule.pattern ?? rule.ruleId,
|
||||||
|
description: "Messages containing this keyword.",
|
||||||
|
icon: Icons.text_fields,
|
||||||
|
builder: (title, description, icon) => HookBuilder(
|
||||||
|
key: ValueKey(rule.ruleId),
|
||||||
|
builder: (context) {
|
||||||
|
final saving = Saving.use();
|
||||||
|
final custom =
|
||||||
|
!rule.enabled || rule.actions != .highlightWithSound;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
enabled: !saving.active,
|
||||||
|
leading: Icon(icon),
|
||||||
|
title: Text(title),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: custom
|
||||||
|
? "Set up differently by another client. Press to reset to Nexus defaults."
|
||||||
|
: "Remove keyword",
|
||||||
|
icon: Icon(custom ? Icons.warning : Icons.delete),
|
||||||
|
onPressed: saving.active
|
||||||
|
? null
|
||||||
|
: () => saving
|
||||||
|
.run(
|
||||||
|
() => custom
|
||||||
|
? accountData.savePushRule(
|
||||||
|
.content,
|
||||||
|
rule.copyWith(
|
||||||
|
enabled: true,
|
||||||
|
actions: .highlightWithSound,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: accountData.deletePushRule(
|
||||||
|
.content,
|
||||||
|
rule.ruleId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.onError(showError),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
.new(
|
||||||
|
title: "Add Keyword",
|
||||||
|
description: "Notify for messages containing a word.",
|
||||||
|
icon: Icons.add,
|
||||||
|
builder: (_, _, _) => KeywordField(),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
.new(
|
.new(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/push_rules.dart";
|
||||||
|
|
||||||
part "account_data.freezed.dart";
|
part "account_data.freezed.dart";
|
||||||
part "account_data.g.dart";
|
part "account_data.g.dart";
|
||||||
|
|
@ -22,12 +23,15 @@ class const AccountData({
|
||||||
final ImagePackRooms? imagePackRooms,
|
final ImagePackRooms? imagePackRooms,
|
||||||
@JsonKey(name: AccountData.legacyImagePackRoomsKey)
|
@JsonKey(name: AccountData.legacyImagePackRoomsKey)
|
||||||
final ImagePackRooms? legacyImagePackRooms,
|
final ImagePackRooms? legacyImagePackRooms,
|
||||||
|
@JsonKey(name: AccountData.pushRulesKey)
|
||||||
|
final PushRules pushRules = const .new(),
|
||||||
}) with _$AccountData {
|
}) with _$AccountData {
|
||||||
static const invitePermissionConfigKey = "m.invite_permission_config";
|
static const invitePermissionConfigKey = "m.invite_permission_config";
|
||||||
static const directKey = "m.direct";
|
static const directKey = "m.direct";
|
||||||
static const recentEmojiKey = "m.recent_emoji";
|
static const recentEmojiKey = "m.recent_emoji";
|
||||||
static const imagePackRoomsKey = "m.image_pack.rooms";
|
static const imagePackRoomsKey = "m.image_pack.rooms";
|
||||||
static const legacyImagePackRoomsKey = "im.ponies.emote_rooms";
|
static const legacyImagePackRoomsKey = "im.ponies.emote_rooms";
|
||||||
|
static const pushRulesKey = "m.push_rules";
|
||||||
|
|
||||||
ImagePackRooms get enabledImagePacks =>
|
ImagePackRooms get enabledImagePacks =>
|
||||||
imagePackRooms ?? legacyImagePackRooms ?? const .new();
|
imagePackRooms ?? legacyImagePackRooms ?? const .new();
|
||||||
|
|
|
||||||
150
lib/models/push_rule_switch.dart
Normal file
150
lib/models/push_rule_switch.dart
Normal 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
105
lib/models/push_rules.dart
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
27
lib/models/requests/update_push_rule.dart
Normal file
27
lib/models/requests/update_push_rule.dart
Normal 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 }
|
||||||
70
lib/widgets/settings/keyword_field.dart
Normal file
70
lib/widgets/settings/keyword_field.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/account_data.dart";
|
||||||
|
import "package:nexus/helpers/hooks/saving.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
|
|
||||||
|
/// Adds a keyword, which notifies with a highlight and sound for messages
|
||||||
|
/// containing it.
|
||||||
|
class const KeywordField({super.key}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final saving = Saving.use();
|
||||||
|
final controller = useTextEditingController();
|
||||||
|
final keyword = useValueListenable(controller).text.trim();
|
||||||
|
final exists = ref.watch(
|
||||||
|
AccountDataController.provider.select(
|
||||||
|
(data) => data.pushRules.global.find(.content, keyword) != null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keywords are their rules' IDs, which can't be like these
|
||||||
|
final error = keyword.startsWith(".")
|
||||||
|
? "Can't start with a ."
|
||||||
|
: keyword.contains(RegExp(r"[/\\]"))
|
||||||
|
? r"Can't contain / or \"
|
||||||
|
: exists
|
||||||
|
? "Already a keyword"
|
||||||
|
: null;
|
||||||
|
final canAdd = keyword.isNotEmpty && error == null && !saving.active;
|
||||||
|
|
||||||
|
void add() {
|
||||||
|
if (!canAdd) return;
|
||||||
|
saving
|
||||||
|
.run(() async {
|
||||||
|
await ref
|
||||||
|
.read(AccountDataController.provider.notifier)
|
||||||
|
.savePushRule(
|
||||||
|
.content,
|
||||||
|
.new(
|
||||||
|
ruleId: keyword,
|
||||||
|
pattern: keyword,
|
||||||
|
actions: .highlightWithSound,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (context.mounted) controller.clear();
|
||||||
|
})
|
||||||
|
.onError(showError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: .symmetric(horizontal: 16),
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
enabled: !saving.active,
|
||||||
|
onSubmitted: (_) => add(),
|
||||||
|
decoration: .new(
|
||||||
|
labelText: "New keyword",
|
||||||
|
helperText: "Matches whole words. * matches anything, and ? matches one character.",
|
||||||
|
errorText: error,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: "Add keyword",
|
||||||
|
icon: Icon(Icons.add),
|
||||||
|
onPressed: canAdd ? add : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1032,6 +1032,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
multi_state_switch:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: multi_state_switch
|
||||||
|
sha256: "501890f46d33d6cba6a84eb8f1689dba180a264ce3373fa689f75f4887d822fd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
native_toolchain_c:
|
native_toolchain_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ dependencies:
|
||||||
app_links: 7.2.2
|
app_links: 7.2.2
|
||||||
file_selector: 1.1.0
|
file_selector: 1.1.0
|
||||||
material_ui: 1.6.0
|
material_ui: 1.6.0
|
||||||
|
multi_state_switch: 1.1.0
|
||||||
unifiedpush: 6.2.0
|
unifiedpush: 6.2.0
|
||||||
unifiedpush_storage_shared_preferences: 1.0.0
|
unifiedpush_storage_shared_preferences: 1.0.0
|
||||||
flutter_local_notifications: 22.3.1
|
flutter_local_notifications: 22.3.1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue