From 742661802b1c26b99490f3bf1d1bbfae1f41183e Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Fri, 21 Aug 2026 10:30:02 -0400 Subject: [PATCH] fix suggested feed moderation parity --- .../moderation/feed_generator_moderation.dart | 10 +++ lib/src/core/moderation/moderation.dart | 1 + .../visible_pinned_feeds_provider.dart | 26 ++---- .../feed/ui/widgets/feed/feeds_bar.dart | 6 +- .../providers/suggested_feeds_provider.dart | 31 +++++++ .../ui/widgets/suggested_feeds_list.dart | 8 +- .../ui/widgets/settings_feed_card.dart | 6 +- .../suggested_feeds_provider_test.dart | 85 ++++++++++++++++++- 8 files changed, 137 insertions(+), 36 deletions(-) create mode 100644 lib/src/core/moderation/feed_generator_moderation.dart diff --git a/lib/src/core/moderation/feed_generator_moderation.dart b/lib/src/core/moderation/feed_generator_moderation.dart new file mode 100644 index 00000000..5755a7d2 --- /dev/null +++ b/lib/src/core/moderation/feed_generator_moderation.dart @@ -0,0 +1,10 @@ +import 'package:spark/src/core/moderation/moderation_subject.dart'; +import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; + +ModerationSubject feedGeneratorModerationSubject(GeneratorView generator) { + return ModerationSubject.content( + labels: generator.labels ?? const [], + authorLabels: generator.creator.labels ?? const [], + subjectDid: generator.creator.did, + ); +} diff --git a/lib/src/core/moderation/moderation.dart b/lib/src/core/moderation/moderation.dart index 642b15bf..9595b822 100644 --- a/lib/src/core/moderation/moderation.dart +++ b/lib/src/core/moderation/moderation.dart @@ -1,3 +1,4 @@ +export 'feed_generator_moderation.dart'; export 'moderation_definitions.dart'; export 'moderation_engine.dart'; export 'moderation_label_events.dart'; diff --git a/lib/src/features/feed/providers/visible_pinned_feeds_provider.dart b/lib/src/features/feed/providers/visible_pinned_feeds_provider.dart index 84b5b5af..c45903ea 100644 --- a/lib/src/features/feed/providers/visible_pinned_feeds_provider.dart +++ b/lib/src/features/feed/providers/visible_pinned_feeds_provider.dart @@ -22,10 +22,8 @@ final visiblePinnedFeedsProvider = Provider((ref) { final generator = feed.view; if (generator == null) return true; - final hasLabels = - (generator.labels?.isNotEmpty ?? false) || - (generator.creator.labels?.isNotEmpty ?? false); - if (!hasLabels) return true; + final subject = feedGeneratorModerationSubject(generator); + if (!subject.hasLabels) return true; if (engine == null) { if (feed.config.id == settings.activeFeed.config.id) { activeFeedModerationUnresolved = true; @@ -33,10 +31,10 @@ final visiblePinnedFeedsProvider = Provider((ref) { return false; } - return !feedGeneratorModerationDecision( - engine, - generator, - ).forContext(ModerationContext.contentList).filter; + return !subject + .evaluate(engine) + .forContext(ModerationContext.contentList) + .filter; }) .toList(growable: false); final activeFeedIndex = feeds.indexWhere( @@ -57,15 +55,3 @@ final visiblePinnedFeedsProvider = Provider((ref) { !activeFeedModerationUnresolved, ); }); - -ModerationDecision feedGeneratorModerationDecision( - ModerationEngine engine, - GeneratorView generator, { - Iterable preferredLocales = const [], -}) { - return ModerationSubject.content( - labels: generator.labels ?? const [], - authorLabels: generator.creator.labels ?? const [], - subjectDid: generator.creator.did, - ).evaluate(engine, preferredLocales: preferredLocales); -} diff --git a/lib/src/features/feed/ui/widgets/feed/feeds_bar.dart b/lib/src/features/feed/ui/widgets/feed/feeds_bar.dart index 1cc794fe..c6815175 100644 --- a/lib/src/features/feed/ui/widgets/feed/feeds_bar.dart +++ b/lib/src/features/feed/ui/widgets/feed/feeds_bar.dart @@ -165,11 +165,9 @@ class _FeedsBarState extends ConsumerState { final generator = feed.view; var text = generator?.displayName ?? l10n.labelFollowing; if (engine != null && generator != null) { - final decision = feedGeneratorModerationDecision( - engine, + final decision = feedGeneratorModerationSubject( generator, - preferredLocales: [locale], - ); + ).evaluate(engine, preferredLocales: [locale]); if (decision.forContext(ModerationContext.contentList).blur) { text = l10n.moderationContentNotice; } diff --git a/lib/src/features/search/providers/suggested_feeds_provider.dart b/lib/src/features/search/providers/suggested_feeds_provider.dart index 63a6c02e..dc942c29 100644 --- a/lib/src/features/search/providers/suggested_feeds_provider.dart +++ b/lib/src/features/search/providers/suggested_feeds_provider.dart @@ -1,5 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:spark/src/core/moderation/moderation.dart'; +import 'package:spark/src/core/moderation/moderation_provider.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/feed_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; @@ -8,6 +11,34 @@ import 'package:spark/src/core/utils/logging/logger.dart'; part 'suggested_feeds_provider.g.dart'; +final promotableSuggestedFeedsProvider = + Provider.autoDispose>>((ref) { + final suggestedFeeds = ref.watch(suggestedFeedsProvider); + final feeds = suggestedFeeds.value; + if (feeds == null || + !feeds.any( + (feed) => feedGeneratorModerationSubject(feed).hasLabels, + )) { + return suggestedFeeds; + } + + return ref + .watch(moderationEngineProvider) + .when( + data: (engine) => suggestedFeeds.whenData( + (feeds) => feeds + .where( + (feed) => !feedGeneratorModerationSubject( + feed, + ).evaluate(engine).excludeFromPromotion, + ) + .toList(growable: false), + ), + error: AsyncValue.error, + loading: AsyncValue.loading, + ); + }); + /// Provider for fetching suggested Spark feeds @riverpod class SuggestedFeeds extends _$SuggestedFeeds { diff --git a/lib/src/features/search/ui/widgets/suggested_feeds_list.dart b/lib/src/features/search/ui/widgets/suggested_feeds_list.dart index cca2d483..a66f21f7 100644 --- a/lib/src/features/search/ui/widgets/suggested_feeds_list.dart +++ b/lib/src/features/search/ui/widgets/suggested_feeds_list.dart @@ -14,7 +14,7 @@ class SuggestedFeedsList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final suggestedFeedsAsync = ref.watch(suggestedFeedsProvider); + final suggestedFeedsAsync = ref.watch(promotableSuggestedFeedsProvider); final settingsState = ref.watch(settingsProvider); return Column( @@ -102,11 +102,7 @@ class SuggestedFeedsList extends ConsumerWidget { ); return ModeratedContent( - subject: ModerationSubject.content( - labels: generatorView.labels ?? const [], - authorLabels: generatorView.creator.labels ?? const [], - subjectDid: generatorView.creator.did, - ), + subject: feedGeneratorModerationSubject(generatorView), context: ModerationContext.contentList, child: Padding( padding: const EdgeInsets.only(bottom: 12), diff --git a/lib/src/features/settings/ui/widgets/settings_feed_card.dart b/lib/src/features/settings/ui/widgets/settings_feed_card.dart index 75bc2853..05ed4447 100644 --- a/lib/src/features/settings/ui/widgets/settings_feed_card.dart +++ b/lib/src/features/settings/ui/widgets/settings_feed_card.dart @@ -117,11 +117,7 @@ class SettingsFeedCard extends ConsumerWidget { final generator = _generator; if (generator == null) return card; return ModeratedContent( - subject: ModerationSubject.content( - labels: generator.labels ?? const [], - authorLabels: generator.creator.labels ?? const [], - subjectDid: generator.creator.did, - ), + subject: feedGeneratorModerationSubject(generator), context: ModerationContext.contentList, child: card, ); diff --git a/test/src/features/search/providers/suggested_feeds_provider_test.dart b/test/src/features/search/providers/suggested_feeds_provider_test.dart index 1559adc1..884d5512 100644 --- a/test/src/features/search/providers/suggested_feeds_provider_test.dart +++ b/test/src/features/search/providers/suggested_feeds_provider_test.dart @@ -1,7 +1,12 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:poptart/poptart.dart'; +import 'package:poptart_lex/com/atproto/label/defs.dart'; +import 'package:spark/src/core/moderation/moderation.dart'; +import 'package:spark/src/core/moderation/moderation_provider.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/feed_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; @@ -74,6 +79,67 @@ void main() { await scope.read(suggestedFeedsProvider.notifier).refresh(); expect(scope.read(suggestedFeedsProvider).hasError, isTrue); }); + + test('waits for moderation before exposing suggested feeds', () async { + final promoted = _generator('promoted'); + final excluded = _generator('excluded', labels: [_label('!no-promote')]); + feedRepository.suggestedResponses.add(() async => [promoted, excluded]); + final engineCompleter = Completer(); + final scope = ProviderContainer.test( + overrides: [ + moderationEngineProvider.overrideWith((ref) => engineCompleter.future), + ], + ); + addTearDown(scope.dispose); + final subscription = scope.listen( + promotableSuggestedFeedsProvider, + (previous, next) {}, + ); + addTearDown(subscription.close); + + await scope.read(suggestedFeedsProvider.future); + expect(scope.read(promotableSuggestedFeedsProvider).isLoading, isTrue); + + engineCompleter.complete(_engine()); + await scope.read(moderationEngineProvider.future); + + expect(scope.read(promotableSuggestedFeedsProvider).requireValue, [ + promoted, + ]); + }); + + test('exposes moderation errors instead of unfiltered feeds', () async { + final moderationError = StateError('moderation failed'); + feedRepository.suggestedResponses.add( + () async => [ + _generator('excluded', labels: [_label('!no-promote')]), + ], + ); + final scope = ProviderContainer.test( + retry: (retryCount, error) => null, + overrides: [ + moderationEngineProvider.overrideWith( + (ref) => Future.error(moderationError), + ), + ], + ); + addTearDown(scope.dispose); + final subscription = scope.listen( + promotableSuggestedFeedsProvider, + (previous, next) {}, + ); + addTearDown(subscription.close); + + await scope.read(suggestedFeedsProvider.future); + await expectLater( + scope.read(moderationEngineProvider.future), + throwsA(same(moderationError)), + ); + + final result = scope.read(promotableSuggestedFeedsProvider); + expect(result.hasError, isTrue); + expect(result.error, same(moderationError)); + }); } class _FakeFeedRepository implements FeedRepository { @@ -107,11 +173,28 @@ ProfileView _profile(String id) => final _indexedAt = DateTime.utc(2026, 7, 1); -GeneratorView _generator(String id) => GeneratorView( +GeneratorView _generator(String id, {List