Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to filter posts containing keyword in title/body #974

Merged
merged 9 commits into from
Dec 13, 2023
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Added the ability to render SVGs in markdown bodies - contribution from @micahmo
- Added Safari extension to open Lemmy links in Thunder
- Added support for displaying comment origin instance - contribution from @ggichure.
- Aded ability to set keywords for filtering post titles/body

## Changed
- Added new items to the Post and Comment actions sheet - contribution from @micahmo
Expand Down
1 change: 1 addition & 0 deletions lib/core/enums/local_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ enum LocalSettings {
dimReadPosts(name: 'setting_dim_read_posts', label: 'Dim Read Posts'),
useAdvancedShareSheet(name: 'setting_use_advanced_share_sheet', label: 'Use Advanced Share Sheet'),
showCrossPosts(name: 'setting_show_cross_posts', label: 'Show Cross-Posts'),
keywordFilters(name: 'setting_general_keyword_filters', label: ''),

// Advanced Settings
userFormat(name: 'user_format', label: ''),
Expand Down
17 changes: 17 additions & 0 deletions lib/feed/utils/post.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import 'package:lemmy_api_client/v3.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'package:thunder/account/models/account.dart';
import 'package:thunder/core/auth/helpers/fetch_account.dart';
import 'package:thunder/core/enums/local_settings.dart';
import 'package:thunder/core/models/post_view_media.dart';
import 'package:thunder/core/singletons/lemmy_client.dart';
import 'package:thunder/core/singletons/preferences.dart';
import 'package:thunder/post/utils/post.dart';

/// Helper function which handles the logic of fetching posts from the API
Expand All @@ -19,6 +23,9 @@ Future<Map<String, dynamic>> fetchPosts({
Account? account = await fetchActiveProfileAccount();
LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3;

SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences;
List<String> keywordFilters = prefs.getStringList(LocalSettings.keywordFilters.name) ?? [];

bool hasReachedEnd = false;

List<PostViewMedia> postViewMedias = [];
Expand All @@ -39,6 +46,16 @@ Future<Map<String, dynamic>> fetchPosts({
// Remove deleted posts
getPostsResponse = getPostsResponse.copyWith(posts: getPostsResponse.posts.where((PostView postView) => postView.post.deleted == false).toList());

// Remove posts that contain any of the keywords in the title or body
getPostsResponse = getPostsResponse.copyWith(
posts: getPostsResponse.posts.where((postView) {
final title = postView.post.name.toLowerCase();
final body = postView.post.body?.toLowerCase() ?? '';

return !keywordFilters.any((keyword) => title.contains(keyword.toLowerCase()) || body.contains(keyword.toLowerCase()));
micahmo marked this conversation as resolved.
Show resolved Hide resolved
}).toList(),
);

// Parse the posts and add in media information which is used elsewhere in the app
List<PostViewMedia> formattedPosts = await parsePostViews(getPostsResponse.posts);
postViewMedias.addAll(formattedPosts);
Expand Down
42 changes: 33 additions & 9 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
"@addAccountToSeeProfile": {},
"addAnonymousInstance": "Add Anonymous Instance",
"@addAnonymousInstance": {},
"addKeywordFilter": "Add Keyword",
"@addKeywordFilter": {
"description": "Hint for text field to add keyword"
},
"addedCommunityToSubscriptions": "Added community to subscriptions",
"@addedCommunityToSubscriptions": {},
"advanced": "Advanced",
Expand Down Expand Up @@ -144,6 +148,10 @@
"@commentReported": {},
"commentSavedAsDraft": "Comment saved as draft",
"@commentSavedAsDraft": {},
"commentShowUserInstance": "Show User Instance",
"@commentShowUserInstance": {
"description": "Settings toggle to display user instance alongside their display name in comments"
},
"commentSortType": "Comment Sort Type",
"@commentSortType": {},
"commentSwipeGesturesHint": "Looking to use buttons instead? Enable them in the comments section in general settings.",
Expand All @@ -169,14 +177,14 @@
"@compactViewSettings": {
"description": "Subcategory in Setting -> Appearance -> Posts"
},
"confirmLogOutTitle": "Confirm Log Out",
"@confirmLogOutTitle": {
"description": "The title of the confirm logout dialog"
},
"confirmLogOutBody": "Are you sure you want to log out?",
"@confirmLogOutBody": {
"description": "The body of the confirm logout dialog"
},
"confirmLogOutTitle": "Confirm Log Out",
"@confirmLogOutTitle": {
"description": "The title of the confirm logout dialog"
},
"confirmResetCommentPreferences": "This will reset all comment preferences. Are you sure you want to proceed?",
"@confirmResetCommentPreferences": {
"description": "Description for reset preferences dialog in Setting -> Appearance -> Comments"
Expand Down Expand Up @@ -401,6 +409,14 @@
"@instanceHasAlreadyBenAdded": {},
"internetOrInstanceIssues": "You may not be connected to the Internet, or your instance may be currently unavailable.",
"@internetOrInstanceIssues": {},
"keywordFilterDescription": "Filters posts containing any keywords in the title or body",
"@keywordFilterDescription": {
"description": "Description of keyword filter settings"
},
"keywordFilters": "Keyword Filters",
"@keywordFilters": {
"description": "Subcategory in Setting -> General"
},
"language": "Language",
"@language": {
"description": "Label when creating or editing a post."
Expand Down Expand Up @@ -470,6 +486,10 @@
"@noCommunityBlocks": {},
"noInstanceBlocks": "No blocked instances.",
"@noInstanceBlocks": {},
"noKeywordFilters": "No keyword filters added",
"@noKeywordFilters": {
"description": "Message for no keywords added"
},
"noLanguage": "No language",
"@noLanguage": {
"description": "The entry for no language when selecting a post language"
Expand Down Expand Up @@ -591,6 +611,14 @@
"@removeAccount": {},
"removeInstance": "Remove instance",
"@removeInstance": {},
"removeKeyword": "Remove \"{keyword}\"?",
"@removeKeyword": {
"description": "Description of removing a keyword"
},
"removeKeywordFilter": "Remove Keyword",
"@removeKeywordFilter": {
"description": "Title for dialig to remove keyword"
},
"removedCommunityFromSubscriptions": "Unsubscribed from community",
"@removedCommunityFromSubscriptions": {},
"reply": "{count, plural, zero {Reply} one {Reply} other {Replies} }",
Expand Down Expand Up @@ -893,9 +921,5 @@
"description": "The total score of post or comment"
},
"xUpvotes": "{x} upvotes",
"@xUpvotes": {},
"commentShowUserInstance":"Show User Instance",
"@commentShowUserInstance":{
"description":"Settings toggle to display user instance alongside their display name in comments"
}
"@xUpvotes": {}
}
11 changes: 11 additions & 0 deletions lib/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:thunder/settings/pages/appearance_settings_page.dart';
import 'package:thunder/settings/pages/comment_appearance_settings_page.dart';
import 'package:thunder/settings/pages/debug_settings_page.dart';
import 'package:thunder/settings/pages/fab_settings_page.dart';
import 'package:thunder/settings/pages/filter_settings_page.dart';
import 'package:thunder/settings/pages/general_settings_page.dart';
import 'package:thunder/settings/pages/gesture_settings_page.dart';
import 'package:thunder/settings/pages/post_appearance_settings_page.dart';
Expand Down Expand Up @@ -43,6 +44,16 @@ final GoRouter router = GoRouter(
);
},
),
GoRoute(
name: 'filters',
path: 'filters',
builder: (context, state) {
return BlocProvider.value(
value: state.extra! as ThunderBloc,
child: const FilterSettingsPage(),
);
},
),
GoRoute(
name: 'appearance',
path: 'appearance',
Expand Down
149 changes: 149 additions & 0 deletions lib/settings/pages/filter_settings_page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

import 'package:thunder/core/enums/local_settings.dart';
import 'package:thunder/core/singletons/preferences.dart';
import 'package:thunder/settings/widgets/settings_list_tile.dart';
import 'package:thunder/shared/dialogs.dart';
import 'package:thunder/shared/input_dialogs.dart';
import 'package:thunder/thunder/bloc/thunder_bloc.dart';

class FilterSettingsPage extends StatefulWidget {
const FilterSettingsPage({super.key});

@override
State<FilterSettingsPage> createState() => _FilterSettingsPageState();
}

class _FilterSettingsPageState extends State<FilterSettingsPage> with SingleTickerProviderStateMixin {
/// The list of keyword filters to apply for posts
List<String> keywordFilters = [];

void setPreferences(attribute, value) async {
final prefs = (await UserPreferences.instance).sharedPreferences;

switch (attribute) {
case LocalSettings.keywordFilters:
await prefs.setStringList(LocalSettings.keywordFilters.name, value);
setState(() => keywordFilters = value);
break;
}

if (context.mounted) {
context.read<ThunderBloc>().add(UserPreferencesChangeEvent());
}
}

void _initPreferences() async {
final prefs = (await UserPreferences.instance).sharedPreferences;

setState(() {
keywordFilters = prefs.getStringList(LocalSettings.keywordFilters.name) ?? [];
});
}

@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _initPreferences());
}

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;

return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
title: Text(l10n.filters),
centerTitle: false,
toolbarHeight: 70.0,
pinned: true,
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0, bottom: 8.0),
child: Text(
l10n.keywordFilterDescription,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.8),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.keywordFilters, style: theme.textTheme.titleMedium),
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(
Icons.add_rounded,
semanticLabel: l10n.add,
),
onPressed: () => showKeywordInputDialog(
context,
title: l10n.addKeywordFilter,
onKeywordSelected: (keyword) {
setPreferences(LocalSettings.keywordFilters, [...keywordFilters, keyword]);
},
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
child: Align(
alignment: Alignment.centerLeft,
child: keywordFilters.isEmpty
? Text(
l10n.noKeywordFilters,
micahmo marked this conversation as resolved.
Show resolved Hide resolved
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.8),
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 20),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: keywordFilters.length,
itemBuilder: (context, index) {
return SettingsListTile(
description: keywordFilters[index],
widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)),
onTap: () async {
showThunderDialog(
context: context,
title: l10n.removeKeywordFilter,
contentText: l10n.removeKeyword(keywordFilters[index]),
primaryButtonText: l10n.remove,
onPrimaryButtonPressed: (dialogContext, setPrimaryButtonEnabled) {
setPreferences(LocalSettings.keywordFilters, keywordFilters.where((element) => element != keywordFilters[index]).toList());
Navigator.of(dialogContext).pop();
},
secondaryButtonText: l10n.cancel,
onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(),
);
},
);
},
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 128.0)),
],
),
);
}
}
1 change: 1 addition & 0 deletions lib/settings/pages/settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class SettingsPage extends StatelessWidget {

final List<SettingTopic> topics = [
SettingTopic(title: l10n.general, icon: Icons.settings, path: '/settings/general'),
SettingTopic(title: l10n.filters, icon: Icons.filter_alt_rounded, path: '/settings/filters'),
SettingTopic(title: l10n.appearance, icon: Icons.color_lens_rounded, path: '/settings/appearance'),
SettingTopic(title: l10n.gestures, icon: Icons.swipe, path: '/settings/gestures'),
SettingTopic(title: l10n.floatingActionButton, icon: Icons.settings_applications_rounded, path: '/settings/fab'),
Expand Down
11 changes: 10 additions & 1 deletion lib/shared/dialogs.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math';

import 'package:flutter/material.dart';

Future<T?> showThunderDialog<T>({
Expand Down Expand Up @@ -29,7 +31,14 @@ Future<T?> showThunderDialog<T>({
return StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(title),
content: contentText != null ? Text(contentText) : contentWidgetBuilder!((enabled) => setState(() => primaryButtonEnabled = enabled)),
content: SizedBox(
width: min(MediaQuery.of(context).size.width, 700),
child: contentText != null
? Text(contentText)
: contentWidgetBuilder!(
(enabled) => setState(() => primaryButtonEnabled = enabled),
),
),
actions: [
if (secondaryButtonText != null)
TextButton(
Expand Down
Loading
Loading