Merge branch 'filtering' into 'main'

Filtering

See merge request mysocialportal/relatica!40
codemagic-setup
HankG 2023-05-08 13:37:20 +00:00
commit ea1783d220
22 zmienionych plików z 1563 dodań i 69 usunięć

Wyświetl plik

@ -79,6 +79,11 @@ class StandardAppDrawer extends StatelessWidget {
'Blocks',
() => context.pushNamed(ScreenPaths.blocks),
),
buildMenuButton(
context,
'Filters',
() => context.pushNamed(ScreenPaths.filters),
),
buildMenuButton(
context,
'Groups Management',

Wyświetl plik

@ -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<FlattenedTreeEntryControl> {
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<FlattenedTreeEntryControl> {
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<FlattenedTreeEntryControl> {
@override
Widget build(BuildContext context) {
_logger.finest('Building ${entry.toShortString()}');
final filterService = context
.watch<ActiveProfileSelector<TimelineEntryFilterService>>()
.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<FlattenedTreeEntryControl> {
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<FlattenedTreeEntryControl> {
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 =>

Wyświetl plik

@ -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<void> dependencyInjectionInitialization() async {
},
),
);
getIt.registerSingleton<ActiveProfileSelector<TimelineEntryFilterService>>(
ActiveProfileSelector(
(profile) {
final profilePersistencePath =
p.join(appSupportdir.path, '${profile.id}_filters.json');
return TimelineEntryFilterService(profilePersistencePath)..load();
},
),
);
final objectBoxCache = await ObjectBoxCache.create();
getIt.registerSingleton<ObjectBoxCache>(objectBoxCache);
getIt.registerSingleton<IHashtagRepo>(ObjectBoxHashtagRepo());

Wyświetl plik

@ -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<ActiveProfileSelector<BlocksManager>>(
create: (_) => getIt<ActiveProfileSelector<BlocksManager>>(),
),
ChangeNotifierProvider<
ActiveProfileSelector<TimelineEntryFilterService>>(
create: (_) =>
getIt<ActiveProfileSelector<TimelineEntryFilterService>>(),
),
],
child: MaterialApp.router(
useInheritedMediaQuery: true,

Wyświetl plik

@ -0,0 +1,35 @@
enum ComparisonType {
contains,
containsIgnoreCase,
endsWithIgnoreCase,
equals,
equalsIgnoreCase,
;
factory ComparisonType.parse(String? value) {
return ComparisonType.values.firstWhere(
(v) => v.name == value,
orElse: () => equals,
);
}
}
class StringFilter {
final String filterString;
final ComparisonType type;
const StringFilter({
required this.filterString,
required this.type,
});
Map<String, dynamic> toJson() => {
'filterString': filterString,
'type': type.name,
};
factory StringFilter.fromJson(Map<String, dynamic> json) => StringFilter(
filterString: json['filterString'],
type: ComparisonType.parse(json['type']),
);
}

Wyświetl plik

@ -0,0 +1,160 @@
import 'package:uuid/uuid.dart';
import '../connection.dart';
import 'string_filter.dart';
enum TimelineEntryFilterAction {
hide,
warn,
;
factory TimelineEntryFilterAction.parse(String? value) {
return TimelineEntryFilterAction.values.firstWhere(
(v) => v.name == value,
orElse: () => warn,
);
}
String toLabel() {
switch (this) {
case TimelineEntryFilterAction.hide:
return 'Hide';
case TimelineEntryFilterAction.warn:
return 'Warn';
}
}
String toVerb() {
switch (this) {
case TimelineEntryFilterAction.hide:
return 'Hiding';
case TimelineEntryFilterAction.warn:
return 'Warning';
}
}
}
class TimelineEntryFilter {
final String id;
final TimelineEntryFilterAction action;
final String name;
final bool enabled;
final List<StringFilter> authorFilters;
final List<StringFilter> domainFilters;
final List<StringFilter> keywordFilters;
final List<StringFilter> hashtagFilters;
const TimelineEntryFilter({
required this.id,
required this.action,
required this.name,
required this.enabled,
required this.authorFilters,
required this.domainFilters,
required this.keywordFilters,
required this.hashtagFilters,
});
TimelineEntryFilter copy({
String? id,
TimelineEntryFilterAction? action,
String? name,
bool? enabled,
List<StringFilter>? authorFilters,
List<StringFilter>? domainFilters,
List<StringFilter>? keywordFilters,
List<StringFilter>? 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<Connection> authors = const [],
List<String> domains = const [],
List<String> keywords = const [],
List<String> hashtags = const [],
}) {
return TimelineEntryFilter(
id: id ?? const Uuid().v4(),
action: action,
name: name,
enabled: enabled,
authorFilters: authors
.map((a) =>
StringFilter(filterString: a.id, type: ComparisonType.equals))
.toList(),
domainFilters: domains
.map((d) => d.startsWith('*')
? StringFilter(
filterString: d.substring(1),
type: ComparisonType.endsWithIgnoreCase,
)
: StringFilter(
filterString: d,
type: ComparisonType.equalsIgnoreCase,
))
.toList(),
keywordFilters: keywords
.map((k) => StringFilter(
filterString: k, type: ComparisonType.containsIgnoreCase))
.toList(),
hashtagFilters: hashtags
.map((h) => StringFilter(
filterString: h, type: ComparisonType.equalsIgnoreCase))
.toList(),
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TimelineEntryFilter &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
Map<String, dynamic> toJson() => {
'id': id,
'action': action.name,
'name': name,
'enabled': enabled,
'authorFilters': authorFilters.map((f) => f.toJson()).toList(),
'domainFilters': domainFilters.map((f) => f.toJson()).toList(),
'keywordFilters': keywordFilters.map((f) => f.toJson()).toList(),
'hashtagFilters': hashtagFilters.map((f) => f.toJson()).toList(),
};
factory TimelineEntryFilter.fromJson(Map<String, dynamic> json) =>
TimelineEntryFilter(
id: json['id'],
action: TimelineEntryFilterAction.parse(json['action']),
name: json['name'],
enabled: json['enabled'] ?? true,
authorFilters: (json['authorFilters'] as List<dynamic>)
.map((json) => StringFilter.fromJson(json))
.toList(),
domainFilters: (json['domainFilters'] as List<dynamic>)
.map((json) => StringFilter.fromJson(json))
.toList(),
keywordFilters: (json['keywordFilters'] as List<dynamic>)
.map((json) => StringFilter.fromJson(json))
.toList(),
hashtagFilters: (json['hashtagFilters'] as List<dynamic>)
.map((json) => StringFilter.fromJson(json))
.toList(),
);
}

Wyświetl plik

@ -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;

Wyświetl plik

@ -48,6 +48,8 @@ class TimelineEntry {
final bool isFavorited;
final List<String> tags;
final List<LinkData> links;
final List<Connection> 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<String>? tags,
List<LinkData>? links,
List<Connection>? likes,
List<Connection>? 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 ^

Wyświetl plik

@ -5,6 +5,8 @@ import 'models/interaction_type_enum.dart';
import 'screens/blocks_screen.dart';
import 'screens/contacts_screen.dart';
import 'screens/editor.dart';
import 'screens/filter_editor_screen.dart';
import 'screens/filters_screen.dart';
import 'screens/follow_request_adjudication_screen.dart';
import 'screens/gallery_browsers_screen.dart';
import 'screens/gallery_screen.dart';
@ -30,6 +32,7 @@ import 'services/auth_service.dart';
class ScreenPaths {
static String blocks = '/blocks';
static String filters = '/filters';
static String thread = '/thread';
static String connectHandle = '/connect';
static String contacts = '/contacts';
@ -80,6 +83,24 @@ final appRouter = GoRouter(
name: ScreenPaths.blocks,
builder: (context, state) => const BlocksScreen(),
),
GoRoute(
path: ScreenPaths.filters,
name: ScreenPaths.filters,
builder: (context, state) => const FiltersScreen(),
routes: [
GoRoute(
path: 'new',
pageBuilder: (context, state) => const NoTransitionPage(
child: FilterEditorScreen(id: ''),
),
),
GoRoute(
path: 'edit/:id',
pageBuilder: (context, state) => NoTransitionPage(
child: FilterEditorScreen(id: state.params['id']!)),
)
],
),
GoRoute(
path: ScreenPaths.signin,
name: ScreenPaths.signin,

Wyświetl plik

@ -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,

Wyświetl plik

@ -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<ContactsScreen> {
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,

Wyświetl plik

@ -0,0 +1,541 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart';
import 'package:provider/provider.dart';
import '../controls/autocomplete/hashtag_autocomplete_options.dart';
import '../controls/autocomplete/mention_autocomplete_options.dart';
import '../controls/image_control.dart';
import '../controls/padding.dart';
import '../globals.dart';
import '../models/connection.dart';
import '../models/filters/string_filter.dart';
import '../models/filters/timeline_entry_filter.dart';
import '../services/connections_manager.dart';
import '../services/timeline_entry_filter_service.dart';
import '../utils/active_profile_selector.dart';
import '../utils/snackbar_builder.dart';
class FilterEditorScreen extends StatefulWidget {
final String id;
const FilterEditorScreen({super.key, required this.id});
@override
State<FilterEditorScreen> createState() => _FilterEditorScreenState();
}
class _FilterEditorScreenState extends State<FilterEditorScreen> {
static final _logger = Logger('$FilterEditorScreen');
final nameController = TextEditingController();
var action = TimelineEntryFilterAction.hide;
final filteredAuthors = <Connection>[];
final filteredDomains = <String>[];
final filteredKeywords = <String>[];
final filteredHashtags = <String>[];
@override
void initState() {
super.initState();
if (widget.id.isEmpty) {
return;
}
final filter = getIt<ActiveProfileSelector<TimelineEntryFilterService>>()
.activeEntry
.andThen((tfs) => tfs.getForId(widget.id))
.value;
final cm =
getIt<ActiveProfileSelector<ConnectionsManager>>().activeEntry.value;
nameController.text = filter.name;
action = filter.action;
for (final f in filter.authorFilters) {
cm.getById(f.filterString).withResult((c) => filteredAuthors.add(c));
}
filteredDomains.addAll(
filter.domainFilters.map((f) => f.toLabel()),
);
filteredKeywords.addAll(
filter.keywordFilters.map((f) => f.toLabel()),
);
filteredHashtags.addAll(
filter.hashtagFilters.map((f) => f.toLabel()),
);
}
@override
Widget build(BuildContext context) {
_logger.finer('Build for filter ${widget.id}');
final fieldWidth = MediaQuery.of(context).size.width * 0.8;
final service = context
.watch<ActiveProfileSelector<TimelineEntryFilterService>>()
.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<TimelineEntryFilterAction>(
initialSelection: action,
onSelected: (value) => action = value!,
dropdownMenuEntries: TimelineEntryFilterAction.values
.map((a) =>
DropdownMenuEntry(value: a, label: a.toLabel()))
.toList()),
const VerticalPadding(),
const Text('Authors:'),
Container(
width: fieldWidth,
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(5.0)),
border:
Border.all(color: Theme.of(context).dividerColor)),
child: Wrap(children: [
IconButton(
onPressed: () async {
final newConnection =
await promptForConnection(context);
if (!mounted) {
return;
}
if (newConnection == null) {
return;
}
if (filteredAuthors.contains(newConnection)) {
buildSnackbar(
context,
'Already filtering on ${newConnection.handle}',
);
}
setState(() {
filteredAuthors.add(newConnection);
});
},
icon: const Icon(Icons.add),
),
...filteredAuthors.map(
(a) => Padding(
padding: const EdgeInsets.all(4.0),
child: Card(
child: Padding(
padding: const EdgeInsets.only(left: 5.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageControl(
imageUrl: a.avatarUrl.toString(),
iconOverride: const Icon(Icons.person),
width: 24.0,
),
const HorizontalPadding(
width: 2.0,
),
Flexible(
child: Text(
'${a.name} (${a.handle})',
softWrap: true,
maxLines: 10,
),
),
IconButton(
tooltip: 'Delete',
onPressed: () => setState(() {
filteredAuthors.remove(a);
}),
icon: const Icon(Icons.cancel)),
]),
),
),
),
)
]),
),
const VerticalPadding(),
const Text('Hashtags:'),
Container(
width: fieldWidth,
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(5.0)),
border:
Border.all(color: Theme.of(context).dividerColor)),
child: Wrap(children: [
IconButton(
onPressed: () async {
final newValue = await promptForHashtag(context);
if (newValue == null || newValue.isEmpty) {
return;
}
setState(() {
filteredHashtags.add(newValue);
});
},
icon: const Icon(Icons.add),
),
...filteredHashtags.map(
(h) => Padding(
padding: const EdgeInsets.all(4.0),
child: Card(
child: Padding(
padding: const EdgeInsets.only(left: 5.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child:
Text(h, softWrap: true, maxLines: 10),
),
IconButton(
tooltip: 'Delete',
onPressed: () => setState(() {
filteredHashtags.remove(h);
}),
icon: const Icon(Icons.cancel)),
]),
),
),
),
)
]),
),
const VerticalPadding(),
const Text('Keywords:'),
Container(
width: fieldWidth,
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(5.0)),
border:
Border.all(color: Theme.of(context).dividerColor)),
child: Wrap(children: [
IconButton(
onPressed: () async {
final newValue = await promptForString(context);
if (newValue == null || newValue.isEmpty) {
return;
}
setState(() {
filteredKeywords.add(newValue);
});
},
icon: const Icon(Icons.add),
),
...filteredKeywords.map(
(k) => Padding(
padding: const EdgeInsets.all(4.0),
child: Card(
child: Padding(
padding: const EdgeInsets.only(left: 5.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child:
Text(k, softWrap: true, maxLines: 10),
),
IconButton(
tooltip: 'Delete',
onPressed: () => setState(() {
filteredKeywords.remove(k);
}),
icon: const Icon(Icons.cancel)),
]),
),
),
),
)
]),
),
const VerticalPadding(),
const Text('Domains:'),
Container(
width: fieldWidth,
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(5.0)),
border:
Border.all(color: Theme.of(context).dividerColor)),
child: Wrap(children: [
IconButton(
onPressed: () async {
final newValue = await promptForString(context);
if (newValue == null || newValue.isEmpty) {
return;
}
setState(() {
filteredDomains.add(newValue);
});
},
icon: const Icon(Icons.add),
),
...filteredDomains.map(
(d) => Padding(
padding: const EdgeInsets.all(4.0),
child: Card(
child: Padding(
padding: const EdgeInsets.only(left: 5.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child:
Text(d, softWrap: true, maxLines: 10),
),
IconButton(
tooltip: 'Delete',
onPressed: () => setState(() {
filteredDomains.remove(d);
}),
icon: const Icon(Icons.cancel)),
]),
),
),
),
)
]),
),
const VerticalPadding(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
final update = TimelineEntryFilter.create(
id: widget.id.isNotEmpty ? widget.id : null,
action: action,
name: nameController.text,
enabled: true,
authors: filteredAuthors,
hashtags: filteredHashtags,
keywords: filteredKeywords,
domains: filteredDomains,
);
service.upsertFilter(update);
if (context.canPop()) {
context.pop();
}
},
child: Text(widget.id.isEmpty ? 'Add' : 'Update')),
],
),
const VerticalPadding(),
],
),
)),
),
);
}
Future<String?> 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<Connection?> promptForConnection(BuildContext context) async {
final focusNode = FocusNode();
return await showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
final controller = TextEditingController();
return AlertDialog(
content: MultiTriggerAutocomplete(
textEditingController: controller,
focusNode: focusNode,
optionsAlignment: OptionsAlignment.top,
autocompleteTriggers: [
AutocompleteTrigger(
trigger: '@',
triggerOnlyAfterSpace: false,
optionsViewBuilder:
(ovbContext, autocompleteQuery, controller) {
return MentionAutocompleteOptions(
query: autocompleteQuery.query,
onMentionUserTap: (user) {
final autocomplete =
MultiTriggerAutocomplete.of(ovbContext);
return autocomplete
.acceptAutocompleteOption(user.handle);
},
);
},
),
],
fieldViewBuilder: (fvbContext, controller, focusNode) =>
TextFormField(
focusNode: focusNode,
controller: controller,
decoration: InputDecoration(
labelText: 'Author (@<user>@domain)',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderSide: const BorderSide(),
borderRadius: BorderRadius.circular(5.0),
),
),
),
),
actions: [
ElevatedButton(
onPressed: () {
final rval =
getIt<ActiveProfileSelector<ConnectionsManager>>()
.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<String?> promptForHashtag(BuildContext context) async {
final focusNode = FocusNode();
return await showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
final controller = TextEditingController();
return AlertDialog(
content: MultiTriggerAutocomplete(
textEditingController: controller,
focusNode: focusNode,
optionsAlignment: OptionsAlignment.top,
autocompleteTriggers: [
AutocompleteTrigger(
trigger: '#',
triggerOnlyAfterSpace: false,
optionsViewBuilder:
(ovbContext, autocompleteQuery, controller) {
return HashtagAutocompleteOptions(
query: autocompleteQuery.query,
onHashtagTap: (hashtag) {
final autocomplete =
MultiTriggerAutocomplete.of(ovbContext);
return autocomplete.acceptAutocompleteOption(hashtag);
},
);
},
),
],
fieldViewBuilder: (fvbContext, controller, focusNode) =>
TextFormField(
focusNode: focusNode,
controller: controller,
decoration: InputDecoration(
labelText: 'Hashtag (#<hashtag>)',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderSide: const BorderSide(),
borderRadius: BorderRadius.circular(5.0),
),
),
),
),
actions: [
ElevatedButton(
onPressed: () {
final rval = controller.text.trim();
if (rval.startsWith('#')) {
dialogContext.pop(rval.substring(1));
} else {
dialogContext.pop(rval);
}
},
child: const Text('OK'),
),
ElevatedButton(
onPressed: () => context.pop(),
child: const Text('Cancel'),
),
],
);
});
}
}
extension StringFilterLabel on StringFilter {
String toLabel() {
switch (type) {
case ComparisonType.endsWithIgnoreCase:
return '*$filterString';
case ComparisonType.contains:
case ComparisonType.containsIgnoreCase:
case ComparisonType.equals:
case ComparisonType.equalsIgnoreCase:
return filterString;
}
}
}

