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",
|
||||
"recents",
|
||||
"unban",
|
||||
"underride",
|
||||
"unifiedpush",
|
||||
"unredact",
|
||||
"webpush"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import "package:flutter/foundation.dart";
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
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";
|
||||
|
||||
class AccountDataController extends Notifier<AccountData> {
|
||||
@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
|
||||
.expand((entry) => entry.value.keys.map((key) => (entry.key, key)))
|
||||
.toISet();
|
||||
final keys = keysOf(packs);
|
||||
bool arrived(AccountData data) {
|
||||
final current = keysOf(data.enabledImagePacks);
|
||||
return current.length == keys.length && current.containsAll(keys);
|
||||
}
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
final completer = Completer<void>();
|
||||
final removeListener = listenSelf((_, data) {
|
||||
if (arrived(data) && !completer.isCompleted) completer.complete();
|
||||
});
|
||||
|
||||
try {
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
await Future.wait([
|
||||
return _sendAndWait(
|
||||
() => .wait([
|
||||
if (state.imagePackRooms != null)
|
||||
client.setAccountData(
|
||||
.new(type: AccountData.imagePackRoomsKey, content: packs),
|
||||
|
|
@ -49,8 +42,65 @@ class AccountDataController extends Notifier<AccountData> {
|
|||
client.setAccountData(
|
||||
.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)) {
|
||||
await completer.future.timeout(
|
||||
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_membership.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/room.dart";
|
||||
import "package:nexus/models/room_summary.dart";
|
||||
|
|
@ -333,6 +334,9 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<void> setAccountData(SetAccountDataRequest request) =>
|
||||
_sendCommand("set_account_data", request.toJson());
|
||||
|
||||
Future<void> updatePushRule(UpdatePushRuleRequest request) =>
|
||||
_sendCommand("update_push_rule", request.toJson());
|
||||
|
||||
Future<void> registerPusher(RegisterPusherRequest request) =>
|
||||
_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:intl/intl.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/client.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/image_pack.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/setting.dart";
|
||||
import "package:nexus/models/settings.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/models/show_and_send.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/settings/dialog_list_tile.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/reset_encryption_sheet.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
|
|
@ -95,6 +99,67 @@ class SettingsSectionsController
|
|||
.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({
|
||||
"General": .new([
|
||||
.new(
|
||||
|
|
@ -412,7 +477,7 @@ class SettingsSectionsController
|
|||
icon: Icons.notifications,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Push notifications",
|
||||
title: "Push Notifications",
|
||||
description: "Enable push notifications using Web Push, and choose which UnifiedPush distributor to use.",
|
||||
builder: (title, description, icon) => HookConsumer(
|
||||
builder: (context, ref, _) {
|
||||
|
|
@ -509,6 +574,102 @@ class SettingsSectionsController
|
|||
),
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
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"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ dependencies:
|
|||
app_links: 7.2.2
|
||||
file_selector: 1.1.0
|
||||
material_ui: 1.6.0
|
||||
multi_state_switch: 1.1.0
|
||||
unifiedpush: 6.2.0
|
||||
unifiedpush_storage_shared_preferences: 1.0.0
|
||||
flutter_local_notifications: 22.3.1
|
||||
|
|
|
|||
Loading…
Reference in a new issue