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)); }); });