Wyświetl plik

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../globals.dart';
import '../models/filters/timeline_entry_filter.dart';
import '../services/connections_manager.dart';
import '../services/timeline_entry_filter_service.dart';
import '../utils/active_profile_selector.dart';
class FiltersScreen extends StatelessWidget {
const FiltersScreen({super.key});
@override
Widget build(BuildContext context) {
final service = context
.watch<ActiveProfileSelector<TimelineEntryFilterService>>()
.activeEntry
.value;
final filters = service.filters;
return Scaffold(
appBar: AppBar(
title: const Text('Filters'),
actions: [
IconButton(
onPressed: () {
context.push('/filters/new');
},
icon: const Icon(Icons.add),
),
],
),
body: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
child: ListView.separated(
itemBuilder: (context, index) {
return buildFilterSummary(context, filters[index], service);
},
separatorBuilder: (_, __) => const Divider(),
itemCount: filters.length),
),
],
),
),
);
}
Widget buildFilterSummary(BuildContext context, TimelineEntryFilter filter,
TimelineEntryFilterService service) {
return ListTile(
leading: Checkbox(
onChanged: (value) => service.upsertFilter(filter.copy(enabled: value)),
value: filter.enabled,
),
title: Text('${filter.action.toVerb()} Filter: ${filter.name}'),
subtitle: Text(
filter.toSummaryText(),
maxLines: 10,
softWrap: true,
),
trailing: IconButton(
onPressed: () async {
final confirm =
await showYesNoDialog(context, 'Delete filter ${filter.name}?');
if (confirm == true) {
service.removeById(filter.id);
}
},
icon: const Icon(Icons.remove)),
onTap: () => context.push('/filters/edit/${filter.id}'),
);
}
}
extension _TimelineEntryFilterSummary on TimelineEntryFilter {
String toSummaryText() {
var authorsString = '';
if (authorFilters.isNotEmpty) {
final cm =
getIt<ActiveProfileSelector<ConnectionsManager>>().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');
}
}

Wyświetl plik

@ -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

Wyświetl plik

@ -91,7 +91,7 @@ class _MessageThreadScreenState extends State<MessageThreadScreen> {
? null
: const TextStyle(fontWeight: FontWeight.bold),
),
trailing: Text(DateTime.fromMillisecondsSinceEpoch(
subtitle: Text(DateTime.fromMillisecondsSinceEpoch(
m.createdAt * 1000)
.toString()),
);

Wyświetl plik

@ -90,11 +90,13 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
reshareOriginalPostId = '';
}
final List<dynamic>? tags = json['tags'];
if (tags?.isNotEmpty ?? false) {
final List<dynamic>? tagsJson = json['tags'];
final tags = <String>[];
if (tagsJson?.isNotEmpty ?? false) {
final tagManager = getIt<HashtagService>();
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,

Wyświetl plik

@ -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(

Wyświetl plik

@ -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',
);
}

Wyświetl plik

@ -0,0 +1,94 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
import '../models/exec_error.dart';
import '../models/filters/timeline_entry_filter.dart';
import '../models/timeline_entry.dart';
import '../utils/filter_runner.dart';
class TimelineEntryFilterService extends ChangeNotifier {
static final _logger = Logger('$TimelineEntryFilterService');
final String filePath;
final _filters = <String, TimelineEntryFilter>{};
final _entryCache = <String, _EntryCacheItem>{};
List<TimelineEntryFilter> get filters =>
UnmodifiableListView(_filters.values);
TimelineEntryFilterService(this.filePath);
void load() {
final file = File(filePath);
if (!file.existsSync()) {
return;
}
try {
final str = file.readAsStringSync();
final json = jsonDecode(str) as List<dynamic>;
final filters = json.map((j) => TimelineEntryFilter.fromJson(j)).toList();
_filters.clear();
_filters.addEntries(filters.map((f) => MapEntry(f.id, f)));
} catch (e) {
_logger.severe('Error parsing filters file $filePath: $e');
}
}
void save() {
try {
final json = _filters.values.map((f) => f.toJson()).toList();
final str = jsonEncode(json);
File(filePath).writeAsStringSync(str);
} catch (e) {
_logger.severe('Error writing filters file $filePath: $e');
}
}
Result<TimelineEntryFilter, ExecError> getForId(String id) {
if (!_filters.containsKey(id)) {
return buildErrorResult(
type: ErrorType.notFound,
message: 'No filter with id: $id',
);
}
return Result.ok(_filters[id]!);
}
void upsertFilter(TimelineEntryFilter filter) {
_filters[filter.id] = filter;
_entryCache.clear();
save();
notifyListeners();
}
void removeById(String id) {
_filters.remove(id);
_entryCache.clear();
save();
notifyListeners();
}
FilterResult checkTimelineEntry(TimelineEntry entry) {
if (entry == _entryCache[entry.id]?.entry) {
return _entryCache[entry.id]!.result;
}
final result = runFilters(entry, _filters.values.toList());
_entryCache[entry.id] = _EntryCacheItem(entry, result);
return result;
}
}
class _EntryCacheItem {
final TimelineEntry entry;
final FilterResult result;
_EntryCacheItem(this.entry, this.result);
}

Wyświetl plik

@ -0,0 +1,141 @@
import 'package:relatica/utils/html_to_edit_text_helper.dart';
import '../models/filters/string_filter.dart';
import '../models/filters/timeline_entry_filter.dart';
import '../models/timeline_entry.dart';
class FilterResult {
static const show = FilterResult(
false,
TimelineEntryFilterAction.warn,
'',
);
final TimelineEntryFilterAction action;
final bool isFiltered;
final String trippingFilterName;
const FilterResult(
this.isFiltered,
this.action,
this.trippingFilterName,
);
String toActionString() {
return isFiltered ? action.name : 'show';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FilterResult &&
runtimeType == other.runtimeType &&
action == other.action &&
isFiltered == other.isFiltered &&
trippingFilterName == other.trippingFilterName;
@override
int get hashCode =>
action.hashCode ^ isFiltered.hashCode ^ trippingFilterName.hashCode;
}
FilterResult runFilters(
TimelineEntry entry,
List<TimelineEntryFilter> filters,
) {
var isFiltered = false;
var action = TimelineEntryFilterAction.warn;
var trippingFilterName = '';
for (final filter in filters.where((f) => f.enabled)) {
if (filter.isFiltered(entry)) {
isFiltered = true;
if (trippingFilterName.isEmpty) {
trippingFilterName = filter.name;
}
if (filter.action == TimelineEntryFilterAction.hide) {
action = TimelineEntryFilterAction.hide;
trippingFilterName = filter.name;
break;
}
}
}
return FilterResult(isFiltered, action, trippingFilterName);
}
extension StringFilterOps on StringFilter {
bool isFiltered(String value) {
switch (type) {
case ComparisonType.contains:
return value.contains(filterString);
case ComparisonType.containsIgnoreCase:
final lv = value.toLowerCase();
final lf = filterString.toLowerCase();
final c = lv.contains(lf);
return c;
case ComparisonType.equals:
return value == filterString;
case ComparisonType.equalsIgnoreCase:
return value.toLowerCase() == filterString.toLowerCase();
case ComparisonType.endsWithIgnoreCase:
return value.toLowerCase().endsWith(filterString.toLowerCase());
}
}
}
extension TimelineEntryFilterOps on TimelineEntryFilter {
bool isFiltered(TimelineEntry entry) {
if (authorFilters.isEmpty &&
domainFilters.isEmpty &&
hashtagFilters.isEmpty &&
keywordFilters.isEmpty) {
return false;
}
var authorFiltered = authorFilters.isEmpty ? true : false;
for (final filter in authorFilters) {
if (filter.isFiltered(entry.authorId) ||
filter.isFiltered(entry.reshareAuthorId) ||
filter.isFiltered(entry.parentAuthorId)) {
authorFiltered = true;
break;
}
}
var hashtagFiltered = hashtagFilters.isEmpty ? true : false;
for (final filter in hashtagFilters) {
for (final tag in entry.tags) {
if (filter.isFiltered(tag)) {
hashtagFiltered = true;
break;
}
}
}
var domainFiltered = domainFilters.isEmpty ? true : false;
for (final filter in domainFilters) {
final domain =
Uri.tryParse(entry.externalLink)?.host ?? entry.externalLink;
if (filter.isFiltered(domain)) {
domainFiltered = true;
break;
}
}
var contentFiltered = keywordFilters.isEmpty ? true : false;
final simplifiedBody = keywordFilters.isNotEmpty
? htmlToSimpleText(entry.body).toLowerCase()
: '';
for (final filter in keywordFilters) {
if (filter.isFiltered(simplifiedBody)) {
contentFiltered = true;
break;
}
}
return authorFiltered &&
domainFiltered &&
hashtagFiltered &&
contentFiltered;
}
}

Wyświetl plik

@ -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 {

Wyświetl plik

@ -0,0 +1,274 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relatica/models/connection.dart';
import 'package:relatica/models/filters/string_filter.dart';
import 'package:relatica/models/filters/timeline_entry_filter.dart';
import 'package:relatica/models/timeline_entry.dart';
import 'package:relatica/utils/filter_runner.dart';
void main() {
final entries = [
TimelineEntry(
body: 'Hello world',
authorId: '1',
tags: ['greeting'],
externalLink: 'http://mastodon.social/@user1/1234',
),
TimelineEntry(
body: 'Goodbye',
authorId: '1',
tags: ['SendOff'],
externalLink: 'http://mastodon.social/@user1/4567'),
TimelineEntry(
body: 'Lorem ipsum',
authorId: '1',
tags: ['latin'],
externalLink: 'http://mastodon.social/@user1/7890',
),
TimelineEntry(
body: 'Hello world',
authorId: '2',
tags: ['greeting'],
externalLink: 'http://trolltodon.social/@user2/12',
),
TimelineEntry(
body: 'Goodbye',
authorId: '2',
tags: ['SendOff'],
externalLink: 'http://trolltodon.social/@user2/34',
),
TimelineEntry(
body: 'Lorem ipsum',
authorId: '2',
tags: ['LATIN'],
externalLink: 'http://trolltodon.social/@user2/56',
),
TimelineEntry(
body: 'Chao',
authorId: '2',
tags: ['sendoff'],
externalLink: 'http://trolltodon.social/@user2/78',
),
];
group('Test StringFilter', () {
test('Test equals', () {
const filter = StringFilter(
filterString: 'hello',
type: ComparisonType.equals,
);
expect(filter.isFiltered('hello'), equals(true));
expect(filter.isFiltered('Hello'), equals(false));
expect(filter.isFiltered('hello!'), equals(false));
expect(filter.isFiltered('help'), equals(false));
});
test('Test equalsIgnoreCase', () {
const filter = StringFilter(
filterString: 'hello',
type: ComparisonType.equalsIgnoreCase,
);
expect(filter.isFiltered('hello'), equals(true));
expect(filter.isFiltered('Hello'), equals(true));
expect(filter.isFiltered('hello!'), equals(false));
expect(filter.isFiltered('help'), equals(false));
});
test('Test endsWithIgnoresCase', () {
const filter = StringFilter(
filterString: 'world',
type: ComparisonType.endsWithIgnoreCase,
);
expect(filter.isFiltered('world'), equals(true));
expect(filter.isFiltered('hello WORld'), equals(true));
expect(filter.isFiltered('worldwide'), equals(false));
expect(filter.isFiltered('hello world!'), equals(false));
});
test('Test contains', () {
const filter = StringFilter(
filterString: 'hello',
type: ComparisonType.contains,
);
expect(filter.isFiltered('hello world'), equals(true));
expect(filter.isFiltered('Hello World'), equals(false));
expect(filter.isFiltered('hello world'), equals(true));
expect(filter.isFiltered('help'), equals(false));
});
test('Test containsIgnoreCase', () {
const filter = StringFilter(
filterString: 'hello',
type: ComparisonType.containsIgnoreCase,
);
expect(filter.isFiltered('hello world'), equals(true));
expect(filter.isFiltered('Hello World'), equals(true));
expect(filter.isFiltered('hello world'), equals(true));
expect(filter.isFiltered('Standard greeting #HelloWorld'), equals(true));
expect(filter.isFiltered('help'), equals(false));
});
});
group('Test TimelineEntryFilter', () {
test('Empty Filter', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
);
final expected = [false, false, false, false, false, false, false];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Keyword Filter', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
keywords: ['hello', 'good'],
);
final expected = [true, true, false, true, true, false, false];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Author Filter', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
authors: [Connection(id: '2')],
);
final expected = [false, false, false, true, true, true, true];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
group('Test Domain Filter', () {
test('Exact match', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
domains: ['trolltodon.social'],
);
final expected = [false, false, false, true, true, true, true];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Start wildcard', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
domains: ['*odon.social'],
);
final expected = [true, true, true, true, true, true, true];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
});
test('Test Tag Filter', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
hashtags: ['latin', 'greet'],
);
final expected = [false, false, true, false, false, true, false];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Author plus Keyword', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
authors: [Connection(id: '2')],
keywords: ['good'],
);
final expected = [false, false, false, false, true, false, false];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Author plus tag', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
authors: [Connection(id: '2')],
hashtags: ['latin', 'greet'],
);
final expected = [false, false, false, false, false, true, false];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test Keyword plus tag', () {
final filter = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
keywords: ['chao'],
hashtags: ['SENDOFF'],
);
final expected = [false, false, false, false, false, false, true];
final actual = entries.map((e) => filter.isFiltered(e)).toList();
expect(actual, equals(expected));
});
test('Test all', () {
final filter1 = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
authors: [Connection(id: '2'), Connection(id: '3')],
keywords: ['chao'],
hashtags: ['SENDOFF'],
);
final expected1 = [false, false, false, false, false, false, true];
final actual1 = entries.map((e) => filter1.isFiltered(e)).toList();
expect(actual1, equals(expected1));
final filter2 = TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'filter',
authors: [Connection(id: '1'), Connection(id: '3')],
keywords: ['chao'],
hashtags: ['SENDOFF'],
);
final expected2 = [false, false, false, false, false, false, false];
final actual2 = entries.map((e) => filter2.isFiltered(e)).toList();
expect(actual2, equals(expected2));
});
});
test('Test runner', () {
final runnerEntries = [
...entries,
TimelineEntry(body: 'User 3 Post #1', authorId: '3'),
];
final filters = [
TimelineEntryFilter.create(
action: TimelineEntryFilterAction.warn,
name: 'send-off-hide-filter',
hashtags: ['SENDOFF'],
),
TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'author-3-hide',
authors: [Connection(id: '3')],
),
TimelineEntryFilter.create(
action: TimelineEntryFilterAction.hide,
name: 'send-off-hide-filter',
authors: [Connection(id: '1')],
hashtags: ['SENDOFF'],
)
];
final expected = [
'show',
'hide',
'show',
'show',
'warn',
'show',
'warn',
'hide',
];
final actual = runnerEntries
.map((e) => runFilters(e, filters).toActionString())
.toList();
expect(expected, equals(actual));
});
}