Skip to content

Commit

Permalink
Ability to filter posts containing keyword in title/body (#974)
Browse files Browse the repository at this point in the history
* initial work on keyword filtering

* applied filtering to post title and body

* cleaned up logic for input dialog

* minor linting

* checks for empty strings, and trimming whitespace

* moved filter description to top of page

* updated naming for dialog context
  • Loading branch information
hjiangsu authored Dec 13, 2023
1 parent b51feff commit 9e75282
Show file tree
Hide file tree
Showing 13 changed files with 328 additions and 97 deletions.
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()));
}).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,
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

0 comments on commit 9e75282

Please sign in to comment.