From 979068fc3a02f74065337f43720c9d880214bea0 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 2 Nov 2026 12:44:41 -0500 Subject: [PATCH] Add block button to profiles Fixes #152 --- lib/controllers/account_data.dart | 16 ++++++ lib/controllers/settings_sections.dart | 62 ++++++++++++++++++++ lib/models/account_data.dart | 24 ++++++++ lib/widgets/settings/block_user_field.dart | 66 ++++++++++++++++++++++ lib/widgets/user_bottom_sheet.dart | 60 ++++++++++++++++++++ 5 files changed, 228 insertions(+) create mode 100644 lib/widgets/settings/block_user_field.dart diff --git a/lib/controllers/account_data.dart b/lib/controllers/account_data.dart index 3bf5acb..c9dc0b4 100644 --- a/lib/controllers/account_data.dart +++ b/lib/controllers/account_data.dart @@ -50,6 +50,22 @@ class AccountDataController extends Notifier { ); } + Future setIgnored(String userId, {required bool ignored}) => + _sendAndWait( + () => ref + .read(ClientController.provider.notifier) + .setAccountData( + .new( + type: AccountData.ignoredUserListKey, + content: state.ignoredUserList.withUser( + userId, + ignored: ignored, + ), + ), + ), + (data) => data.ignoredUserList.contains(userId) == ignored, + ); + /// Saves [rule] of [kind], creating it if it's new, or otherwise changing only /// its actions and whether it's enabled. Future savePushRule(PushRuleKind kind, PushRule rule) { diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 915b1fb..277382e 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -13,6 +13,7 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/enabled_image_packs.dart"; import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/notification.dart"; +import "package:nexus/controllers/profile.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/unified_push.dart"; @@ -20,6 +21,7 @@ import "package:nexus/controllers/spec_versions.dart"; import "package:nexus/controllers/unified_push_allowed.dart"; import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/helpers/extensions/distributor_name.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/room_image_packs.dart"; import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/models/account_data.dart"; @@ -33,8 +35,10 @@ 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/avatar_or_hash.dart"; import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/image_pack_avatar.dart"; +import "package:nexus/widgets/settings/block_user_field.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"; @@ -102,6 +106,11 @@ class SettingsSectionsController final pushRules = ref.watch( AccountDataController.provider.select((data) => data.pushRules.global), ); + final ignoredUsers = ref.watch( + AccountDataController.provider.select( + (data) => data.ignoredUserList.ignoredUsers.keys.toIList().sort(), + ), + ); final accountData = ref.read(AccountDataController.provider.notifier); Setting header(String title, String description, IconData icon) => .new( @@ -726,6 +735,59 @@ class SettingsSectionsController ), icon: Icons.person_off, ), + header( + "Blocked Users", + "Users whose messages and invites are hidden. They aren't told they're blocked.", + Icons.block, + ), + ...ignoredUsers.map( + (userId) => Setting( + title: userId, + description: "A blocked user.", + icon: Icons.person, + builder: (title, description, icon) => HookConsumer( + key: ValueKey(userId), + builder: (context, ref, _) { + final saving = Saving.use(); + final profile = ref + .watch(ProfileController.provider(userId)) + .value + ?.profile; + final name = profile?.displayName ?? userId.localpart; + + return ListTile( + enabled: !saving.active, + leading: AvatarOrHash(profile?.avatarUrl, name), + title: Text(name, overflow: .ellipsis), + subtitle: Text(userId, overflow: .ellipsis), + onTap: () => context.showAdaptiveSheet( + (_) => UserBottomSheet(userId), + ), + trailing: IconButton( + tooltip: "Unblock", + icon: Icon(Icons.lock_open), + onPressed: saving.active + ? null + : () => saving + .run( + () => accountData.setIgnored( + userId, + ignored: false, + ), + ) + .onError(showError), + ), + ); + }, + ), + ), + ), + .new( + title: "Block User", + description: "Block a user by their ID.", + icon: Icons.block, + builder: (_, _, _) => BlockUserField(), + ), ]), ), .new( diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index 1a7949b..51176cd 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -25,6 +25,8 @@ class const AccountData({ final ImagePackRooms? legacyImagePackRooms, @JsonKey(name: AccountData.pushRulesKey) final PushRules pushRules = const .new(), + @JsonKey(name: AccountData.ignoredUserListKey) + final IgnoredUserList ignoredUserList = const .new(), }) with _$AccountData { static const invitePermissionConfigKey = "m.invite_permission_config"; static const directKey = "m.direct"; @@ -32,6 +34,7 @@ class const AccountData({ static const imagePackRoomsKey = "m.image_pack.rooms"; static const legacyImagePackRoomsKey = "im.ponies.emote_rooms"; static const pushRulesKey = "m.push_rules"; + static const ignoredUserListKey = "m.ignored_user_list"; ImagePackRooms get enabledImagePacks => imagePackRooms ?? legacyImagePackRooms ?? const .new(); @@ -89,6 +92,27 @@ class const ImagePackRooms({ _$ImagePackRoomsFromJson(json); } +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const IgnoredUserList({ + /// Blocked user IDs, each to a (currently empty) object + @JsonKey(name: "ignored_users") + final IMap> ignoredUsers = const .empty(), +}) with _$IgnoredUserList { + bool contains(String userId) => ignoredUsers.containsKey(userId); + + IgnoredUserList withUser(String userId, {required bool ignored}) => copyWith( + ignoredUsers: ignored + ? ignoredUsers.add(userId, const {}) + : ignoredUsers.remove(userId), + ); + + Map toJson() => _$IgnoredUserListToJson(this); + + factory IgnoredUserList.fromJson(Map json) => + _$IgnoredUserListFromJson(json); +} + @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const InvitePermissionConfig({ diff --git a/lib/widgets/settings/block_user_field.dart b/lib/widgets/settings/block_user_field.dart new file mode 100644 index 0000000..ad0a829 --- /dev/null +++ b/lib/widgets/settings/block_user_field.dart @@ -0,0 +1,66 @@ +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/controllers/client_state.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; + +/// Blocks a user by their ID, hiding their messages and invites. +class const BlockUserField({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final saving = Saving.use(); + final controller = useTextEditingController(); + final userId = useValueListenable(controller).text.trim(); + final blocked = ref.watch( + AccountDataController.provider.select( + (data) => data.ignoredUserList.contains(userId), + ), + ); + final ownId = ref.watch( + ClientStateController.provider.select((state) => state?.userId), + ); + + final error = userId.isEmpty || userId.isValidUserId + ? userId == ownId + ? "You can't block yourself" + : blocked + ? "Already blocked" + : null + : "Enter a user ID, like @user:example.com"; + final canBlock = userId.isNotEmpty && error == null && !saving.active; + + void block() { + if (!canBlock) return; + saving + .run(() async { + await ref + .read(AccountDataController.provider.notifier) + .setIgnored(userId, ignored: true); + if (context.mounted) controller.clear(); + }) + .onError(showError); + } + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 8).copyWith(top: 8), + child: TextField( + controller: controller, + enabled: !saving.active, + onSubmitted: (_) => block(), + decoration: .new( + labelText: "User ID", + hintText: "@user:example.com", + errorText: error, + suffixIcon: IconButton( + tooltip: "Block user", + icon: Icon(Icons.block), + onPressed: canBlock ? block : null, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 1e21581..3c34027 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -6,6 +6,7 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/mutual_rooms.dart"; @@ -17,6 +18,7 @@ import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; @@ -145,7 +147,65 @@ final class const UserBottomSheet( if (!editable) ...[ Row( mainAxisAlignment: .end, + spacing: 8, children: [ + if (userId != + ref.watch(ClientStateController.provider)?.userId) + Consumer( + builder: (context, ref, _) { + final ignored = ref.watch( + AccountDataController.provider.select( + (data) => data.ignoredUserList.contains(userId), + ), + ); + final name = member.displayName ?? userId.localpart; + Future setIgnored( + bool ignored, + ) => context.withProgressSnackbar( + "${ignored ? "Blocking" : "Unblocking"} $name...", + () => ref + .read(AccountDataController.provider.notifier) + .setIgnored(userId, ignored: ignored), + ); + + return M3EButton( + tooltip: ignored ? "Unblock" : "Block", + onPressed: ignored + ? () => setIgnored(false) + : () => context.showAdaptiveSheet( + (context) => SheetLayout( + title: Text("Block $name"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + setIgnored(true); + }, + style: TextButton.styleFrom( + foregroundColor: + theme.colorScheme.error, + ), + child: Text("Block"), + ), + ], + children: [ + Text( + "Are you sure you want to block $userId? You won't see their new messages or invites.", + ), + ], + ), + ), + style: .tonal, + child: Icon( + ignored ? Icons.lock_open : Icons.block, + ), + ); + }, + ), M3EButton( onPressed: Navigator.of(context).pop, child: Icon(Icons.close),