diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 2b67dada..46bfc448 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -705,5 +705,15 @@ "comment_tag_you": "YOU", "@comment_tag_you": {}, "code_of_conduct_clickthrough": "By accessing the Lemmy network using Liftoff! you agree to adhere to our Code of Conduct", - "@code_of_conduct_clickthrough": {} -} \ No newline at end of file + "@code_of_conduct_clickthrough": {}, + "instance_filter": "Instance filter", + "@instance_filter": {}, + "instance_filter_explanation": "A list of terms in names of instances you wish to block from all your post feeds. A good start is: nsfw porn yiff", + "@instance_filter_explanation": {}, + "instance_filter_add": "Add term", + "@instance_filter_add": {}, + "instance_filter_none": "No filters chosen", + "@instance_filter_none": {}, + "sensitive_content": "Sensitive Content", + "@sensitive_content": {} +} diff --git a/lib/pages/home_tab.dart b/lib/pages/home_tab.dart index 3c2a5457..838f2623 100644 --- a/lib/pages/home_tab.dart +++ b/lib/pages/home_tab.dart @@ -11,6 +11,7 @@ import '../hooks/memo_future.dart'; import '../hooks/stores.dart'; import '../l10n/l10n.dart'; import '../stores/config_store.dart'; +import '../util/extensions/api.dart'; import '../util/goto.dart'; import '../widgets/bottom_modal.dart'; import '../widgets/cached_network_image.dart'; @@ -366,6 +367,8 @@ class InfiniteHomeList extends HookWidget { @override Widget build(BuildContext context) { final accStore = useAccountsStore(); + final instanceFilter = + useStore((ConfigStore store) => store.instanceFilter); /// fetches post from many instances at once and combines them into a single /// list @@ -401,27 +404,46 @@ class InfiniteHomeList extends HookWidget { final instancePosts = await Future.wait(futures); final longest = instancePosts.map((e) => e.length).reduce(max); - final newPosts = [ + final unfilteredPosts = [ for (var i = 0; i < longest; i++) for (final posts in instancePosts) if (i < posts.length) posts[i] ]; + // We assume here that the total list even filtered will be longer + // than `limit` posts long. If not then the lists ends here. - return newPosts; + final filtered = unfilteredPosts.where((e) => instanceFilter.every((b) => + !e.postView.community.originInstanceHost.toLowerCase().contains(b))); + + return filtered.toList(); } - FetcherWithSorting fetcherFromInstance( - String instanceHost, PostListingType listingType) => - (page, batchSize, sort) => LemmyApiV3(instanceHost) - .run(GetPosts( - type: listingType, - sort: sort, - page: page, - limit: batchSize, - savedOnly: false, - auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw, - )) - .toPostStores(); + Future> fetcherFromInstance( + int page, + int limit, + SortType sort, + String instanceHost, + PostListingType listingType, + ) async { + // Get twice as many as we need, so we will keep the pipeline full, + // unless the user has blocked 'lemmy', in which case their + // feed will end early. + final limitWithBans = instanceFilter.isEmpty ? limit : 2 * limit; + final unfilteredPosts = await LemmyApiV3(instanceHost) + .run(GetPosts( + type: listingType, + sort: sort, + page: page, + limit: limitWithBans, + savedOnly: false, + auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw, + )) + .toPostStores(); + final filtered = unfilteredPosts.where((e) => instanceFilter.every((b) => + !e.postView.community.originInstanceHost.toLowerCase().contains(b))); + + return filtered.toList(); + } final memoizedFetcher = useMemoized( () { @@ -429,7 +451,7 @@ class InfiniteHomeList extends HookWidget { return selectedInstanceHost == null ? (page, limit, sort) => generalFetcher(page, limit, sort, selectedList.listingType) - : fetcherFromInstance( + : (page, limit, sort) => fetcherFromInstance(page, limit, sort, selectedInstanceHost, selectedList.listingType); }, [selectedList], diff --git a/lib/pages/settings/instance_filter_setting_widget.dart b/lib/pages/settings/instance_filter_setting_widget.dart new file mode 100644 index 00000000..45299b38 --- /dev/null +++ b/lib/pages/settings/instance_filter_setting_widget.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../../l10n/gen/l10n.dart'; +import '../../stores/config_store.dart'; +import '../../util/observer_consumers.dart'; + +/// Allows user to manage their instance filter list. +/// Creates its own reference to the ConfigStore, so can be +/// called easily from e.g. context menus if this is wanted. +class InstanceFilterSettingWidget extends HookWidget { + const InstanceFilterSettingWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final focusNode = useFocusNode(); + final controller = useTextEditingController(); + final termIsValid = useListenableSelector(controller, + () => controller.text.isNotEmpty && !controller.text.contains(' ')); + + updateFilter(ConfigStore store, String term) { + if (!store.instanceFilter.contains(term.toLowerCase())) { + // Create a new List as in-place modifications don't get notified. + store.instanceFilter = store.instanceFilter.toList() + ..add(term.toLowerCase()); + } + controller.clear(); + } + + final emptyListMessage = Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: Text(L10n.of(context).instance_filter_none, + style: const TextStyle( + fontStyle: FontStyle.italic, + ))); + + return ObserverBuilder(builder: (context, store) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(L10n.of(context).instance_filter, + style: TextStyle(fontSize: store.commentTitleSize)), + Text(L10n.of(context).instance_filter_explanation), + if (store.instanceFilter.isEmpty) + emptyListMessage + else + Wrap( + spacing: 5, + children: store.instanceFilter + .map((e) => InputChip( + label: Text(e), + // Create a new List as in-place + // modifications don't get notified. + onDeleted: () => store.instanceFilter = + store.instanceFilter.toList()..remove(e), + )) + .toList(), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Padding( + padding: const EdgeInsets.only(top: 5), + child: TextField( + maxLength: 10, + controller: controller, + focusNode: focusNode, + onEditingComplete: () => + updateFilter(store, controller.text), + ), + ), + ), + const SizedBox(width: 15), + ElevatedButton( + onPressed: termIsValid + ? () => updateFilter(store, controller.text) + : null, + child: Text(L10n.of(context).instance_filter_add)) + ], + ), + ], + ), + ); + }); + } +} diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index e8a29398..33805a25 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -24,6 +24,7 @@ import '../manage_account.dart'; import 'add_account_page.dart'; import 'add_instance_page.dart'; import 'blocks/blocks.dart'; +import 'instance_filter_setting_widget.dart'; import 'mock_post.dart'; /// Page with a list of different settings sections @@ -619,149 +620,155 @@ class GeneralConfigPage extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(L10n.of(context).general)), body: ObserverBuilder( - builder: (context, store) => ListView( - children: [ - ListTile( - title: Text(L10n.of(context).sort_type), - trailing: SizedBox( - width: 120, - child: RadioPicker( - values: SortType.values, - groupValue: store.defaultSortType, - onChanged: (value) => store.defaultSortType = value, - mapValueToString: (value) => value.value, - buttonBuilder: (context, displayValue, onPressed) => - FilledButton( - onPressed: onPressed, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(displayValue), - const Icon(Icons.arrow_drop_down), - ], + builder: (context, store) { + return ListView( + children: [ + ListTile( + title: Text(L10n.of(context).sort_type), + trailing: SizedBox( + width: 120, + child: RadioPicker( + values: SortType.values, + groupValue: store.defaultSortType, + onChanged: (value) => store.defaultSortType = value, + mapValueToString: (value) => value.value, + buttonBuilder: (context, displayValue, onPressed) => + FilledButton( + onPressed: onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(displayValue), + const Icon(Icons.arrow_drop_down), + ], + ), ), ), ), ), - ), - ListTile( - title: Text(L10n.of(context).comment_sort_type), - trailing: SizedBox( - width: 120, - child: RadioPicker( - values: CommentSortType.values - .sublist(0, CommentSortType.values.length - 1), - groupValue: store.defaultCommentSort, - onChanged: (value) => store.defaultCommentSort = value, - mapValueToString: (value) => value.value, - buttonBuilder: (context, displayValue, onPressed) => - FilledButton( - onPressed: onPressed, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(displayValue), - const Icon(Icons.arrow_drop_down), - ], + ListTile( + title: Text(L10n.of(context).comment_sort_type), + trailing: SizedBox( + width: 120, + child: RadioPicker( + values: CommentSortType.values + .sublist(0, CommentSortType.values.length - 1), + groupValue: store.defaultCommentSort, + onChanged: (value) => store.defaultCommentSort = value, + mapValueToString: (value) => value.value, + buttonBuilder: (context, displayValue, onPressed) => + FilledButton( + onPressed: onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(displayValue), + const Icon(Icons.arrow_drop_down), + ], + ), ), ), ), ), - ), - ListTile( - title: Text(L10n.of(context).type), - trailing: SizedBox( - width: 120, - child: RadioPicker( - values: const [ - PostListingType.all, - PostListingType.local, - PostListingType.subscribed, - ], - groupValue: store.defaultListingType, - onChanged: (value) => store.defaultListingType = value, - mapValueToString: (value) => value.value, - buttonBuilder: (context, displayValue, onPressed) => - FilledButton( - onPressed: onPressed, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(displayValue), - const Icon(Icons.arrow_drop_down), - ], + ListTile( + title: Text(L10n.of(context).type), + trailing: SizedBox( + width: 120, + child: RadioPicker( + values: const [ + PostListingType.all, + PostListingType.local, + PostListingType.subscribed, + ], + groupValue: store.defaultListingType, + onChanged: (value) => store.defaultListingType = value, + mapValueToString: (value) => value.value, + buttonBuilder: (context, displayValue, onPressed) => + FilledButton( + onPressed: onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(displayValue), + const Icon(Icons.arrow_drop_down), + ], + ), ), ), ), ), - ), - ListTile( - title: Text(L10n.of(context).language), - trailing: SizedBox( - width: 120, - child: RadioPicker( - title: L10n.of(context).choose_language, - groupValue: store.locale, - values: L10n.supportedLocales, - mapValueToString: (locale) => locale.languageName, - onChanged: (selected) { - store.locale = selected; - }, - buttonBuilder: (context, displayValue, onPressed) => - FilledButton( - onPressed: onPressed, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(displayValue), - const Icon(Icons.arrow_drop_down), - ], + ListTile( + title: Text(L10n.of(context).language), + trailing: SizedBox( + width: 120, + child: RadioPicker( + title: L10n.of(context).choose_language, + groupValue: store.locale, + values: L10n.supportedLocales, + mapValueToString: (locale) => locale.languageName, + onChanged: (selected) { + store.locale = selected; + }, + buttonBuilder: (context, displayValue, onPressed) => + FilledButton( + onPressed: onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(displayValue), + const Icon(Icons.arrow_drop_down), + ], + ), ), ), ), ), - ), - SwitchListTile.adaptive( - title: Text(L10n.of(context).show_everything_feed), - subtitle: Text(L10n.of(context).show_everything_feed_explanation), - value: store.showEverythingFeed, - onChanged: (checked) { - store.showEverythingFeed = checked; - }, - ), - SwitchListTile.adaptive( - title: Text(L10n.of(context).use_in_app_browser), - value: store.useInAppBrowser, - onChanged: (checked) { - store.useInAppBrowser = checked; - }, - ), - SwitchListTile.adaptive( - title: Text(L10n.of(context).convert_webp_to_png), - value: store.convertWebpToPng, - onChanged: (checked) { - store.convertWebpToPng = checked; - }, - ), - const SizedBox(height: 12), - _SectionHeading(L10n.of(context).other_settings), - SwitchListTile.adaptive( - title: Text(L10n.of(context).disable_animations), - value: store.disableAnimations, - onChanged: (checked) { - store.disableAnimations = checked; - }, - ), - SwitchListTile.adaptive( - title: Text(L10n.of(context).hide_nsfw), - subtitle: Text(L10n.of(context).hide_nsfw_explanation), - value: store.blurNsfw, - onChanged: (checked) { - store.blurNsfw = checked; - }, - ), - ], - ), + SwitchListTile.adaptive( + title: Text(L10n.of(context).show_everything_feed), + subtitle: + Text(L10n.of(context).show_everything_feed_explanation), + value: store.showEverythingFeed, + onChanged: (checked) { + store.showEverythingFeed = checked; + }, + ), + SwitchListTile.adaptive( + title: Text(L10n.of(context).use_in_app_browser), + value: store.useInAppBrowser, + onChanged: (checked) { + store.useInAppBrowser = checked; + }, + ), + SwitchListTile.adaptive( + title: Text(L10n.of(context).convert_webp_to_png), + value: store.convertWebpToPng, + onChanged: (checked) { + store.convertWebpToPng = checked; + }, + ), + SwitchListTile.adaptive( + title: Text(L10n.of(context).disable_animations), + value: store.disableAnimations, + onChanged: (checked) { + store.disableAnimations = checked; + }, + ), + const SizedBox(height: 12), + _SectionHeading(L10n.of(context).sensitive_content), + SwitchListTile.adaptive( + title: Text(L10n.of(context).hide_nsfw), + subtitle: Text(L10n.of(context).hide_nsfw_explanation), + value: store.blurNsfw, + onChanged: (checked) { + store.blurNsfw = checked; + }, + ), + + // Instance Filter setting + const InstanceFilterSettingWidget(), + ], + ); + }, ), ); } diff --git a/lib/stores/config_store.dart b/lib/stores/config_store.dart index d8c6d052..619a0ac6 100644 --- a/lib/stores/config_store.dart +++ b/lib/stores/config_store.dart @@ -82,6 +82,10 @@ abstract class _ConfigStore with Store { @JsonKey(defaultValue: true) bool blurNsfw = true; + @observable + @JsonKey(defaultValue: []) + List instanceFilter = []; + @observable @JsonKey(defaultValue: true) bool showThumbnail = true; diff --git a/lib/stores/config_store.g.dart b/lib/stores/config_store.g.dart index 0818500d..ebfe0da1 100644 --- a/lib/stores/config_store.g.dart +++ b/lib/stores/config_store.g.dart @@ -17,6 +17,10 @@ ConfigStore _$ConfigStoreFromJson(Map json) => ConfigStore() ..showAvatars = json['showAvatars'] as bool? ?? true ..showScores = json['showScores'] as bool? ?? true ..blurNsfw = json['blurNsfw'] as bool? ?? true + ..instanceFilter = (json['instanceFilter'] as List?) + ?.map((e) => e as String) + .toList() ?? + [] ..showThumbnail = json['showThumbnail'] as bool? ?? true ..autoPlayVideo = json['autoPlayVideo'] as bool? ?? true ..autoMuteVideo = json['autoMuteVideo'] as bool? ?? true @@ -48,6 +52,7 @@ Map _$ConfigStoreToJson(ConfigStore instance) => 'showAvatars': instance.showAvatars, 'showScores': instance.showScores, 'blurNsfw': instance.blurNsfw, + 'instanceFilter': instance.instanceFilter, 'showThumbnail': instance.showThumbnail, 'autoPlayVideo': instance.autoPlayVideo, 'autoMuteVideo': instance.autoMuteVideo, @@ -232,6 +237,22 @@ mixin _$ConfigStore on _ConfigStore, Store { }); } + late final _$instanceFilterAtom = + Atom(name: '_ConfigStore.instanceFilter', context: context); + + @override + List get instanceFilter { + _$instanceFilterAtom.reportRead(); + return super.instanceFilter; + } + + @override + set instanceFilter(List value) { + _$instanceFilterAtom.reportWrite(value, super.instanceFilter, () { + super.instanceFilter = value; + }); + } + late final _$showThumbnailAtom = Atom(name: '_ConfigStore.showThumbnail', context: context); @@ -509,6 +530,7 @@ postCardShadowV2: ${postCardShadowV2}, showAvatars: ${showAvatars}, showScores: ${showScores}, blurNsfw: ${blurNsfw}, +instanceFilter: ${instanceFilter}, showThumbnail: ${showThumbnail}, autoPlayVideo: ${autoPlayVideo}, autoMuteVideo: ${autoMuteVideo},