From 4b63a5f8374168fe382de8473af50cc0939a7c20 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Thu, 4 May 2023 10:37:32 -0400 Subject: [PATCH 01/11] Fix "unfound connection request" error by forcing refresh --- lib/screens/follow_request_adjudication_screen.dart | 8 +++++++- lib/services/follow_requests_manager.dart | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) 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/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', ); } From f91080856fe44f297f8070f03ab61c1773d75db1 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Thu, 4 May 2023 21:43:29 -0400 Subject: [PATCH 02/11] Move DM timestamps to the bottom of the message from the right --- lib/screens/message_thread_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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()), ); From 9e95427b9f44b3560d8ce93e1f26f0b1433d4998 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Fri, 5 May 2023 10:52:24 -0400 Subject: [PATCH 03/11] Add low level timeline entry filtering capabilities --- lib/models/filters/string_filter.dart | 34 +++ lib/models/filters/timeline_entry_filter.dart | 79 +++++++ lib/models/timeline_entry.dart | 8 + lib/utils/filter_runner.dart | 97 +++++++++ test/filter_runner_test.dart | 205 ++++++++++++++++++ 5 files changed, 423 insertions(+) create mode 100644 lib/models/filters/string_filter.dart create mode 100644 lib/models/filters/timeline_entry_filter.dart create mode 100644 lib/utils/filter_runner.dart create mode 100644 test/filter_runner_test.dart diff --git a/lib/models/filters/string_filter.dart b/lib/models/filters/string_filter.dart new file mode 100644 index 0000000..569b313 --- /dev/null +++ b/lib/models/filters/string_filter.dart @@ -0,0 +1,34 @@ +enum ComparisonType { + containsCaseSensitive, + containsCaseInsensitive, + 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, + }; + + 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..2a68832 --- /dev/null +++ b/lib/models/filters/timeline_entry_filter.dart @@ -0,0 +1,79 @@ +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, + ); + } +} + +class TimelineEntryFilter { + final TimelineEntryFilterAction action; + final String name; + final List authorFilters; + final List contentFilters; + final List hashtagFilters; + + const TimelineEntryFilter({ + required this.action, + required this.name, + required this.authorFilters, + required this.contentFilters, + required this.hashtagFilters, + }); + + factory TimelineEntryFilter.create({ + required TimelineEntryFilterAction action, + required String name, + List authors = const [], + List keywords = const [], + List hashtags = const [], + }) { + return TimelineEntryFilter( + action: action, + name: name, + authorFilters: authors + .map((a) => + StringFilter(filterString: a.id, type: ComparisonType.equals)) + .toList(), + contentFilters: keywords + .map((k) => StringFilter( + filterString: k, type: ComparisonType.containsCaseInsensitive)) + .toList(), + hashtagFilters: hashtags + .map((h) => StringFilter( + filterString: h, type: ComparisonType.equalsIgnoreCase)) + .toList(), + ); + } + + Map toJson() => { + 'action': action.name, + 'name': name, + 'authorFilters': authorFilters.map((f) => f.toJson()), + 'contentFilters': contentFilters.map((f) => f.toJson()), + 'hashtagFilters': hashtagFilters.map((f) => f.toJson()), + }; + + factory TimelineEntryFilter.fromJson(Map json) => + TimelineEntryFilter( + action: TimelineEntryFilterAction.parse(json['action']), + name: json['name'], + authorFilters: (json['authorFilters'] as List) + .map((json) => StringFilter.fromJson(json)) + .toList(), + contentFilters: (json['contentFilters'] as List) + .map((json) => StringFilter.fromJson(json)) + .toList(), + hashtagFilters: (json['hashtagFilters'] as List) + .map((json) => StringFilter.fromJson(json)) + .toList(), + ); +} 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/utils/filter_runner.dart b/lib/utils/filter_runner.dart new file mode 100644 index 0000000..c247af4 --- /dev/null +++ b/lib/utils/filter_runner.dart @@ -0,0 +1,97 @@ +import '../models/filters/string_filter.dart'; +import '../models/filters/timeline_entry_filter.dart'; +import '../models/timeline_entry.dart'; + +class FilterResult { + final TimelineEntryFilterAction action; + final bool isFiltered; + + const FilterResult(this.isFiltered, this.action); + + 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; + + @override + int get hashCode => action.hashCode ^ isFiltered.hashCode; +} + +FilterResult runFilters( + TimelineEntry entry, + List filters, +) { + var isFiltered = false; + var action = TimelineEntryFilterAction.warn; + for (final filter in filters) { + if (filter.isFiltered(entry)) { + isFiltered = true; + if (filter.action == TimelineEntryFilterAction.hide) { + action = TimelineEntryFilterAction.hide; + break; + } + } + } + + return FilterResult(isFiltered, action); +} + +extension StringFilterOps on StringFilter { + bool isFiltered(String value) { + switch (type) { + case ComparisonType.containsCaseSensitive: + return value.contains(filterString); + case ComparisonType.containsCaseInsensitive: + return value.toLowerCase().contains(filterString.toLowerCase()); + case ComparisonType.equals: + return value == filterString; + case ComparisonType.equalsIgnoreCase: + return value.toLowerCase() == filterString.toLowerCase(); + } + } +} + +extension TimelineEntryFilterOps on TimelineEntryFilter { + bool isFiltered(TimelineEntry entry) { + if (authorFilters.isEmpty && + hashtagFilters.isEmpty && + contentFilters.isEmpty) { + return false; + } + + var authorFiltered = authorFilters.isEmpty ? true : false; + for (final filter in authorFilters) { + if (filter.isFiltered(entry.authorId)) { + 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 contentFiltered = contentFilters.isEmpty ? true : false; + for (final filter in contentFilters) { + if (filter.isFiltered(entry.body)) { + contentFiltered = true; + break; + } + } + + return authorFiltered && hashtagFiltered && contentFiltered; + } +} diff --git a/test/filter_runner_test.dart b/test/filter_runner_test.dart new file mode 100644 index 0000000..d042f90 --- /dev/null +++ b/test/filter_runner_test.dart @@ -0,0 +1,205 @@ +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']), + TimelineEntry(body: 'Goodbye', authorId: '1', tags: ['SendOff']), + TimelineEntry(body: 'Lorem ipsum', authorId: '1', tags: ['latin']), + TimelineEntry(body: 'Hello world', authorId: '2', tags: ['greeting']), + TimelineEntry(body: 'Goodbye', authorId: '2', tags: ['SendOff']), + TimelineEntry(body: 'Lorem ipsum', authorId: '2', tags: ['LATIN']), + TimelineEntry(body: 'Chao', authorId: '2', tags: ['sendoff']), + ]; + + 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 containsCaseSensitive', () { + const filter = StringFilter( + filterString: 'hello', + type: ComparisonType.containsCaseSensitive, + ); + 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 containsCaseInsensitive', () { + const filter = StringFilter( + filterString: 'hello', + type: ComparisonType.containsCaseInsensitive, + ); + expect(filter.isFiltered('hello world'), equals(true)); + expect(filter.isFiltered('Hello World'), equals(true)); + expect(filter.isFiltered('hello world'), 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 Content 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)); + }); + + 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 content', () { + 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 Content 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)); + }); +} From ed1b800e8212e16f59ae3f018a1cdd09f603fe73 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Fri, 5 May 2023 13:55:17 -0400 Subject: [PATCH 04/11] Change the reshare fix to have open ended versions until know which version fixed in --- lib/models/friendica_version.dart | 5 +++++ lib/services/feature_version_checker.dart | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) 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/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( From 5eb7dcf7fe0be95c6e7fbe5605cfdd43a607d386 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Fri, 5 May 2023 14:18:49 -0400 Subject: [PATCH 05/11] Add domain blocking capability to low level filters --- lib/models/filters/string_filter.dart | 5 +- lib/models/filters/timeline_entry_filter.dart | 20 ++++- lib/utils/filter_runner.dart | 22 ++++- test/filter_runner_test.dart | 90 ++++++++++++++++--- 4 files changed, 120 insertions(+), 17 deletions(-) diff --git a/lib/models/filters/string_filter.dart b/lib/models/filters/string_filter.dart index 569b313..05879d1 100644 --- a/lib/models/filters/string_filter.dart +++ b/lib/models/filters/string_filter.dart @@ -1,6 +1,7 @@ enum ComparisonType { - containsCaseSensitive, - containsCaseInsensitive, + contains, + containsIgnoreCase, + endsWithIgnoreCase, equals, equalsIgnoreCase, ; diff --git a/lib/models/filters/timeline_entry_filter.dart b/lib/models/filters/timeline_entry_filter.dart index 2a68832..284b2bf 100644 --- a/lib/models/filters/timeline_entry_filter.dart +++ b/lib/models/filters/timeline_entry_filter.dart @@ -18,6 +18,7 @@ class TimelineEntryFilter { final TimelineEntryFilterAction action; final String name; final List authorFilters; + final List domainFilters; final List contentFilters; final List hashtagFilters; @@ -25,6 +26,7 @@ class TimelineEntryFilter { required this.action, required this.name, required this.authorFilters, + required this.domainFilters, required this.contentFilters, required this.hashtagFilters, }); @@ -33,6 +35,7 @@ class TimelineEntryFilter { required TimelineEntryFilterAction action, required String name, List authors = const [], + List domains = const [], List keywords = const [], List hashtags = const [], }) { @@ -43,9 +46,20 @@ class TimelineEntryFilter { .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(), contentFilters: keywords .map((k) => StringFilter( - filterString: k, type: ComparisonType.containsCaseInsensitive)) + filterString: k, type: ComparisonType.containsIgnoreCase)) .toList(), hashtagFilters: hashtags .map((h) => StringFilter( @@ -58,6 +72,7 @@ class TimelineEntryFilter { 'action': action.name, 'name': name, 'authorFilters': authorFilters.map((f) => f.toJson()), + 'domainFilters': domainFilters.map((f) => f.toJson()), 'contentFilters': contentFilters.map((f) => f.toJson()), 'hashtagFilters': hashtagFilters.map((f) => f.toJson()), }; @@ -69,6 +84,9 @@ class TimelineEntryFilter { authorFilters: (json['authorFilters'] as List) .map((json) => StringFilter.fromJson(json)) .toList(), + domainFilters: (json['domainFilters'] as List) + .map((json) => StringFilter.fromJson(json)) + .toList(), contentFilters: (json['contentFilters'] as List) .map((json) => StringFilter.fromJson(json)) .toList(), diff --git a/lib/utils/filter_runner.dart b/lib/utils/filter_runner.dart index c247af4..11c611f 100644 --- a/lib/utils/filter_runner.dart +++ b/lib/utils/filter_runner.dart @@ -46,14 +46,16 @@ FilterResult runFilters( extension StringFilterOps on StringFilter { bool isFiltered(String value) { switch (type) { - case ComparisonType.containsCaseSensitive: + case ComparisonType.contains: return value.contains(filterString); - case ComparisonType.containsCaseInsensitive: + case ComparisonType.containsIgnoreCase: return value.toLowerCase().contains(filterString.toLowerCase()); case ComparisonType.equals: return value == filterString; case ComparisonType.equalsIgnoreCase: return value.toLowerCase() == filterString.toLowerCase(); + case ComparisonType.endsWithIgnoreCase: + return value.toLowerCase().endsWith(filterString.toLowerCase()); } } } @@ -61,6 +63,7 @@ extension StringFilterOps on StringFilter { extension TimelineEntryFilterOps on TimelineEntryFilter { bool isFiltered(TimelineEntry entry) { if (authorFilters.isEmpty && + domainFilters.isEmpty && hashtagFilters.isEmpty && contentFilters.isEmpty) { return false; @@ -84,6 +87,16 @@ extension TimelineEntryFilterOps on TimelineEntryFilter { } } + 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 = contentFilters.isEmpty ? true : false; for (final filter in contentFilters) { if (filter.isFiltered(entry.body)) { @@ -92,6 +105,9 @@ extension TimelineEntryFilterOps on TimelineEntryFilter { } } - return authorFiltered && hashtagFiltered && contentFiltered; + return authorFiltered && + domainFiltered && + hashtagFiltered && + contentFiltered; } } diff --git a/test/filter_runner_test.dart b/test/filter_runner_test.dart index d042f90..43131c1 100644 --- a/test/filter_runner_test.dart +++ b/test/filter_runner_test.dart @@ -7,13 +7,47 @@ import 'package:relatica/utils/filter_runner.dart'; void main() { final entries = [ - TimelineEntry(body: 'Hello world', authorId: '1', tags: ['greeting']), - TimelineEntry(body: 'Goodbye', authorId: '1', tags: ['SendOff']), - TimelineEntry(body: 'Lorem ipsum', authorId: '1', tags: ['latin']), - TimelineEntry(body: 'Hello world', authorId: '2', tags: ['greeting']), - TimelineEntry(body: 'Goodbye', authorId: '2', tags: ['SendOff']), - TimelineEntry(body: 'Lorem ipsum', authorId: '2', tags: ['LATIN']), - TimelineEntry(body: 'Chao', authorId: '2', tags: ['sendoff']), + 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', () { @@ -37,20 +71,30 @@ void main() { expect(filter.isFiltered('hello!'), equals(false)); expect(filter.isFiltered('help'), equals(false)); }); - test('Test containsCaseSensitive', () { + 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.containsCaseSensitive, + 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 containsCaseInsensitive', () { + test('Test containsIgnoreCase', () { const filter = StringFilter( filterString: 'hello', - type: ComparisonType.containsCaseInsensitive, + type: ComparisonType.containsIgnoreCase, ); expect(filter.isFiltered('hello world'), equals(true)); expect(filter.isFiltered('Hello World'), equals(true)); @@ -91,6 +135,30 @@ void main() { 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, From 155500fd7054c7ad35150481d63e013576c9528c Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Sun, 7 May 2023 19:06:31 -0400 Subject: [PATCH 06/11] Add leading images to blocks and contacts screen --- lib/screens/blocks_screen.dart | 6 ++++++ lib/screens/contacts_screen.dart | 6 ++++++ 2 files changed, 12 insertions(+) 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, From d2757a7664217e13ed77eeafa656956fe9937157 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Sun, 7 May 2023 19:07:32 -0400 Subject: [PATCH 07/11] Initial filters screen --- lib/controls/filter_control.dart | 495 ++++++++++++++++++ lib/controls/standard_app_drawer.dart | 5 + lib/di_initialization.dart | 11 + lib/main.dart | 6 + lib/models/filters/string_filter.dart | 2 +- lib/models/filters/timeline_entry_filter.dart | 34 +- lib/routes.dart | 7 + lib/screens/filters_screen.dart | 58 ++ .../timeline_entry_filter_service.dart | 81 +++ lib/utils/filter_runner.dart | 6 +- test/filter_runner_test.dart | 6 +- 11 files changed, 696 insertions(+), 15 deletions(-) create mode 100644 lib/controls/filter_control.dart create mode 100644 lib/screens/filters_screen.dart create mode 100644 lib/services/timeline_entry_filter_service.dart diff --git a/lib/controls/filter_control.dart b/lib/controls/filter_control.dart new file mode 100644 index 0000000..c57575a --- /dev/null +++ b/lib/controls/filter_control.dart @@ -0,0 +1,495 @@ +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 '../globals.dart'; +import '../models/connection.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'; +import 'autocomplete/hashtag_autocomplete_options.dart'; +import 'autocomplete/mention_autocomplete_options.dart'; +import 'image_control.dart'; +import 'padding.dart'; + +class FilterControl extends StatefulWidget { + final TimelineEntryFilter initialEntry; + final TimelineEntryFilterService service; + final Function(TimelineEntryFilter)? onUpdate; + final Function(TimelineEntryFilter)? onRemove; + + const FilterControl({ + super.key, + required this.initialEntry, + required this.service, + this.onUpdate, + this.onRemove, + }); + + @override + State createState() => _FilterControlState(); +} + +class _FilterControlState extends State { + static final _logger = Logger('$FilterControl'); + final nameController = TextEditingController(); + var action = TimelineEntryFilterAction.hide; + final filteredAuthors = []; + final filteredDomains = []; + final filteredKeywords = []; + final filteredHashtags = []; + + TimelineEntryFilter get entry => widget.initialEntry; + + @override + void initState() { + super.initState(); + final cm = + getIt>().activeEntry.value; + nameController.text = widget.initialEntry.name; + action = widget.initialEntry.action; + for (final f in widget.initialEntry.authorFilters) { + cm.getById(f.filterString).withResult((c) => filteredAuthors.add(c)); + } + filteredDomains.addAll( + widget.initialEntry.domainFilters.map((f) => f.filterString), + ); + + filteredKeywords.addAll( + widget.initialEntry.keywordFilters.map((f) => f.filterString), + ); + + filteredHashtags.addAll( + widget.initialEntry.hashtagFilters.map((f) => f.filterString), + ); + } + + @override + Widget build(BuildContext context) { + _logger.finer( + 'Build for filter ${widget.initialEntry.id} ${widget.initialEntry.name}'); + final fieldWidth = MediaQuery.of(context).size.width * 0.8; + return Padding( + padding: const EdgeInsets.all(8.0), + 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, + dropdownMenuEntries: TimelineEntryFilterAction.values + .map((a) => DropdownMenuEntry(value: a, label: a.name)) + .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: [ + if (widget.onUpdate != null) ...[ + ElevatedButton( + onPressed: () => + widget.onUpdate!(TimelineEntryFilter.create( + id: widget.initialEntry.id, + action: action, + name: nameController.text, + authors: filteredAuthors, + hashtags: filteredHashtags, + keywords: filteredKeywords, + domains: filteredDomains, + )), + child: const Text('Update')), + const HorizontalPadding() + ], + if (widget.onRemove != null) + ElevatedButton( + onPressed: () => widget.onRemove!(widget.initialEntry), + child: const Text('Remove')), + ], + ) + ], + ), + ); + } + + 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.bottomEnd, + 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.bottomEnd, + 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'), + ), + ], + ); + }); + } +} 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/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 index 05879d1..eeb32e7 100644 --- a/lib/models/filters/string_filter.dart +++ b/lib/models/filters/string_filter.dart @@ -25,7 +25,7 @@ class StringFilter { Map toJson() => { 'filterString': filterString, - 'type': type, + 'type': type.name, }; factory StringFilter.fromJson(Map json) => StringFilter( diff --git a/lib/models/filters/timeline_entry_filter.dart b/lib/models/filters/timeline_entry_filter.dart index 284b2bf..78885ef 100644 --- a/lib/models/filters/timeline_entry_filter.dart +++ b/lib/models/filters/timeline_entry_filter.dart @@ -1,3 +1,5 @@ +import 'package:uuid/uuid.dart'; + import '../connection.dart'; import 'string_filter.dart'; @@ -15,23 +17,26 @@ enum TimelineEntryFilterAction { } class TimelineEntryFilter { + final String id; final TimelineEntryFilterAction action; final String name; final List authorFilters; final List domainFilters; - final List contentFilters; + final List keywordFilters; final List hashtagFilters; const TimelineEntryFilter({ + required this.id, required this.action, required this.name, required this.authorFilters, required this.domainFilters, - required this.contentFilters, + required this.keywordFilters, required this.hashtagFilters, }); factory TimelineEntryFilter.create({ + String? id, required TimelineEntryFilterAction action, required String name, List authors = const [], @@ -40,6 +45,7 @@ class TimelineEntryFilter { List hashtags = const [], }) { return TimelineEntryFilter( + id: id ?? const Uuid().v4(), action: action, name: name, authorFilters: authors @@ -57,7 +63,7 @@ class TimelineEntryFilter { type: ComparisonType.equalsIgnoreCase, )) .toList(), - contentFilters: keywords + keywordFilters: keywords .map((k) => StringFilter( filterString: k, type: ComparisonType.containsIgnoreCase)) .toList(), @@ -68,17 +74,29 @@ class TimelineEntryFilter { ); } + @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, - 'authorFilters': authorFilters.map((f) => f.toJson()), - 'domainFilters': domainFilters.map((f) => f.toJson()), - 'contentFilters': contentFilters.map((f) => f.toJson()), - 'hashtagFilters': hashtagFilters.map((f) => f.toJson()), + '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'], authorFilters: (json['authorFilters'] as List) @@ -87,7 +105,7 @@ class TimelineEntryFilter { domainFilters: (json['domainFilters'] as List) .map((json) => StringFilter.fromJson(json)) .toList(), - contentFilters: (json['contentFilters'] as List) + keywordFilters: (json['keywordFilters'] as List) .map((json) => StringFilter.fromJson(json)) .toList(), hashtagFilters: (json['hashtagFilters'] as List) diff --git a/lib/routes.dart b/lib/routes.dart index 0801cd2..323a3e4 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -5,6 +5,7 @@ import 'models/interaction_type_enum.dart'; import 'screens/blocks_screen.dart'; import 'screens/contacts_screen.dart'; import 'screens/editor.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 +31,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 +82,11 @@ final appRouter = GoRouter( name: ScreenPaths.blocks, builder: (context, state) => const BlocksScreen(), ), + GoRoute( + path: ScreenPaths.filters, + name: ScreenPaths.filters, + builder: (context, state) => const FiltersScreen(), + ), GoRoute( path: ScreenPaths.signin, name: ScreenPaths.signin, diff --git a/lib/screens/filters_screen.dart b/lib/screens/filters_screen.dart new file mode 100644 index 0000000..d7f592e --- /dev/null +++ b/lib/screens/filters_screen.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../controls/filter_control.dart'; +import '../models/filters/timeline_entry_filter.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: () { + service.upsertFilter( + TimelineEntryFilter.create( + action: TimelineEntryFilterAction.warn, name: 'New Filter'), + ); + }, + icon: const Icon(Icons.add), + ), + ], + ), + body: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: ListView.separated( + itemBuilder: (context, index) { + final filter = filters[index]; + return FilterControl( + initialEntry: filter, + service: service, + onUpdate: (update) => service.upsertFilter(update), + onRemove: (_) => service.removeFilter(filter), + ); + }, + separatorBuilder: (_, __) => const Divider(), + itemCount: filters.length), + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/timeline_entry_filter_service.dart b/lib/services/timeline_entry_filter_service.dart new file mode 100644 index 0000000..5874a76 --- /dev/null +++ b/lib/services/timeline_entry_filter_service.dart @@ -0,0 +1,81 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.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); + + 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.addAll(filters); + } catch (e) { + _logger.severe('Error parsing filters file $filePath: $e'); + } + } + + void save() { + try { + final json = _filters.map((f) => f.toJson()).toList(); + final str = jsonEncode(json); + File(filePath).writeAsStringSync(str); + } catch (e) { + _logger.severe('Error writing filters file $filePath: $e'); + } + } + + void upsertFilter(TimelineEntryFilter filter) { + _filters.remove(filter); + _filters.add(filter); + _entryCache.clear(); + save(); + notifyListeners(); + } + + void removeFilter(TimelineEntryFilter filter) { + _filters.remove(filter); + _entryCache.clear(); + save(); + notifyListeners(); + } + + FilterResult checkEntry(TimelineEntry entry) { + if (entry == _entryCache[entry.id]?.entry) { + return _entryCache[entry.id]!.result; + } + + final result = runFilters(entry, _filters.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 index 11c611f..f08a272 100644 --- a/lib/utils/filter_runner.dart +++ b/lib/utils/filter_runner.dart @@ -65,7 +65,7 @@ extension TimelineEntryFilterOps on TimelineEntryFilter { if (authorFilters.isEmpty && domainFilters.isEmpty && hashtagFilters.isEmpty && - contentFilters.isEmpty) { + keywordFilters.isEmpty) { return false; } @@ -97,8 +97,8 @@ extension TimelineEntryFilterOps on TimelineEntryFilter { } } - var contentFiltered = contentFilters.isEmpty ? true : false; - for (final filter in contentFilters) { + var contentFiltered = keywordFilters.isEmpty ? true : false; + for (final filter in keywordFilters) { if (filter.isFiltered(entry.body)) { contentFiltered = true; break; diff --git a/test/filter_runner_test.dart b/test/filter_runner_test.dart index 43131c1..19813e8 100644 --- a/test/filter_runner_test.dart +++ b/test/filter_runner_test.dart @@ -113,7 +113,7 @@ void main() { final actual = entries.map((e) => filter.isFiltered(e)).toList(); expect(actual, equals(expected)); }); - test('Test Content Filter', () { + test('Test Keyword Filter', () { final filter = TimelineEntryFilter.create( action: TimelineEntryFilterAction.hide, name: 'filter', @@ -170,7 +170,7 @@ void main() { expect(actual, equals(expected)); }); - test('Test Author plus content', () { + test('Test Author plus Keyword', () { final filter = TimelineEntryFilter.create( action: TimelineEntryFilterAction.hide, name: 'filter', @@ -194,7 +194,7 @@ void main() { expect(actual, equals(expected)); }); - test('Test Content plus tag', () { + test('Test Keyword plus tag', () { final filter = TimelineEntryFilter.create( action: TimelineEntryFilterAction.hide, name: 'filter', From 6644fee5236ce1501dcdcb60a89a78a80b3e9e61 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Sun, 7 May 2023 20:10:43 -0400 Subject: [PATCH 08/11] Refactor Filter UI into List screen w/summary and editor screen --- lib/controls/filter_control.dart | 495 ----------------- lib/models/filters/timeline_entry_filter.dart | 18 + lib/routes.dart | 14 + lib/screens/filter_editor_screen.dart | 524 ++++++++++++++++++ lib/screens/filters_screen.dart | 66 ++- .../timeline_entry_filter_service.dart | 31 +- 6 files changed, 632 insertions(+), 516 deletions(-) delete mode 100644 lib/controls/filter_control.dart create mode 100644 lib/screens/filter_editor_screen.dart diff --git a/lib/controls/filter_control.dart b/lib/controls/filter_control.dart deleted file mode 100644 index c57575a..0000000 --- a/lib/controls/filter_control.dart +++ /dev/null @@ -1,495 +0,0 @@ -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 '../globals.dart'; -import '../models/connection.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'; -import 'autocomplete/hashtag_autocomplete_options.dart'; -import 'autocomplete/mention_autocomplete_options.dart'; -import 'image_control.dart'; -import 'padding.dart'; - -class FilterControl extends StatefulWidget { - final TimelineEntryFilter initialEntry; - final TimelineEntryFilterService service; - final Function(TimelineEntryFilter)? onUpdate; - final Function(TimelineEntryFilter)? onRemove; - - const FilterControl({ - super.key, - required this.initialEntry, - required this.service, - this.onUpdate, - this.onRemove, - }); - - @override - State createState() => _FilterControlState(); -} - -class _FilterControlState extends State { - static final _logger = Logger('$FilterControl'); - final nameController = TextEditingController(); - var action = TimelineEntryFilterAction.hide; - final filteredAuthors = []; - final filteredDomains = []; - final filteredKeywords = []; - final filteredHashtags = []; - - TimelineEntryFilter get entry => widget.initialEntry; - - @override - void initState() { - super.initState(); - final cm = - getIt>().activeEntry.value; - nameController.text = widget.initialEntry.name; - action = widget.initialEntry.action; - for (final f in widget.initialEntry.authorFilters) { - cm.getById(f.filterString).withResult((c) => filteredAuthors.add(c)); - } - filteredDomains.addAll( - widget.initialEntry.domainFilters.map((f) => f.filterString), - ); - - filteredKeywords.addAll( - widget.initialEntry.keywordFilters.map((f) => f.filterString), - ); - - filteredHashtags.addAll( - widget.initialEntry.hashtagFilters.map((f) => f.filterString), - ); - } - - @override - Widget build(BuildContext context) { - _logger.finer( - 'Build for filter ${widget.initialEntry.id} ${widget.initialEntry.name}'); - final fieldWidth = MediaQuery.of(context).size.width * 0.8; - return Padding( - padding: const EdgeInsets.all(8.0), - 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, - dropdownMenuEntries: TimelineEntryFilterAction.values - .map((a) => DropdownMenuEntry(value: a, label: a.name)) - .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: [ - if (widget.onUpdate != null) ...[ - ElevatedButton( - onPressed: () => - widget.onUpdate!(TimelineEntryFilter.create( - id: widget.initialEntry.id, - action: action, - name: nameController.text, - authors: filteredAuthors, - hashtags: filteredHashtags, - keywords: filteredKeywords, - domains: filteredDomains, - )), - child: const Text('Update')), - const HorizontalPadding() - ], - if (widget.onRemove != null) - ElevatedButton( - onPressed: () => widget.onRemove!(widget.initialEntry), - child: const Text('Remove')), - ], - ) - ], - ), - ); - } - - 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.bottomEnd, - 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.bottomEnd, - 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'), - ), - ], - ); - }); - } -} diff --git a/lib/models/filters/timeline_entry_filter.dart b/lib/models/filters/timeline_entry_filter.dart index 78885ef..7275ea9 100644 --- a/lib/models/filters/timeline_entry_filter.dart +++ b/lib/models/filters/timeline_entry_filter.dart @@ -14,6 +14,24 @@ enum TimelineEntryFilterAction { 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 { diff --git a/lib/routes.dart b/lib/routes.dart index 323a3e4..a51555c 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -5,6 +5,7 @@ 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'; @@ -86,6 +87,19 @@ final appRouter = GoRouter( 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, diff --git a/lib/screens/filter_editor_screen.dart b/lib/screens/filter_editor_screen.dart new file mode 100644 index 0000000..4f0badb --- /dev/null +++ b/lib/screens/filter_editor_screen.dart @@ -0,0 +1,524 @@ +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/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.filterString), + ); + + filteredKeywords.addAll( + filter.keywordFilters.map((f) => f.filterString), + ); + + filteredHashtags.addAll( + filter.hashtagFilters.map((f) => f.filterString), + ); + } + + @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, + 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, + 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.bottomEnd, + 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.bottomEnd, + 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'), + ), + ], + ); + }); + } +} diff --git a/lib/screens/filters_screen.dart b/lib/screens/filters_screen.dart index d7f592e..388531e 100644 --- a/lib/screens/filters_screen.dart +++ b/lib/screens/filters_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -import '../controls/filter_control.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'; @@ -23,10 +25,7 @@ class FiltersScreen extends StatelessWidget { actions: [ IconButton( onPressed: () { - service.upsertFilter( - TimelineEntryFilter.create( - action: TimelineEntryFilterAction.warn, name: 'New Filter'), - ); + context.push('/filters/new'); }, icon: const Icon(Icons.add), ), @@ -39,13 +38,7 @@ class FiltersScreen extends StatelessWidget { Expanded( child: ListView.separated( itemBuilder: (context, index) { - final filter = filters[index]; - return FilterControl( - initialEntry: filter, - service: service, - onUpdate: (update) => service.upsertFilter(update), - onRemove: (_) => service.removeFilter(filter), - ); + return buildFilterSummary(context, filters[index], service); }, separatorBuilder: (_, __) => const Divider(), itemCount: filters.length), @@ -55,4 +48,53 @@ class FiltersScreen extends StatelessWidget { ), ); } + + Widget buildFilterSummary(BuildContext context, TimelineEntryFilter filter, + TimelineEntryFilterService service) { + return ListTile( + 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/services/timeline_entry_filter_service.dart b/lib/services/timeline_entry_filter_service.dart index 5874a76..690c04e 100644 --- a/lib/services/timeline_entry_filter_service.dart +++ b/lib/services/timeline_entry_filter_service.dart @@ -4,7 +4,9 @@ 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'; @@ -12,10 +14,11 @@ import '../utils/filter_runner.dart'; class TimelineEntryFilterService extends ChangeNotifier { static final _logger = Logger('$TimelineEntryFilterService'); final String filePath; - final _filters = {}; + final _filters = {}; final _entryCache = {}; - List get filters => UnmodifiableListView(_filters); + List get filters => + UnmodifiableListView(_filters.values); TimelineEntryFilterService(this.filePath); @@ -30,7 +33,7 @@ class TimelineEntryFilterService extends ChangeNotifier { final json = jsonDecode(str) as List; final filters = json.map((j) => TimelineEntryFilter.fromJson(j)).toList(); _filters.clear(); - _filters.addAll(filters); + _filters.addEntries(filters.map((f) => MapEntry(f.id, f))); } catch (e) { _logger.severe('Error parsing filters file $filePath: $e'); } @@ -38,7 +41,7 @@ class TimelineEntryFilterService extends ChangeNotifier { void save() { try { - final json = _filters.map((f) => f.toJson()).toList(); + final json = _filters.values.map((f) => f.toJson()).toList(); final str = jsonEncode(json); File(filePath).writeAsStringSync(str); } catch (e) { @@ -46,16 +49,26 @@ class TimelineEntryFilterService extends ChangeNotifier { } } + 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.remove(filter); - _filters.add(filter); + _filters[filter.id] = filter; _entryCache.clear(); save(); notifyListeners(); } - void removeFilter(TimelineEntryFilter filter) { - _filters.remove(filter); + void removeById(String id) { + _filters.remove(id); _entryCache.clear(); save(); notifyListeners(); @@ -66,7 +79,7 @@ class TimelineEntryFilterService extends ChangeNotifier { return _entryCache[entry.id]!.result; } - final result = runFilters(entry, _filters.toList()); + final result = runFilters(entry, _filters.values.toList()); _entryCache[entry.id] = _EntryCacheItem(entry, result); return result; From 1c81f05f8de90543afd2ae64497d113f1f148114 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Mon, 8 May 2023 07:18:09 -0400 Subject: [PATCH 09/11] Add initial filter card/hiding work for timeline entries --- .../flattened_tree_entry_control.dart | 179 ++++++++++++------ lib/screens/filter_editor_screen.dart | 5 +- .../timeline_entry_mastodon_extensions.dart | 9 +- .../timeline_entry_filter_service.dart | 2 +- lib/utils/filter_runner.dart | 42 +++- lib/utils/html_to_edit_text_helper.dart | 14 +- test/filter_runner_test.dart | 1 + 7 files changed, 177 insertions(+), 75 deletions(-) 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/screens/filter_editor_screen.dart b/lib/screens/filter_editor_screen.dart index 4f0badb..b24a58d 100644 --- a/lib/screens/filter_editor_screen.dart +++ b/lib/screens/filter_editor_screen.dart @@ -104,6 +104,7 @@ class _FilterEditorScreenState extends State { const Text('Action:'), DropdownMenu( initialSelection: action, + onSelected: (value) => action = value!, dropdownMenuEntries: TimelineEntryFilterAction.values .map((a) => DropdownMenuEntry(value: a, label: a.toLabel())) @@ -396,7 +397,7 @@ class _FilterEditorScreenState extends State { content: MultiTriggerAutocomplete( textEditingController: controller, focusNode: focusNode, - optionsAlignment: OptionsAlignment.bottomEnd, + optionsAlignment: OptionsAlignment.top, autocompleteTriggers: [ AutocompleteTrigger( trigger: '@', @@ -469,7 +470,7 @@ class _FilterEditorScreenState extends State { content: MultiTriggerAutocomplete( textEditingController: controller, focusNode: focusNode, - optionsAlignment: OptionsAlignment.bottomEnd, + optionsAlignment: OptionsAlignment.top, autocompleteTriggers: [ AutocompleteTrigger( trigger: '#', 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/timeline_entry_filter_service.dart b/lib/services/timeline_entry_filter_service.dart index 690c04e..835124f 100644 --- a/lib/services/timeline_entry_filter_service.dart +++ b/lib/services/timeline_entry_filter_service.dart @@ -74,7 +74,7 @@ class TimelineEntryFilterService extends ChangeNotifier { notifyListeners(); } - FilterResult checkEntry(TimelineEntry entry) { + FilterResult checkTimelineEntry(TimelineEntry entry) { if (entry == _entryCache[entry.id]?.entry) { return _entryCache[entry.id]!.result; } diff --git a/lib/utils/filter_runner.dart b/lib/utils/filter_runner.dart index f08a272..1621e06 100644 --- a/lib/utils/filter_runner.dart +++ b/lib/utils/filter_runner.dart @@ -1,12 +1,24 @@ +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); + const FilterResult( + this.isFiltered, + this.action, + this.trippingFilterName, + ); String toActionString() { return isFiltered ? action.name : 'show'; @@ -18,10 +30,12 @@ class FilterResult { other is FilterResult && runtimeType == other.runtimeType && action == other.action && - isFiltered == other.isFiltered; + isFiltered == other.isFiltered && + trippingFilterName == other.trippingFilterName; @override - int get hashCode => action.hashCode ^ isFiltered.hashCode; + int get hashCode => + action.hashCode ^ isFiltered.hashCode ^ trippingFilterName.hashCode; } FilterResult runFilters( @@ -30,17 +44,23 @@ FilterResult runFilters( ) { var isFiltered = false; var action = TimelineEntryFilterAction.warn; + var trippingFilterName = ''; for (final filter in filters) { 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); + return FilterResult(isFiltered, action, trippingFilterName); } extension StringFilterOps on StringFilter { @@ -49,7 +69,10 @@ extension StringFilterOps on StringFilter { case ComparisonType.contains: return value.contains(filterString); case ComparisonType.containsIgnoreCase: - return value.toLowerCase().contains(filterString.toLowerCase()); + final lv = value.toLowerCase(); + final lf = filterString.toLowerCase(); + final c = lv.contains(lf); + return c; case ComparisonType.equals: return value == filterString; case ComparisonType.equalsIgnoreCase: @@ -71,7 +94,9 @@ extension TimelineEntryFilterOps on TimelineEntryFilter { var authorFiltered = authorFilters.isEmpty ? true : false; for (final filter in authorFilters) { - if (filter.isFiltered(entry.authorId)) { + if (filter.isFiltered(entry.authorId) || + filter.isFiltered(entry.reshareAuthorId) || + filter.isFiltered(entry.parentAuthorId)) { authorFiltered = true; break; } @@ -98,8 +123,11 @@ extension TimelineEntryFilterOps on TimelineEntryFilter { } var contentFiltered = keywordFilters.isEmpty ? true : false; + final simplifiedBody = keywordFilters.isNotEmpty + ? htmlToSimpleText(entry.body).toLowerCase() + : ''; for (final filter in keywordFilters) { - if (filter.isFiltered(entry.body)) { + if (filter.isFiltered(simplifiedBody)) { contentFiltered = true; break; } 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 index 19813e8..f840cfb 100644 --- a/test/filter_runner_test.dart +++ b/test/filter_runner_test.dart @@ -99,6 +99,7 @@ void main() { 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)); }); }); From 1474bbc69abe6adb23d41b42849bb0307b3cbad8 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Mon, 8 May 2023 07:47:27 -0400 Subject: [PATCH 10/11] Fix filter editor card for "ends with" filter strings --- lib/screens/filter_editor_screen.dart | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/screens/filter_editor_screen.dart b/lib/screens/filter_editor_screen.dart index b24a58d..4e60b29 100644 --- a/lib/screens/filter_editor_screen.dart +++ b/lib/screens/filter_editor_screen.dart @@ -10,6 +10,7 @@ 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'; @@ -52,15 +53,15 @@ class _FilterEditorScreenState extends State { cm.getById(f.filterString).withResult((c) => filteredAuthors.add(c)); } filteredDomains.addAll( - filter.domainFilters.map((f) => f.filterString), + filter.domainFilters.map((f) => f.toLabel()), ); filteredKeywords.addAll( - filter.keywordFilters.map((f) => f.filterString), + filter.keywordFilters.map((f) => f.toLabel()), ); filteredHashtags.addAll( - filter.hashtagFilters.map((f) => f.filterString), + filter.hashtagFilters.map((f) => f.toLabel()), ); } @@ -523,3 +524,17 @@ class _FilterEditorScreenState extends State { }); } } + +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; + } + } +} From 55052b08524553f2da6d83210ac5329c6a8eb972 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Mon, 8 May 2023 09:32:00 -0400 Subject: [PATCH 11/11] Add ability to toggle filters on/off --- lib/models/filters/timeline_entry_filter.dart | 27 +++++++++++++++++++ lib/screens/filter_editor_screen.dart | 1 + lib/screens/filters_screen.dart | 4 +++ lib/utils/filter_runner.dart | 2 +- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/lib/models/filters/timeline_entry_filter.dart b/lib/models/filters/timeline_entry_filter.dart index 7275ea9..a57887b 100644 --- a/lib/models/filters/timeline_entry_filter.dart +++ b/lib/models/filters/timeline_entry_filter.dart @@ -38,6 +38,7 @@ class TimelineEntryFilter { final String id; final TimelineEntryFilterAction action; final String name; + final bool enabled; final List authorFilters; final List domainFilters; final List keywordFilters; @@ -47,16 +48,39 @@ class 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 [], @@ -66,6 +90,7 @@ class TimelineEntryFilter { id: id ?? const Uuid().v4(), action: action, name: name, + enabled: enabled, authorFilters: authors .map((a) => StringFilter(filterString: a.id, type: ComparisonType.equals)) @@ -106,6 +131,7 @@ class TimelineEntryFilter { '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(), @@ -117,6 +143,7 @@ class 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(), diff --git a/lib/screens/filter_editor_screen.dart b/lib/screens/filter_editor_screen.dart index 4e60b29..9434b9a 100644 --- a/lib/screens/filter_editor_screen.dart +++ b/lib/screens/filter_editor_screen.dart @@ -335,6 +335,7 @@ class _FilterEditorScreenState extends State { id: widget.id.isNotEmpty ? widget.id : null, action: action, name: nameController.text, + enabled: true, authors: filteredAuthors, hashtags: filteredHashtags, keywords: filteredKeywords, diff --git a/lib/screens/filters_screen.dart b/lib/screens/filters_screen.dart index 388531e..3bdfbca 100644 --- a/lib/screens/filters_screen.dart +++ b/lib/screens/filters_screen.dart @@ -52,6 +52,10 @@ class FiltersScreen extends StatelessWidget { 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(), diff --git a/lib/utils/filter_runner.dart b/lib/utils/filter_runner.dart index 1621e06..7a712fc 100644 --- a/lib/utils/filter_runner.dart +++ b/lib/utils/filter_runner.dart @@ -45,7 +45,7 @@ FilterResult runFilters( var isFiltered = false; var action = TimelineEntryFilterAction.warn; var trippingFilterName = ''; - for (final filter in filters) { + for (final filter in filters.where((f) => f.enabled)) { if (filter.isFiltered(entry)) { isFiltered = true; if (trippingFilterName.isEmpty) {