diff --git a/lib/controls/standard_app_drawer.dart b/lib/controls/standard_app_drawer.dart index 23eafa3..4c57eb4 100644 --- a/lib/controls/standard_app_drawer.dart +++ b/lib/controls/standard_app_drawer.dart @@ -79,6 +79,11 @@ class StandardAppDrawer extends StatelessWidget { 'Blocks', () => context.pushNamed(ScreenPaths.blocks), ), + buildMenuButton( + context, + 'Filters', + () => context.pushNamed(ScreenPaths.filters), + ), buildMenuButton( context, 'Groups Management', diff --git a/lib/controls/timeline/flattened_tree_entry_control.dart b/lib/controls/timeline/flattened_tree_entry_control.dart index 0c6de90..bb46d77 100644 --- a/lib/controls/timeline/flattened_tree_entry_control.dart +++ b/lib/controls/timeline/flattened_tree_entry_control.dart @@ -1,13 +1,18 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; import '../../globals.dart'; +import '../../models/filters/timeline_entry_filter.dart'; import '../../models/flattened_tree_item.dart'; import '../../models/timeline_entry.dart'; +import '../../services/timeline_entry_filter_service.dart'; import '../../services/timeline_manager.dart'; import '../../utils/active_profile_selector.dart'; import '../../utils/clipboard_utils.dart'; +import '../../utils/filter_runner.dart'; import '../../utils/html_to_edit_text_helper.dart'; import '../../utils/responsive_sizes_calculator.dart'; import '../../utils/url_opening_utils.dart'; @@ -37,8 +42,9 @@ class _StatusControlState extends State { static final _logger = Logger('$FlattenedTreeEntryControl'); var showContent = true; - + var showFilteredPost = false; var showComments = false; + var isProcessing = false; FlattenedTreeItem get item => widget.originalItem; @@ -48,10 +54,11 @@ class _StatusControlState extends State { bool get hasComments => entry.engagementSummary.repliesCount > 0; - bool isProcessing = false; + var filteringInfo = FilterResult.show; @override void initState() { + super.initState(); showContent = entry.spoilerText.isEmpty; showComments = isPost ? false : true; } @@ -59,12 +66,37 @@ class _StatusControlState extends State { @override Widget build(BuildContext context) { _logger.finest('Building ${entry.toShortString()}'); + final filterService = context + .watch>() + .activeEntry + .value; + + filteringInfo = filterService.checkTimelineEntry(entry); + const otherPadding = 8.0; final leftPadding = otherPadding + (widget.originalItem.level * 15.0); final color = widget.originalItem.level.isOdd ? Theme.of(context).secondaryHeaderColor : Theme.of(context).dialogBackgroundColor; - final body = Container( + + if (filteringInfo.isFiltered && + filteringInfo.action == TimelineEntryFilterAction.hide) { + return kReleaseMode + ? const SizedBox() + : Container( + height: 10, + color: Colors.red, + ); + } + + late final Widget body; + if (filteringInfo.isFiltered && !showFilteredPost) { + body = buildHiddenBody(context, filteringInfo); + } else { + body = buildMainWidgetBody(context); + } + + final bodyCard = Container( decoration: BoxDecoration( color: color, border: Border.all(width: 0.5), @@ -73,63 +105,13 @@ class _StatusControlState extends State { BoxShadow( color: Theme.of(context).dividerColor, blurRadius: 2, - offset: Offset(4, 4), + offset: const Offset(4, 4), spreadRadius: 0.1, blurStyle: BlurStyle.normal, ) ], ), - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: StatusHeaderControl( - entry: entry, - ), - ), - buildMenuControl(context), - ], - ), - const VerticalPadding( - height: 5, - ), - if (entry.spoilerText.isNotEmpty) - TextButton( - onPressed: () { - setState(() { - showContent = !showContent; - }); - }, - child: Text( - 'Content Summary: ${entry.spoilerText} (Click to ${showContent ? "Hide" : "Show"}}')), - if (showContent) ...[ - buildBody(context), - const VerticalPadding( - height: 5, - ), - if (entry.linkPreviewData != null) - LinkPreviewControl(preview: entry.linkPreviewData!), - buildMediaBar(context), - ], - const VerticalPadding( - height: 5, - ), - InteractionsBarControl( - entry: entry, - isMine: item.isMine, - showOpenControl: widget.showStatusOpenButton, - ), - const VerticalPadding( - height: 5, - ), - ], - ), - ), + child: body, ); return Padding( padding: EdgeInsets.only( @@ -138,11 +120,94 @@ class _StatusControlState extends State { top: otherPadding, bottom: otherPadding, ), - child: body, + child: bodyCard, ); } - Widget buildBody(BuildContext context) { + Widget buildMainWidgetBody(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(5.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: StatusHeaderControl( + entry: entry, + ), + ), + if (filteringInfo.isFiltered) + IconButton( + onPressed: () => setState(() { + showFilteredPost = false; + }), + icon: const Icon(Icons.hide_source)), + buildMenuControl(context), + ], + ), + const VerticalPadding( + height: 5, + ), + if (entry.spoilerText.isNotEmpty) + TextButton( + onPressed: () { + setState(() { + showContent = !showContent; + }); + }, + child: Text( + 'Content Summary: ${entry.spoilerText} (Click to ${showContent ? "Hide" : "Show"}}')), + if (showContent) ...[ + buildContentField(context), + const VerticalPadding( + height: 5, + ), + if (entry.linkPreviewData != null) + LinkPreviewControl(preview: entry.linkPreviewData!), + buildMediaBar(context), + ], + const VerticalPadding( + height: 5, + ), + InteractionsBarControl( + entry: entry, + isMine: item.isMine, + showOpenControl: widget.showStatusOpenButton, + ), + const VerticalPadding( + height: 5, + ), + ], + ), + ); + } + + Widget buildHiddenBody(BuildContext context, FilterResult result) { + return Padding( + padding: const EdgeInsets.all(5.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (result.isFiltered && + result.action == TimelineEntryFilterAction.warn) + TextButton( + onPressed: () { + setState(() { + showFilteredPost = true; + }); + }, + child: Text( + '${result.trippingFilterName} filtered post. Click to show'), + ), + ], + ), + ); + } + + Widget buildContentField(BuildContext context) { return HtmlTextViewerControl( content: entry.body, onTapUrl: (url) async => diff --git a/lib/di_initialization.dart b/lib/di_initialization.dart index 5f61aac..29cbd28 100644 --- a/lib/di_initialization.dart +++ b/lib/di_initialization.dart @@ -31,6 +31,7 @@ import 'services/notifications_manager.dart'; import 'services/persistent_info_service.dart'; import 'services/secrets_service.dart'; import 'services/setting_service.dart'; +import 'services/timeline_entry_filter_service.dart'; import 'services/timeline_manager.dart'; import 'update_timer_initialization.dart'; import 'utils/active_profile_selector.dart'; @@ -48,6 +49,16 @@ Future dependencyInjectionInitialization() async { }, ), ); + + getIt.registerSingleton>( + ActiveProfileSelector( + (profile) { + final profilePersistencePath = + p.join(appSupportdir.path, '${profile.id}_filters.json'); + return TimelineEntryFilterService(profilePersistencePath)..load(); + }, + ), + ); final objectBoxCache = await ObjectBoxCache.create(); getIt.registerSingleton(objectBoxCache); getIt.registerSingleton(ObjectBoxHashtagRepo()); diff --git a/lib/main.dart b/lib/main.dart index 684adac..481ff5d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'services/hashtag_service.dart'; import 'services/interactions_manager.dart'; import 'services/notifications_manager.dart'; import 'services/setting_service.dart'; +import 'services/timeline_entry_filter_service.dart'; import 'services/timeline_manager.dart'; import 'utils/active_profile_selector.dart'; import 'utils/app_scrolling_behavior.dart'; @@ -115,6 +116,11 @@ class App extends StatelessWidget { ChangeNotifierProvider>( create: (_) => getIt>(), ), + ChangeNotifierProvider< + ActiveProfileSelector>( + create: (_) => + getIt>(), + ), ], child: MaterialApp.router( useInheritedMediaQuery: true, diff --git a/lib/models/filters/string_filter.dart b/lib/models/filters/string_filter.dart new file mode 100644 index 0000000..eeb32e7 --- /dev/null +++ b/lib/models/filters/string_filter.dart @@ -0,0 +1,35 @@ +enum ComparisonType { + contains, + containsIgnoreCase, + endsWithIgnoreCase, + equals, + equalsIgnoreCase, + ; + + factory ComparisonType.parse(String? value) { + return ComparisonType.values.firstWhere( + (v) => v.name == value, + orElse: () => equals, + ); + } +} + +class StringFilter { + final String filterString; + final ComparisonType type; + + const StringFilter({ + required this.filterString, + required this.type, + }); + + Map toJson() => { + 'filterString': filterString, + 'type': type.name, + }; + + factory StringFilter.fromJson(Map json) => StringFilter( + filterString: json['filterString'], + type: ComparisonType.parse(json['type']), + ); +} diff --git a/lib/models/filters/timeline_entry_filter.dart b/lib/models/filters/timeline_entry_filter.dart new file mode 100644 index 0000000..a57887b --- /dev/null +++ b/lib/models/filters/timeline_entry_filter.dart @@ -0,0 +1,160 @@ +import 'package:uuid/uuid.dart'; + +import '../connection.dart'; +import 'string_filter.dart'; + +enum TimelineEntryFilterAction { + hide, + warn, + ; + + factory TimelineEntryFilterAction.parse(String? value) { + return TimelineEntryFilterAction.values.firstWhere( + (v) => v.name == value, + orElse: () => warn, + ); + } + + String toLabel() { + switch (this) { + case TimelineEntryFilterAction.hide: + return 'Hide'; + case TimelineEntryFilterAction.warn: + return 'Warn'; + } + } + + String toVerb() { + switch (this) { + case TimelineEntryFilterAction.hide: + return 'Hiding'; + case TimelineEntryFilterAction.warn: + return 'Warning'; + } + } +} + +class TimelineEntryFilter { + final String id; + final TimelineEntryFilterAction action; + final String name; + final bool enabled; + final List authorFilters; + final List domainFilters; + final List keywordFilters; + final List hashtagFilters; + + const TimelineEntryFilter({ + required this.id, + required this.action, + required this.name, + required this.enabled, + required this.authorFilters, + required this.domainFilters, + required this.keywordFilters, + required this.hashtagFilters, + }); + + TimelineEntryFilter copy({ + String? id, + TimelineEntryFilterAction? action, + String? name, + bool? enabled, + List? authorFilters, + List? domainFilters, + List? keywordFilters, + List? hashtagFilters, + }) => + TimelineEntryFilter( + id: id ?? this.id, + action: action ?? this.action, + name: name ?? this.name, + enabled: enabled ?? this.enabled, + authorFilters: authorFilters ?? this.authorFilters, + domainFilters: domainFilters ?? this.domainFilters, + keywordFilters: keywordFilters ?? this.keywordFilters, + hashtagFilters: hashtagFilters ?? this.hashtagFilters, + ); + + factory TimelineEntryFilter.create({ + String? id, + required TimelineEntryFilterAction action, + required String name, + required bool enabled, + List authors = const [], + List domains = const [], + List keywords = const [], + List hashtags = const [], + }) { + return TimelineEntryFilter( + id: id ?? const Uuid().v4(), + action: action, + name: name, + enabled: enabled, + authorFilters: authors + .map((a) => + StringFilter(filterString: a.id, type: ComparisonType.equals)) + .toList(), + domainFilters: domains + .map((d) => d.startsWith('*') + ? StringFilter( + filterString: d.substring(1), + type: ComparisonType.endsWithIgnoreCase, + ) + : StringFilter( + filterString: d, + type: ComparisonType.equalsIgnoreCase, + )) + .toList(), + keywordFilters: keywords + .map((k) => StringFilter( + filterString: k, type: ComparisonType.containsIgnoreCase)) + .toList(), + hashtagFilters: hashtags + .map((h) => StringFilter( + filterString: h, type: ComparisonType.equalsIgnoreCase)) + .toList(), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TimelineEntryFilter && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; + + Map toJson() => { + 'id': id, + 'action': action.name, + 'name': name, + 'enabled': enabled, + 'authorFilters': authorFilters.map((f) => f.toJson()).toList(), + 'domainFilters': domainFilters.map((f) => f.toJson()).toList(), + 'keywordFilters': keywordFilters.map((f) => f.toJson()).toList(), + 'hashtagFilters': hashtagFilters.map((f) => f.toJson()).toList(), + }; + + factory TimelineEntryFilter.fromJson(Map json) => + TimelineEntryFilter( + id: json['id'], + action: TimelineEntryFilterAction.parse(json['action']), + name: json['name'], + enabled: json['enabled'] ?? true, + authorFilters: (json['authorFilters'] as List) + .map((json) => StringFilter.fromJson(json)) + .toList(), + domainFilters: (json['domainFilters'] as List) + .map((json) => StringFilter.fromJson(json)) + .toList(), + keywordFilters: (json['keywordFilters'] as List) + .map((json) => StringFilter.fromJson(json)) + .toList(), + hashtagFilters: (json['hashtagFilters'] as List) + .map((json) => StringFilter.fromJson(json)) + .toList(), + ); +} diff --git a/lib/models/friendica_version.dart b/lib/models/friendica_version.dart index 9245181..ce78e16 100644 --- a/lib/models/friendica_version.dart +++ b/lib/models/friendica_version.dart @@ -150,6 +150,10 @@ final FriendicaVersion v2022_12 = FriendicaVersion(DateTime(2022, 12)); // 2023 Versions final FriendicaVersion v2023_01 = FriendicaVersion(DateTime(2023, 01)); final FriendicaVersion v2023_04 = FriendicaVersion(DateTime(2023, 04)); +final FriendicaVersion v2023_04_01 = FriendicaVersion( + DateTime(2023, 04), + extra: '1', +); final knownFriendicaVersions = [ // 2018 Versions @@ -187,6 +191,7 @@ final knownFriendicaVersions = [ // 2023 Versions v2023_01, v2023_04, + v2023_04_01, ]; FriendicaVersion latestVersion() => knownFriendicaVersions.last; diff --git a/lib/models/timeline_entry.dart b/lib/models/timeline_entry.dart index e11a14f..69f286b 100644 --- a/lib/models/timeline_entry.dart +++ b/lib/models/timeline_entry.dart @@ -48,6 +48,8 @@ class TimelineEntry { final bool isFavorited; + final List tags; + final List links; final List likes; @@ -81,6 +83,7 @@ class TimelineEntry { this.externalLink = '', this.locationData = const LocationData(), this.isFavorited = false, + this.tags = const [], this.links = const [], this.likes = const [], this.dislikes = const [], @@ -112,6 +115,7 @@ class TimelineEntry { reshareAuthorId = 'Random parent author id ${randomId()}', locationData = LocationData.randomBuilt(), isFavorited = DateTime.now().second ~/ 2 == 0 ? true : false, + tags = [], links = [], likes = [], dislikes = [], @@ -140,6 +144,7 @@ class TimelineEntry { String? reshareAuthorId, LocationData? locationData, bool? isFavorited, + List? tags, List? links, List? likes, List? dislikes, @@ -170,6 +175,7 @@ class TimelineEntry { reshareAuthorId: parentAuthorId ?? this.reshareAuthorId, locationData: locationData ?? this.locationData, isFavorited: isFavorited ?? this.isFavorited, + tags: tags ?? this.tags, links: links ?? this.links, likes: likes ?? this.likes, dislikes: dislikes ?? this.dislikes, @@ -213,6 +219,7 @@ class TimelineEntry { externalLink == other.externalLink && locationData == other.locationData && isFavorited == other.isFavorited && + tags == other.tags && links == other.links && likes == other.likes && dislikes == other.dislikes && @@ -241,6 +248,7 @@ class TimelineEntry { externalLink.hashCode ^ locationData.hashCode ^ isFavorited.hashCode ^ + tags.hashCode ^ links.hashCode ^ likes.hashCode ^ dislikes.hashCode ^ diff --git a/lib/routes.dart b/lib/routes.dart index 0801cd2..a51555c 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -5,6 +5,8 @@ import 'models/interaction_type_enum.dart'; import 'screens/blocks_screen.dart'; import 'screens/contacts_screen.dart'; import 'screens/editor.dart'; +import 'screens/filter_editor_screen.dart'; +import 'screens/filters_screen.dart'; import 'screens/follow_request_adjudication_screen.dart'; import 'screens/gallery_browsers_screen.dart'; import 'screens/gallery_screen.dart'; @@ -30,6 +32,7 @@ import 'services/auth_service.dart'; class ScreenPaths { static String blocks = '/blocks'; + static String filters = '/filters'; static String thread = '/thread'; static String connectHandle = '/connect'; static String contacts = '/contacts'; @@ -80,6 +83,24 @@ final appRouter = GoRouter( name: ScreenPaths.blocks, builder: (context, state) => const BlocksScreen(), ), + GoRoute( + path: ScreenPaths.filters, + name: ScreenPaths.filters, + builder: (context, state) => const FiltersScreen(), + routes: [ + GoRoute( + path: 'new', + pageBuilder: (context, state) => const NoTransitionPage( + child: FilterEditorScreen(id: ''), + ), + ), + GoRoute( + path: 'edit/:id', + pageBuilder: (context, state) => NoTransitionPage( + child: FilterEditorScreen(id: state.params['id']!)), + ) + ], + ), GoRoute( path: ScreenPaths.signin, name: ScreenPaths.signin, diff --git a/lib/screens/blocks_screen.dart b/lib/screens/blocks_screen.dart index e0cec9a..0d6465f 100644 --- a/lib/screens/blocks_screen.dart +++ b/lib/screens/blocks_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import '../controls/image_control.dart'; import '../routes.dart'; import '../services/blocks_manager.dart'; import '../utils/active_profile_selector.dart'; @@ -28,6 +29,11 @@ class BlocksScreen extends StatelessWidget { context.pushNamed(ScreenPaths.userProfile, params: {'id': contact.id}); }, + leading: ImageControl( + imageUrl: contact.avatarUrl.toString(), + iconOverride: const Icon(Icons.person), + width: 32.0, + ), title: Text( '${contact.name} (${contact.handle})', softWrap: true, diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index a768b9c..ec628e5 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../controls/app_bottom_nav_bar.dart'; import '../controls/current_profile_button.dart'; +import '../controls/image_control.dart'; import '../controls/linear_status_indicator.dart'; import '../controls/responsive_max_width.dart'; import '../controls/standard_app_drawer.dart'; @@ -64,6 +65,11 @@ class _ContactsScreenState extends State { context.pushNamed(ScreenPaths.userProfile, params: {'id': contact.id}); }, + leading: ImageControl( + imageUrl: contact.avatarUrl.toString(), + iconOverride: const Icon(Icons.person), + width: 32.0, + ), title: Text( '${contact.name} (${contact.handle})', softWrap: true, diff --git a/lib/screens/filter_editor_screen.dart b/lib/screens/filter_editor_screen.dart new file mode 100644 index 0000000..9434b9a --- /dev/null +++ b/lib/screens/filter_editor_screen.dart @@ -0,0 +1,541 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart'; +import 'package:provider/provider.dart'; + +import '../controls/autocomplete/hashtag_autocomplete_options.dart'; +import '../controls/autocomplete/mention_autocomplete_options.dart'; +import '../controls/image_control.dart'; +import '../controls/padding.dart'; +import '../globals.dart'; +import '../models/connection.dart'; +import '../models/filters/string_filter.dart'; +import '../models/filters/timeline_entry_filter.dart'; +import '../services/connections_manager.dart'; +import '../services/timeline_entry_filter_service.dart'; +import '../utils/active_profile_selector.dart'; +import '../utils/snackbar_builder.dart'; + +class FilterEditorScreen extends StatefulWidget { + final String id; + + const FilterEditorScreen({super.key, required this.id}); + + @override + State createState() => _FilterEditorScreenState(); +} + +class _FilterEditorScreenState extends State { + static final _logger = Logger('$FilterEditorScreen'); + final nameController = TextEditingController(); + var action = TimelineEntryFilterAction.hide; + final filteredAuthors = []; + final filteredDomains = []; + final filteredKeywords = []; + final filteredHashtags = []; + + @override + void initState() { + super.initState(); + if (widget.id.isEmpty) { + return; + } + final filter = getIt>() + .activeEntry + .andThen((tfs) => tfs.getForId(widget.id)) + .value; + final cm = + getIt>().activeEntry.value; + nameController.text = filter.name; + action = filter.action; + for (final f in filter.authorFilters) { + cm.getById(f.filterString).withResult((c) => filteredAuthors.add(c)); + } + filteredDomains.addAll( + filter.domainFilters.map((f) => f.toLabel()), + ); + + filteredKeywords.addAll( + filter.keywordFilters.map((f) => f.toLabel()), + ); + + filteredHashtags.addAll( + filter.hashtagFilters.map((f) => f.toLabel()), + ); + } + + @override + Widget build(BuildContext context) { + _logger.finer('Build for filter ${widget.id}'); + final fieldWidth = MediaQuery.of(context).size.width * 0.8; + final service = context + .watch>() + .activeEntry + .value; + + return Scaffold( + appBar: AppBar( + title: Text(widget.id.isEmpty ? 'New Filter' : 'Edit Filter'), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SingleChildScrollView( + child: Column( + children: [ + Row( + children: [ + const HorizontalPadding(), + Expanded( + child: TextField( + controller: nameController, + decoration: InputDecoration( + labelText: 'Name of filter', + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + ), + ], + ), + const VerticalPadding(), + const Text('Action:'), + DropdownMenu( + initialSelection: action, + onSelected: (value) => action = value!, + dropdownMenuEntries: TimelineEntryFilterAction.values + .map((a) => + DropdownMenuEntry(value: a, label: a.toLabel())) + .toList()), + const VerticalPadding(), + const Text('Authors:'), + Container( + width: fieldWidth, + decoration: BoxDecoration( + borderRadius: + const BorderRadius.all(Radius.circular(5.0)), + border: + Border.all(color: Theme.of(context).dividerColor)), + child: Wrap(children: [ + IconButton( + onPressed: () async { + final newConnection = + await promptForConnection(context); + if (!mounted) { + return; + } + + if (newConnection == null) { + return; + } + + if (filteredAuthors.contains(newConnection)) { + buildSnackbar( + context, + 'Already filtering on ${newConnection.handle}', + ); + } + setState(() { + filteredAuthors.add(newConnection); + }); + }, + icon: const Icon(Icons.add), + ), + ...filteredAuthors.map( + (a) => Padding( + padding: const EdgeInsets.all(4.0), + child: Card( + child: Padding( + padding: const EdgeInsets.only(left: 5.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ImageControl( + imageUrl: a.avatarUrl.toString(), + iconOverride: const Icon(Icons.person), + width: 24.0, + ), + const HorizontalPadding( + width: 2.0, + ), + Flexible( + child: Text( + '${a.name} (${a.handle})', + softWrap: true, + maxLines: 10, + ), + ), + IconButton( + tooltip: 'Delete', + onPressed: () => setState(() { + filteredAuthors.remove(a); + }), + icon: const Icon(Icons.cancel)), + ]), + ), + ), + ), + ) + ]), + ), + const VerticalPadding(), + const Text('Hashtags:'), + Container( + width: fieldWidth, + decoration: BoxDecoration( + borderRadius: + const BorderRadius.all(Radius.circular(5.0)), + border: + Border.all(color: Theme.of(context).dividerColor)), + child: Wrap(children: [ + IconButton( + onPressed: () async { + final newValue = await promptForHashtag(context); + if (newValue == null || newValue.isEmpty) { + return; + } + setState(() { + filteredHashtags.add(newValue); + }); + }, + icon: const Icon(Icons.add), + ), + ...filteredHashtags.map( + (h) => Padding( + padding: const EdgeInsets.all(4.0), + child: Card( + child: Padding( + padding: const EdgeInsets.only(left: 5.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: + Text(h, softWrap: true, maxLines: 10), + ), + IconButton( + tooltip: 'Delete', + onPressed: () => setState(() { + filteredHashtags.remove(h); + }), + icon: const Icon(Icons.cancel)), + ]), + ), + ), + ), + ) + ]), + ), + const VerticalPadding(), + const Text('Keywords:'), + Container( + width: fieldWidth, + decoration: BoxDecoration( + borderRadius: + const BorderRadius.all(Radius.circular(5.0)), + border: + Border.all(color: Theme.of(context).dividerColor)), + child: Wrap(children: [ + IconButton( + onPressed: () async { + final newValue = await promptForString(context); + if (newValue == null || newValue.isEmpty) { + return; + } + setState(() { + filteredKeywords.add(newValue); + }); + }, + icon: const Icon(Icons.add), + ), + ...filteredKeywords.map( + (k) => Padding( + padding: const EdgeInsets.all(4.0), + child: Card( + child: Padding( + padding: const EdgeInsets.only(left: 5.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: + Text(k, softWrap: true, maxLines: 10), + ), + IconButton( + tooltip: 'Delete', + onPressed: () => setState(() { + filteredKeywords.remove(k); + }), + icon: const Icon(Icons.cancel)), + ]), + ), + ), + ), + ) + ]), + ), + const VerticalPadding(), + const Text('Domains:'), + Container( + width: fieldWidth, + decoration: BoxDecoration( + borderRadius: + const BorderRadius.all(Radius.circular(5.0)), + border: + Border.all(color: Theme.of(context).dividerColor)), + child: Wrap(children: [ + IconButton( + onPressed: () async { + final newValue = await promptForString(context); + if (newValue == null || newValue.isEmpty) { + return; + } + setState(() { + filteredDomains.add(newValue); + }); + }, + icon: const Icon(Icons.add), + ), + ...filteredDomains.map( + (d) => Padding( + padding: const EdgeInsets.all(4.0), + child: Card( + child: Padding( + padding: const EdgeInsets.only(left: 5.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: + Text(d, softWrap: true, maxLines: 10), + ), + IconButton( + tooltip: 'Delete', + onPressed: () => setState(() { + filteredDomains.remove(d); + }), + icon: const Icon(Icons.cancel)), + ]), + ), + ), + ), + ) + ]), + ), + const VerticalPadding(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () { + final update = TimelineEntryFilter.create( + id: widget.id.isNotEmpty ? widget.id : null, + action: action, + name: nameController.text, + enabled: true, + authors: filteredAuthors, + hashtags: filteredHashtags, + keywords: filteredKeywords, + domains: filteredDomains, + ); + service.upsertFilter(update); + if (context.canPop()) { + context.pop(); + } + }, + child: Text(widget.id.isEmpty ? 'Add' : 'Update')), + ], + ), + const VerticalPadding(), + ], + ), + )), + ), + ); + } + + Future promptForString(BuildContext context) async { + return await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + final controller = TextEditingController(); + return AlertDialog( + content: TextField( + controller: controller, + decoration: InputDecoration( + labelText: 'Enter value', + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + actions: [ + ElevatedButton( + onPressed: () => context.pop(controller.text), + child: const Text('OK'), + ), + ElevatedButton( + onPressed: () => context.pop(), + child: const Text('Cancel'), + ), + ], + ); + }); + } + + Future promptForConnection(BuildContext context) async { + final focusNode = FocusNode(); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + final controller = TextEditingController(); + return AlertDialog( + content: MultiTriggerAutocomplete( + textEditingController: controller, + focusNode: focusNode, + optionsAlignment: OptionsAlignment.top, + autocompleteTriggers: [ + AutocompleteTrigger( + trigger: '@', + triggerOnlyAfterSpace: false, + optionsViewBuilder: + (ovbContext, autocompleteQuery, controller) { + return MentionAutocompleteOptions( + query: autocompleteQuery.query, + onMentionUserTap: (user) { + final autocomplete = + MultiTriggerAutocomplete.of(ovbContext); + return autocomplete + .acceptAutocompleteOption(user.handle); + }, + ); + }, + ), + ], + fieldViewBuilder: (fvbContext, controller, focusNode) => + TextFormField( + focusNode: focusNode, + controller: controller, + decoration: InputDecoration( + labelText: 'Author (@@domain)', + alignLabelWithHint: true, + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + ), + actions: [ + ElevatedButton( + onPressed: () { + final rval = + getIt>() + .activeEntry + .andThen((cm) { + var handle = controller.text.trim(); + if (handle.startsWith('@')) { + handle = handle.substring(1); + } + return cm.getByHandle(handle); + }) + .withError((error) => buildSnackbar(context, + "Error adding ${controller.text}: $error")) + .fold(onSuccess: (c) => c, onError: (_) => null); + dialogContext.pop(rval); + }, + child: const Text('OK'), + ), + ElevatedButton( + onPressed: () => context.pop(), + child: const Text('Cancel'), + ), + ], + ); + }); + } + + Future promptForHashtag(BuildContext context) async { + final focusNode = FocusNode(); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + final controller = TextEditingController(); + return AlertDialog( + content: MultiTriggerAutocomplete( + textEditingController: controller, + focusNode: focusNode, + optionsAlignment: OptionsAlignment.top, + autocompleteTriggers: [ + AutocompleteTrigger( + trigger: '#', + triggerOnlyAfterSpace: false, + optionsViewBuilder: + (ovbContext, autocompleteQuery, controller) { + return HashtagAutocompleteOptions( + query: autocompleteQuery.query, + onHashtagTap: (hashtag) { + final autocomplete = + MultiTriggerAutocomplete.of(ovbContext); + return autocomplete.acceptAutocompleteOption(hashtag); + }, + ); + }, + ), + ], + fieldViewBuilder: (fvbContext, controller, focusNode) => + TextFormField( + focusNode: focusNode, + controller: controller, + decoration: InputDecoration( + labelText: 'Hashtag (#)', + alignLabelWithHint: true, + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + ), + actions: [ + ElevatedButton( + onPressed: () { + final rval = controller.text.trim(); + if (rval.startsWith('#')) { + dialogContext.pop(rval.substring(1)); + } else { + dialogContext.pop(rval); + } + }, + child: const Text('OK'), + ), + ElevatedButton( + onPressed: () => context.pop(), + child: const Text('Cancel'), + ), + ], + ); + }); + } +} + +extension StringFilterLabel on StringFilter { + String toLabel() { + switch (type) { + case ComparisonType.endsWithIgnoreCase: + return '*$filterString'; + case ComparisonType.contains: + case ComparisonType.containsIgnoreCase: + case ComparisonType.equals: + case ComparisonType.equalsIgnoreCase: + return filterString; + } + } +} diff --git a/lib/screens/filters_screen.dart b/lib/screens/filters_screen.dart new file mode 100644 index 0000000..3bdfbca --- /dev/null +++ b/lib/screens/filters_screen.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../globals.dart'; +import '../models/filters/timeline_entry_filter.dart'; +import '../services/connections_manager.dart'; +import '../services/timeline_entry_filter_service.dart'; +import '../utils/active_profile_selector.dart'; + +class FiltersScreen extends StatelessWidget { + const FiltersScreen({super.key}); + + @override + Widget build(BuildContext context) { + final service = context + .watch>() + .activeEntry + .value; + + final filters = service.filters; + return Scaffold( + appBar: AppBar( + title: const Text('Filters'), + actions: [ + IconButton( + onPressed: () { + context.push('/filters/new'); + }, + icon: const Icon(Icons.add), + ), + ], + ), + body: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: ListView.separated( + itemBuilder: (context, index) { + return buildFilterSummary(context, filters[index], service); + }, + separatorBuilder: (_, __) => const Divider(), + itemCount: filters.length), + ), + ], + ), + ), + ); + } + + Widget buildFilterSummary(BuildContext context, TimelineEntryFilter filter, + TimelineEntryFilterService service) { + return ListTile( + leading: Checkbox( + onChanged: (value) => service.upsertFilter(filter.copy(enabled: value)), + value: filter.enabled, + ), + title: Text('${filter.action.toVerb()} Filter: ${filter.name}'), + subtitle: Text( + filter.toSummaryText(), + maxLines: 10, + softWrap: true, + ), + trailing: IconButton( + onPressed: () async { + final confirm = + await showYesNoDialog(context, 'Delete filter ${filter.name}?'); + if (confirm == true) { + service.removeById(filter.id); + } + }, + icon: const Icon(Icons.remove)), + onTap: () => context.push('/filters/edit/${filter.id}'), + ); + } +} + +extension _TimelineEntryFilterSummary on TimelineEntryFilter { + String toSummaryText() { + var authorsString = ''; + if (authorFilters.isNotEmpty) { + final cm = + getIt>().activeEntry.value; + authorsString = authorFilters + .map((a) => cm + .getById(a.filterString) + .transform((c) => '${c.name} (${c.handle})') + .getValueOrElse(() => '')) + .where((e) => e.isNotEmpty) + .join('; '); + } + + return [ + if (hashtagFilters.isNotEmpty) + 'Hashtags: ${hashtagFilters.map((f) => f.filterString).join(',')}', + if (keywordFilters.isNotEmpty) + 'Keywords: ${keywordFilters.map((f) => f.filterString).join(',')}', + if (domainFilters.isNotEmpty) + 'Domains: ${domainFilters.map((f) => f.filterString).join(', ')}', + if (authorFilters.isNotEmpty) 'Authors: $authorsString', + ].join('\n'); + } +} diff --git a/lib/screens/follow_request_adjudication_screen.dart b/lib/screens/follow_request_adjudication_screen.dart index 40c92f1..006390e 100644 --- a/lib/screens/follow_request_adjudication_screen.dart +++ b/lib/screens/follow_request_adjudication_screen.dart @@ -54,7 +54,13 @@ class _FollowRequestAdjudicationScreenState late final Widget body; if (result.isFailure) { - body = Text('Error getting request info: ${result.error}'); + if (result.error.type == ErrorType.notFound && + nss.connectionUpdateStatus.value) { + fm.update(); + body = const Text('Loading...'); + } else { + body = Text('Error getting request info: ${result.error}'); + } } else { final contact = result.value; final contactStatus = cm diff --git a/lib/screens/message_thread_screen.dart b/lib/screens/message_thread_screen.dart index 7735ece..5e72063 100644 --- a/lib/screens/message_thread_screen.dart +++ b/lib/screens/message_thread_screen.dart @@ -91,7 +91,7 @@ class _MessageThreadScreenState extends State { ? null : const TextStyle(fontWeight: FontWeight.bold), ), - trailing: Text(DateTime.fromMillisecondsSinceEpoch( + subtitle: Text(DateTime.fromMillisecondsSinceEpoch( m.createdAt * 1000) .toString()), ); diff --git a/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart b/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart index f891eba..d7751d3 100644 --- a/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart +++ b/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart @@ -90,11 +90,13 @@ extension TimelineEntryMastodonExtensions on TimelineEntry { reshareOriginalPostId = ''; } - final List? tags = json['tags']; - if (tags?.isNotEmpty ?? false) { + final List? tagsJson = json['tags']; + final tags = []; + if (tagsJson?.isNotEmpty ?? false) { final tagManager = getIt(); - for (final tagJson in tags!) { + for (final tagJson in tagsJson!) { final tag = HashtagMastodonExtensions.fromJson(tagJson); + tags.add(tag.tag); tagManager.add(tag); } } @@ -121,6 +123,7 @@ extension TimelineEntryMastodonExtensions on TimelineEntry { parentAuthor: parentAuthor, title: title, links: linkData, + tags: tags, mediaAttachments: mediaAttachments, engagementSummary: engagementSummary, linkPreviewData: linkPreviewData, diff --git a/lib/services/feature_version_checker.dart b/lib/services/feature_version_checker.dart index 919e329..f26300e 100644 --- a/lib/services/feature_version_checker.dart +++ b/lib/services/feature_version_checker.dart @@ -80,7 +80,6 @@ class FriendicaVersionChecker { RelaticaFeatures.postSpoilerText: FriendicaVersionRequirement(v2023_04), RelaticaFeatures.reshareIdFix: FriendicaVersionRequirement( v2023_04, - maxVersion: v2023_04, ), RelaticaFeatures.statusEditing: FriendicaVersionRequirement(v2023_04), RelaticaFeatures.usingActualFollowRequests: FriendicaVersionRequirement( diff --git a/lib/services/follow_requests_manager.dart b/lib/services/follow_requests_manager.dart index 6f51854..8483039 100644 --- a/lib/services/follow_requests_manager.dart +++ b/lib/services/follow_requests_manager.dart @@ -28,7 +28,7 @@ class FollowRequestsManager extends ChangeNotifier { return request != null ? Result.ok(request) : buildErrorResult( - type: ErrorType.rangeError, + type: ErrorType.notFound, message: 'Request for $id not found', ); } diff --git a/lib/services/timeline_entry_filter_service.dart b/lib/services/timeline_entry_filter_service.dart new file mode 100644 index 0000000..835124f --- /dev/null +++ b/lib/services/timeline_entry_filter_service.dart @@ -0,0 +1,94 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:result_monad/result_monad.dart'; + +import '../models/exec_error.dart'; +import '../models/filters/timeline_entry_filter.dart'; +import '../models/timeline_entry.dart'; +import '../utils/filter_runner.dart'; + +class TimelineEntryFilterService extends ChangeNotifier { + static final _logger = Logger('$TimelineEntryFilterService'); + final String filePath; + final _filters = {}; + final _entryCache = {}; + + List get filters => + UnmodifiableListView(_filters.values); + + TimelineEntryFilterService(this.filePath); + + void load() { + final file = File(filePath); + if (!file.existsSync()) { + return; + } + + try { + final str = file.readAsStringSync(); + final json = jsonDecode(str) as List; + final filters = json.map((j) => TimelineEntryFilter.fromJson(j)).toList(); + _filters.clear(); + _filters.addEntries(filters.map((f) => MapEntry(f.id, f))); + } catch (e) { + _logger.severe('Error parsing filters file $filePath: $e'); + } + } + + void save() { + try { + final json = _filters.values.map((f) => f.toJson()).toList(); + final str = jsonEncode(json); + File(filePath).writeAsStringSync(str); + } catch (e) { + _logger.severe('Error writing filters file $filePath: $e'); + } + } + + Result getForId(String id) { + if (!_filters.containsKey(id)) { + return buildErrorResult( + type: ErrorType.notFound, + message: 'No filter with id: $id', + ); + } + + return Result.ok(_filters[id]!); + } + + void upsertFilter(TimelineEntryFilter filter) { + _filters[filter.id] = filter; + _entryCache.clear(); + save(); + notifyListeners(); + } + + void removeById(String id) { + _filters.remove(id); + _entryCache.clear(); + save(); + notifyListeners(); + } + + FilterResult checkTimelineEntry(TimelineEntry entry) { + if (entry == _entryCache[entry.id]?.entry) { + return _entryCache[entry.id]!.result; + } + + final result = runFilters(entry, _filters.values.toList()); + _entryCache[entry.id] = _EntryCacheItem(entry, result); + + return result; + } +} + +class _EntryCacheItem { + final TimelineEntry entry; + final FilterResult result; + + _EntryCacheItem(this.entry, this.result); +} diff --git a/lib/utils/filter_runner.dart b/lib/utils/filter_runner.dart new file mode 100644 index 0000000..7a712fc --- /dev/null +++ b/lib/utils/filter_runner.dart @@ -0,0 +1,141 @@ +import 'package:relatica/utils/html_to_edit_text_helper.dart'; + +import '../models/filters/string_filter.dart'; +import '../models/filters/timeline_entry_filter.dart'; +import '../models/timeline_entry.dart'; + +class FilterResult { + static const show = FilterResult( + false, + TimelineEntryFilterAction.warn, + '', + ); + final TimelineEntryFilterAction action; + final bool isFiltered; + final String trippingFilterName; + + const FilterResult( + this.isFiltered, + this.action, + this.trippingFilterName, + ); + + String toActionString() { + return isFiltered ? action.name : 'show'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FilterResult && + runtimeType == other.runtimeType && + action == other.action && + isFiltered == other.isFiltered && + trippingFilterName == other.trippingFilterName; + + @override + int get hashCode => + action.hashCode ^ isFiltered.hashCode ^ trippingFilterName.hashCode; +} + +FilterResult runFilters( + TimelineEntry entry, + List filters, +) { + var isFiltered = false; + var action = TimelineEntryFilterAction.warn; + var trippingFilterName = ''; + for (final filter in filters.where((f) => f.enabled)) { + if (filter.isFiltered(entry)) { + isFiltered = true; + if (trippingFilterName.isEmpty) { + trippingFilterName = filter.name; + } + + if (filter.action == TimelineEntryFilterAction.hide) { + action = TimelineEntryFilterAction.hide; + trippingFilterName = filter.name; + break; + } + } + } + + return FilterResult(isFiltered, action, trippingFilterName); +} + +extension StringFilterOps on StringFilter { + bool isFiltered(String value) { + switch (type) { + case ComparisonType.contains: + return value.contains(filterString); + case ComparisonType.containsIgnoreCase: + final lv = value.toLowerCase(); + final lf = filterString.toLowerCase(); + final c = lv.contains(lf); + return c; + case ComparisonType.equals: + return value == filterString; + case ComparisonType.equalsIgnoreCase: + return value.toLowerCase() == filterString.toLowerCase(); + case ComparisonType.endsWithIgnoreCase: + return value.toLowerCase().endsWith(filterString.toLowerCase()); + } + } +} + +extension TimelineEntryFilterOps on TimelineEntryFilter { + bool isFiltered(TimelineEntry entry) { + if (authorFilters.isEmpty && + domainFilters.isEmpty && + hashtagFilters.isEmpty && + keywordFilters.isEmpty) { + return false; + } + + var authorFiltered = authorFilters.isEmpty ? true : false; + for (final filter in authorFilters) { + if (filter.isFiltered(entry.authorId) || + filter.isFiltered(entry.reshareAuthorId) || + filter.isFiltered(entry.parentAuthorId)) { + authorFiltered = true; + break; + } + } + + var hashtagFiltered = hashtagFilters.isEmpty ? true : false; + for (final filter in hashtagFilters) { + for (final tag in entry.tags) { + if (filter.isFiltered(tag)) { + hashtagFiltered = true; + break; + } + } + } + + var domainFiltered = domainFilters.isEmpty ? true : false; + for (final filter in domainFilters) { + final domain = + Uri.tryParse(entry.externalLink)?.host ?? entry.externalLink; + if (filter.isFiltered(domain)) { + domainFiltered = true; + break; + } + } + + var contentFiltered = keywordFilters.isEmpty ? true : false; + final simplifiedBody = keywordFilters.isNotEmpty + ? htmlToSimpleText(entry.body).toLowerCase() + : ''; + for (final filter in keywordFilters) { + if (filter.isFiltered(simplifiedBody)) { + contentFiltered = true; + break; + } + } + + return authorFiltered && + domainFiltered && + hashtagFiltered && + contentFiltered; + } +} diff --git a/lib/utils/html_to_edit_text_helper.dart b/lib/utils/html_to_edit_text_helper.dart index b12f652..19aed42 100644 --- a/lib/utils/html_to_edit_text_helper.dart +++ b/lib/utils/html_to_edit_text_helper.dart @@ -2,11 +2,15 @@ import 'package:html/dom.dart'; import 'package:html/parser.dart'; String htmlToSimpleText(String htmlContentFragment) { - final dom = parseFragment(htmlContentFragment); - final segments = dom.nodes - .map((n) => n is Element ? n.elementToEditText() : n.nodeToEditText()) - .toList(); - return segments.join(''); + try { + final dom = parseFragment(htmlContentFragment); + final segments = dom.nodes + .map((n) => n is Element ? n.elementToEditText() : n.nodeToEditText()) + .toList(); + return segments.join(''); + } catch (e) { + return htmlContentFragment; + } } extension NodeTextConverter on Node { diff --git a/test/filter_runner_test.dart b/test/filter_runner_test.dart new file mode 100644 index 0000000..f840cfb --- /dev/null +++ b/test/filter_runner_test.dart @@ -0,0 +1,274 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relatica/models/connection.dart'; +import 'package:relatica/models/filters/string_filter.dart'; +import 'package:relatica/models/filters/timeline_entry_filter.dart'; +import 'package:relatica/models/timeline_entry.dart'; +import 'package:relatica/utils/filter_runner.dart'; + +void main() { + final entries = [ + TimelineEntry( + body: 'Hello world', + authorId: '1', + tags: ['greeting'], + externalLink: 'http://mastodon.social/@user1/1234', + ), + TimelineEntry( + body: 'Goodbye', + authorId: '1', + tags: ['SendOff'], + externalLink: 'http://mastodon.social/@user1/4567'), + TimelineEntry( + body: 'Lorem ipsum', + authorId: '1', + tags: ['latin'], + externalLink: 'http://mastodon.social/@user1/7890', + ), + TimelineEntry( + body: 'Hello world', + authorId: '2', + tags: ['greeting'], + externalLink: 'http://trolltodon.social/@user2/12', + ), + TimelineEntry( + body: 'Goodbye', + authorId: '2', + tags: ['SendOff'], + externalLink: 'http://trolltodon.social/@user2/34', + ), + TimelineEntry( + body: 'Lorem ipsum', + authorId: '2', + tags: ['LATIN'], + externalLink: 'http://trolltodon.social/@user2/56', + ), + TimelineEntry( + body: 'Chao', + authorId: '2', + tags: ['sendoff'], + externalLink: 'http://trolltodon.social/@user2/78', + ), + ]; + + group('Test StringFilter', () { + test('Test equals', () { + const filter = StringFilter( + filterString: 'hello', + type: ComparisonType.equals, + ); + expect(filter.isFiltered('hello'), equals(true)); + expect(filter.isFiltered('Hello'), equals(false)); + expect(filter.isFiltered('hello!'), equals(false)); + expect(filter.isFiltered('help'), equals(false)); + }); + test('Test equalsIgnoreCase', () { + const filter = StringFilter( + filterString: 'hello', + type: ComparisonType.equalsIgnoreCase, + ); + expect(filter.isFiltered('hello'), equals(true)); + expect(filter.isFiltered('Hello'), equals(true)); + expect(filter.isFiltered('hello!'), equals(false)); + expect(filter.isFiltered('help'), equals(false)); + }); + test('Test endsWithIgnoresCase', () { + const filter = StringFilter( + filterString: 'world', + type: ComparisonType.endsWithIgnoreCase, + ); + expect(filter.isFiltered('world'), equals(true)); + expect(filter.isFiltered('hello WORld'), equals(true)); + expect(filter.isFiltered('worldwide'), equals(false)); + expect(filter.isFiltered('hello world!'), equals(false)); + }); + test('Test contains', () { + const filter = StringFilter( + filterString: 'hello', + type: ComparisonType.contains, + ); + expect(filter.isFiltered('hello world'), equals(true)); + expect(filter.isFiltered('Hello World'), equals(false)); + expect(filter.isFiltered('hello world'), equals(true)); + expect(filter.isFiltered('help'), equals(false)); + }); + test('Test containsIgnoreCase', () { + const filter = StringFilter( + filterString: 'hello', + type: ComparisonType.containsIgnoreCase, + ); + expect(filter.isFiltered('hello world'), equals(true)); + expect(filter.isFiltered('Hello World'), equals(true)); + expect(filter.isFiltered('hello world'), equals(true)); + expect(filter.isFiltered('Standard greeting #HelloWorld'), equals(true)); + expect(filter.isFiltered('help'), equals(false)); + }); + }); + + group('Test TimelineEntryFilter', () { + test('Empty Filter', () { + final filter = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + ); + final expected = [false, false, false, false, false, false, false]; + final actual = entries.map((e) => filter.isFiltered(e)).toList(); + expect(actual, equals(expected)); + }); + test('Test Keyword Filter', () { + final filter = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + keywords: ['hello', 'good'], + ); + final expected = [true, true, false, true, true, false, false]; + final actual = entries.map((e) => filter.isFiltered(e)).toList(); + expect(actual, equals(expected)); + }); + + test('Test Author Filter', () { + final filter = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + authors: [Connection(id: '2')], + ); + final expected = [false, false, false, true, true, true, true]; + final actual = entries.map((e) => filter.isFiltered(e)).toList(); + expect(actual, equals(expected)); + }); + + group('Test Domain Filter', () { + test('Exact match', () { + final filter = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + domains: ['trolltodon.social'], + ); + final expected = [false, false, false, true, true, true, true]; + final actual = entries.map((e) => filter.isFiltered(e)).toList(); + expect(actual, equals(expected)); + }); + + test('Start wildcard', () { + final filter = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + domains: ['*odon.social'], + ); + final expected = [true, true, true, true, true, true, true]; + final actual = entries.map((e) => filter.isFiltered(e)).toList(); + expect(actual, equals(expected)); + }); + }); + + test('Test Tag Filter', () { + final filter = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + hashtags: ['latin', 'greet'], + ); + final expected = [false, false, true, false, false, true, false]; + final actual = entries.map((e) => filter.isFiltered(e)).toList(); + expect(actual, equals(expected)); + }); + + test('Test Author plus Keyword', () { + final filter = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + authors: [Connection(id: '2')], + keywords: ['good'], + ); + final expected = [false, false, false, false, true, false, false]; + final actual = entries.map((e) => filter.isFiltered(e)).toList(); + expect(actual, equals(expected)); + }); + + test('Test Author plus tag', () { + final filter = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + authors: [Connection(id: '2')], + hashtags: ['latin', 'greet'], + ); + final expected = [false, false, false, false, false, true, false]; + final actual = entries.map((e) => filter.isFiltered(e)).toList(); + expect(actual, equals(expected)); + }); + + test('Test Keyword plus tag', () { + final filter = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + keywords: ['chao'], + hashtags: ['SENDOFF'], + ); + final expected = [false, false, false, false, false, false, true]; + final actual = entries.map((e) => filter.isFiltered(e)).toList(); + expect(actual, equals(expected)); + }); + + test('Test all', () { + final filter1 = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + authors: [Connection(id: '2'), Connection(id: '3')], + keywords: ['chao'], + hashtags: ['SENDOFF'], + ); + final expected1 = [false, false, false, false, false, false, true]; + final actual1 = entries.map((e) => filter1.isFiltered(e)).toList(); + expect(actual1, equals(expected1)); + + final filter2 = TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'filter', + authors: [Connection(id: '1'), Connection(id: '3')], + keywords: ['chao'], + hashtags: ['SENDOFF'], + ); + final expected2 = [false, false, false, false, false, false, false]; + final actual2 = entries.map((e) => filter2.isFiltered(e)).toList(); + expect(actual2, equals(expected2)); + }); + }); + + test('Test runner', () { + final runnerEntries = [ + ...entries, + TimelineEntry(body: 'User 3 Post #1', authorId: '3'), + ]; + final filters = [ + TimelineEntryFilter.create( + action: TimelineEntryFilterAction.warn, + name: 'send-off-hide-filter', + hashtags: ['SENDOFF'], + ), + TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'author-3-hide', + authors: [Connection(id: '3')], + ), + TimelineEntryFilter.create( + action: TimelineEntryFilterAction.hide, + name: 'send-off-hide-filter', + authors: [Connection(id: '1')], + hashtags: ['SENDOFF'], + ) + ]; + + final expected = [ + 'show', + 'hide', + 'show', + 'show', + 'warn', + 'show', + 'warn', + 'hide', + ]; + final actual = runnerEntries + .map((e) => runFilters(e, filters).toActionString()) + .toList(); + expect(expected, equals(actual)); + }); +}