diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index e69de3c..3a1a0ce 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -141,9 +141,7 @@ class AppRouter { providers: [ if (existingUnreadCubit == null) BlocProvider( - create: (_) => UnreadCountCubit( - notificationRepository: NotificationRepository(bluesky: context.read()), - ), + create: (_) => UnreadCountCubit(notificationRepository: context.read()), ), ], child: AppShell(navigationShell: navigationShell), @@ -201,9 +199,7 @@ class AppRouter { GoRoute( path: '/notifications', builder: (context, state) => BlocProvider( - create: (_) => NotificationBloc( - notificationRepository: NotificationRepository(bluesky: context.read()), - ), + create: (_) => NotificationBloc(notificationRepository: context.read()), child: const NotificationsScreen(), ), ), diff --git a/lib/core/router/app_shell.dart b/lib/core/router/app_shell.dart index 271201e..0ebb198 100644 --- a/lib/core/router/app_shell.dart +++ b/lib/core/router/app_shell.dart @@ -1,9 +1,9 @@ -import 'package:bluesky/bluesky.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:provider/provider.dart'; class AppShellScope extends InheritedWidget { @@ -367,8 +367,8 @@ class _MenuProfileAvatarState extends State<_MenuProfileAvatar> { } try { - final profile = await context.read().actor.getProfile(actor: did); - return profile.data.avatar; + final profile = await context.read().getProfile(did); + return profile.avatar; } catch (_) { return null; } diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index 2d550f0..67b6561 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -3,11 +3,15 @@ import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_getauthorfeed.dart'; import 'package:bluesky/bluesky.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; class FeedRepository { - FeedRepository({required Bluesky bluesky}) : _bluesky = bluesky; + FeedRepository({required Bluesky bluesky, ModerationService? moderationService}) + : _bluesky = bluesky, + _moderationService = moderationService; final Bluesky _bluesky; + final ModerationService? _moderationService; Future getAuthorFeed({ required String actor, @@ -16,22 +20,38 @@ class FeedRepository { int limit = 50, }) async { final bskyFilter = _mapToBskyFilter(filter); + final headers = await _moderationService?.headersForRequest(); - final response = await _bluesky.feed.getAuthorFeed(actor: actor, cursor: cursor, limit: limit, filter: bskyFilter); + final response = await _bluesky.feed.getAuthorFeed( + actor: actor, + cursor: cursor, + limit: limit, + filter: bskyFilter, + $headers: headers, + ); - return FeedResult(posts: response.data.feed, cursor: response.data.cursor); + return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); } Future getTimeline({String? cursor, int limit = 50}) async { - final response = await _bluesky.feed.getTimeline(cursor: cursor, limit: limit); + final response = await _bluesky.feed.getTimeline( + cursor: cursor, + limit: limit, + $headers: await _moderationService?.headersForRequest(), + ); - return FeedResult(posts: response.data.feed, cursor: response.data.cursor); + return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); } Future getFeed({required AtUri feedUri, String? cursor, int limit = 50}) async { - final response = await _bluesky.feed.getFeed(feed: feedUri, cursor: cursor, limit: limit); - - return FeedResult(posts: response.data.feed, cursor: response.data.cursor); + final response = await _bluesky.feed.getFeed( + feed: feedUri, + cursor: cursor, + limit: limit, + $headers: await _moderationService?.headersForRequest(), + ); + + return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); } Future getPreferences() async { @@ -69,6 +89,15 @@ class FeedRepository { return const FeedGetAuthorFeedFilter.knownValue(data: KnownFeedGetAuthorFeedFilter.posts_and_author_threads); } } + + List _filterFeedPosts(List posts) { + final moderationService = _moderationService; + if (moderationService == null) { + return posts; + } + + return posts.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(); + } } class FeedResult { diff --git a/lib/features/feed/data/post_thread_repository.dart b/lib/features/feed/data/post_thread_repository.dart index 3afa39e..263afa8 100644 --- a/lib/features/feed/data/post_thread_repository.dart +++ b/lib/features/feed/data/post_thread_repository.dart @@ -2,18 +2,30 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_getpostthread.dart'; import 'package:bluesky/bluesky.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; class PostThreadRepository { - PostThreadRepository({required Bluesky bluesky}) : _bluesky = bluesky; + PostThreadRepository({required Bluesky bluesky, ModerationService? moderationService}) + : _bluesky = bluesky, + _moderationService = moderationService; final Bluesky _bluesky; + final ModerationService? _moderationService; Future getPostThread(String uri) async { - final response = await _bluesky.feed.getPostThread(uri: AtUri.parse(uri)); + final response = await _bluesky.feed.getPostThread( + uri: AtUri.parse(uri), + $headers: await _moderationService?.headersForRequest(), + ); final thread = response.data.thread; if (thread.isThreadViewPost) { - return thread.threadViewPost!; + final threadViewPost = thread.threadViewPost!; + if (_moderationService?.shouldFilterPostInView(threadViewPost.post) ?? false) { + throw Exception('Post hidden by moderation preferences'); + } + + return _pruneThread(threadViewPost); } if (thread.isNotFoundPost) { @@ -26,4 +38,40 @@ class PostThreadRepository { throw Exception('Unable to load thread'); } + + ThreadViewPost _pruneThread(ThreadViewPost thread) { + final prunedParent = _pruneParent(thread.parent); + final prunedReplies = thread.replies?.map(_pruneReply).whereType().toList(); + + return thread.copyWith(parent: prunedParent, replies: prunedReplies); + } + + UThreadViewPostParent? _pruneParent(UThreadViewPostParent? parent) { + if (parent == null) { + return null; + } + if (parent.isNotThreadViewPost) { + return parent; + } + + final thread = parent.threadViewPost!; + if (_moderationService?.shouldFilterPostInList(thread.post) ?? false) { + return _pruneParent(thread.parent); + } + + return UThreadViewPostParent.threadViewPost(data: _pruneThread(thread)); + } + + UThreadViewPostReplies? _pruneReply(UThreadViewPostReplies reply) { + if (reply.isNotThreadViewPost) { + return reply; + } + + final thread = reply.threadViewPost!; + if (_moderationService?.shouldFilterPostInList(thread.post) ?? false) { + return null; + } + + return UThreadViewPostReplies.threadViewPost(data: _pruneThread(thread)); + } } diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index 8e6d53c..e47e62b 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_post.dart'; -import 'package:bluesky/bluesky.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -31,8 +30,7 @@ class PostThreadScreen extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => - PostThreadCubit(postThreadRepository: PostThreadRepository(bluesky: context.read()))..load(postUri), + create: (_) => PostThreadCubit(postThreadRepository: context.read())..load(postUri), child: _PostThreadContent(postUri: postUri), ); } diff --git a/lib/features/moderation/data/moderation_service.dart b/lib/features/moderation/data/moderation_service.dart index 9fd3de7..c59ceed 100644 --- a/lib/features/moderation/data/moderation_service.dart +++ b/lib/features/moderation/data/moderation_service.dart @@ -1,91 +1,569 @@ import 'dart:async'; +import 'dart:convert'; -import 'package:bluesky/bluesky.dart'; -import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_actor_getpreferences.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bluesky/app_bsky_labeler_defs.dart'; +import 'package:bluesky/app_bsky_labeler_getservices.dart'; import 'package:bluesky/app_bsky_notification_listnotifications.dart' as notifications; +import 'package:bluesky/bluesky.dart'; +import 'package:bluesky/moderation.dart' as bsky_moderation; +import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +const _officialBlueskyLabelerDid = 'did:plc:ar7c4by46qjdydhdevvrndac'; +const _maxCustomLabelers = 20; + class ModerationService { - ModerationService({required Bluesky bluesky}) : _bluesky = bluesky; + ModerationService({required dynamic bluesky, AppDatabase? database, String? accountDid, String? userDid}) + : _bluesky = bluesky, + _database = database, + _accountDid = accountDid, + _userDid = userDid; + + final dynamic _bluesky; + final AppDatabase? _database; + final String? _accountDid; + final String? _userDid; - final Bluesky _bluesky; bsky_moderation.ModerationOpts? _opts; + List _preferences = const []; + Map _headers = _buildLabelerHeaders(const []); + Future? _initializationFuture; + bool _disposed = false; final _optsController = StreamController.broadcast(); Stream get optsStream => _optsController.stream; bsky_moderation.ModerationOpts? get currentOpts => _opts; + bsky_moderation.ModerationPrefs? get currentPrefs => _opts?.prefs; + List get currentPreferences => List.unmodifiable(_preferences); + Map get currentHeaders => Map.unmodifiable(_headers); + + Future initialize() => ensureInitialized(); + + Future ensureInitialized() async { + if (_disposed) return; + if (_opts != null) return; + + final inFlight = _initializationFuture; + if (inFlight != null) { + await inFlight; + return; + } - Future initialize() async { - await _rebuildOpts(); + final future = _rebuildOpts(); + _initializationFuture = future; + try { + await future; + } finally { + if (identical(_initializationFuture, future)) { + _initializationFuture = null; + } + } } - Future updatePreferences() async { - await _rebuildOpts(); + Future updatePreferences({List? preferences}) async { + if (_disposed) return; + await _rebuildOpts(preferences: preferences, forceRefresh: true); } - Future _rebuildOpts() async { - try { - final prefsResponse = await _bluesky.actor.getPreferences(); - final prefs = prefsResponse.data.getModerationPrefs(); + Future> headersForRequest() async { + await ensureInitialized(); + return currentHeaders; + } - final labelDefs = await _bluesky.labeler.getLabelDefinitions(prefs); + Future> getSubscribedLabelers() async { + final labelerDids = await _getSubscribedLabelerDids(); + if (labelerDids.isEmpty) { + return const []; + } - final opts = bsky_moderation.ModerationOpts(prefs: prefs, labelDefs: labelDefs); + final response = await _bluesky.labeler.getServices( + dids: labelerDids, + detailed: true, + $headers: await headersForRequest(), + ); - _opts = opts; - _optsController.add(opts); - } catch (error) { - log.w('Failed to build moderation opts: $error'); + await _cacheLabelerPolicies(response.data.views); + return response.data.views; + } + + Future getLabelerDetails(String did) async { + final response = await _bluesky.labeler.getServices( + dids: [did], + detailed: true, + $headers: await headersForRequest(), + ); + + await _cacheLabelerPolicies(response.data.views); + + for (final view in response.data.views) { + if (view.isLabelerViewDetailed) { + return view.labelerViewDetailed!; + } + } + + return null; + } + + Future subscribeToLabeler(String did) async { + if (did == _officialBlueskyLabelerDid) { + return; + } + + final preferences = await _loadPreferences(); + final currentLabelers = _subscribedLabelerDidsFromPreferences(preferences); + if (currentLabelers.contains(did)) { + return; + } + if (currentLabelers.length >= _maxCustomLabelers) { + throw StateError('A maximum of $_maxCustomLabelers labelers can be subscribed.'); + } + + final updated = _replaceLabelersPref(preferences, [ + ...currentLabelers.map((labelerDid) => LabelerPrefItem(did: labelerDid)), + LabelerPrefItem(did: did), + ]); + + await _putAndRefresh(updated); + } + + Future unsubscribeFromLabeler(String did) async { + if (did == _officialBlueskyLabelerDid) { + return; } + + final preferences = await _loadPreferences(); + final currentLabelers = _subscribedLabelerDidsFromPreferences(preferences); + final updatedItems = currentLabelers + .where((labelerDid) => labelerDid != did) + .map((labelerDid) => LabelerPrefItem(did: labelerDid)) + .toList(); + + final updated = _replaceLabelersPref(preferences, updatedItems); + await _putAndRefresh(updated); } + Future setAdultContentEnabled(bool enabled) async { + final preferences = await _loadPreferences(); + final updated = _replaceAdultContentPref(preferences, enabled); + await _putAndRefresh(updated); + } + + Future setLabelPreference({ + required String label, + required KnownContentLabelPrefVisibility visibility, + String? labelerDid, + }) async { + final preferences = await _loadPreferences(); + final updated = _replaceContentLabelPref( + preferences, + label: label, + labelerDid: labelerDid, + visibility: ContentLabelPrefVisibility.knownValue(data: visibility), + ); + + await _putAndRefresh(updated); + } + + bsky_moderation.ModerationDecision moderateFeedViewPost(FeedViewPost post) => moderatePost(post.post); + bsky_moderation.ModerationDecision moderatePost(PostView post) { - if (_opts == null) { - return bsky_moderation.ModerationDecision.merge([]); + final opts = _opts; + if (opts == null) { + return bsky_moderation.ModerationDecision.merge(const []); } - return bsky_moderation.moderatePost(bsky_moderation.ModerationSubjectPost.postView(data: post), _opts!); + + return bsky_moderation.moderatePost(bsky_moderation.ModerationSubjectPost.postView(data: post), opts); } bsky_moderation.ModerationDecision moderateProfile(ProfileView profile) { - if (_opts == null) { - return bsky_moderation.ModerationDecision.merge([]); + final opts = _opts; + if (opts == null) { + return bsky_moderation.ModerationDecision.merge(const []); } - return bsky_moderation.moderateProfile(bsky_moderation.ModerationSubjectProfile.profileView(data: profile), _opts!); + + return bsky_moderation.moderateProfile(bsky_moderation.ModerationSubjectProfile.profileView(data: profile), opts); } bsky_moderation.ModerationDecision moderateProfileBasic(ProfileViewBasic profile) { - if (_opts == null) { - return bsky_moderation.ModerationDecision.merge([]); + final opts = _opts; + if (opts == null) { + return bsky_moderation.ModerationDecision.merge(const []); } + return bsky_moderation.moderateProfile( bsky_moderation.ModerationSubjectProfile.profileViewBasic(data: profile), - _opts!, + opts, ); } bsky_moderation.ModerationDecision moderateProfileDetailed(ProfileViewDetailed profile) { - if (_opts == null) { - return bsky_moderation.ModerationDecision.merge([]); + final opts = _opts; + if (opts == null) { + return bsky_moderation.ModerationDecision.merge(const []); } + return bsky_moderation.moderateProfile( bsky_moderation.ModerationSubjectProfile.profileViewDetailed(data: profile), - _opts!, + opts, ); } bsky_moderation.ModerationDecision moderateNotification(notifications.Notification notification) { - if (_opts == null) { - return bsky_moderation.ModerationDecision.merge([]); + final opts = _opts; + if (opts == null) { + return bsky_moderation.ModerationDecision.merge(const []); } + return bsky_moderation.moderateNotification( bsky_moderation.ModerationSubjectNotification.notification(data: notification), - _opts!, + opts, ); } + bsky_moderation.ModerationUI postUi(PostView post, bsky_moderation.ModerationBehaviorContext context) => + moderatePost(post).getUI(context); + + bsky_moderation.ModerationUI profileUi(ProfileView profile, bsky_moderation.ModerationBehaviorContext context) => + moderateProfile(profile).getUI(context); + + bsky_moderation.ModerationUI profileBasicUi( + ProfileViewBasic profile, + bsky_moderation.ModerationBehaviorContext context, + ) => moderateProfileBasic(profile).getUI(context); + + bsky_moderation.ModerationUI profileDetailedUi( + ProfileViewDetailed profile, + bsky_moderation.ModerationBehaviorContext context, + ) => moderateProfileDetailed(profile).getUI(context); + + bsky_moderation.ModerationUI notificationUi( + notifications.Notification notification, + bsky_moderation.ModerationBehaviorContext context, + ) => moderateNotification(notification).getUI(context); + + bool shouldFilterFeedViewPostInList(FeedViewPost post) => shouldFilterPostInList(post.post); + + bool shouldFilterPostInList(PostView post) => + postUi(post, bsky_moderation.ModerationBehaviorContext.contentList).filter; + + bool shouldFilterPostInView(PostView post) => + postUi(post, bsky_moderation.ModerationBehaviorContext.contentView).filter; + + bool shouldFilterProfileInList(ProfileView profile) => + profileUi(profile, bsky_moderation.ModerationBehaviorContext.profileList).filter; + + bool shouldFilterProfileBasicInList(ProfileViewBasic profile) => + profileBasicUi(profile, bsky_moderation.ModerationBehaviorContext.profileList).filter; + + bool shouldFilterProfileDetailedInView(ProfileViewDetailed profile) => + profileDetailedUi(profile, bsky_moderation.ModerationBehaviorContext.profileView).filter; + + bool shouldFilterNotificationInList(notifications.Notification notification) { + final decision = moderateNotification(notification); + return decision.getUI(bsky_moderation.ModerationBehaviorContext.contentList).filter || + decision.getUI(bsky_moderation.ModerationBehaviorContext.profileList).filter; + } + void dispose() { + if (_disposed) { + return; + } + + _disposed = true; _optsController.close(); } + + Future _rebuildOpts({List? preferences, bool forceRefresh = false}) async { + try { + final resolvedPreferences = await _loadPreferences(providedPreferences: preferences, forceRefresh: forceRefresh); + + final moderationPrefs = _toModerationPrefs(resolvedPreferences); + final labelDefs = await _loadLabelDefinitions(moderationPrefs); + final opts = bsky_moderation.ModerationOpts( + userDid: _resolvedUserDid, + prefs: moderationPrefs, + labelDefs: labelDefs, + ); + + _preferences = resolvedPreferences; + _headers = _buildHeadersForPrefs(moderationPrefs); + _opts = opts; + + if (!_disposed) { + _optsController.add(opts); + } + } catch (error, stackTrace) { + log.w('Failed to build moderation opts: $error'); + log.d('$stackTrace'); + } + } + + Future> _loadPreferences({ + List? providedPreferences, + bool forceRefresh = false, + }) async { + if (providedPreferences != null) { + await _cachePreferences(providedPreferences); + return providedPreferences; + } + + if (!forceRefresh && _preferences.isNotEmpty) { + return _preferences; + } + + try { + final prefsResponse = await _bluesky.actor.getPreferences(); + final preferences = prefsResponse.data.preferences; + await _cachePreferences(preferences); + return preferences; + } catch (error) { + final cached = await _loadCachedPreferences(); + if (cached != null) { + log.w('Using cached moderation preferences after request failure: $error'); + return cached; + } + + rethrow; + } + } + + Future _putAndRefresh(List preferences) async { + await _bluesky.actor.putPreferences(preferences: preferences); + await updatePreferences(preferences: preferences); + } + + Future>> _loadLabelDefinitions( + bsky_moderation.ModerationPrefs prefs, + ) async { + final labelerDids = { + _officialBlueskyLabelerDid, + ...prefs.labelers.map((labeler) => labeler.did), + }.where((did) => did.startsWith('did:')).toList(); + + try { + final response = await _bluesky.labeler.getServices( + dids: labelerDids, + detailed: true, + $headers: _buildHeadersForPrefs(prefs), + ); + + await _cacheLabelerPolicies(response.data.views); + return _mapLabelDefinitions(response.data.views); + } catch (error) { + final cached = await _loadCachedLabelDefinitions(labelerDids); + if (cached.isNotEmpty) { + log.w('Using cached label definitions after request failure: $error'); + return cached; + } + + log.w('Proceeding without cached label definitions after request failure: $error'); + return const {}; + } + } + + Future _cacheLabelerPolicies(List views) async { + if (_database == null) { + return; + } + + for (final view in views) { + if (!view.isLabelerViewDetailed) { + continue; + } + + final detailed = view.labelerViewDetailed!; + await _database.upsertLabelerCache(detailed.creator.did, jsonEncode(detailed.policies.toJson())); + } + } + + Future>> _loadCachedLabelDefinitions( + List labelerDids, + ) async { + if (_database == null) { + return const {}; + } + + final definitions = >{}; + for (final did in labelerDids) { + final cached = await _database.getLabelerCache(did); + if (cached == null) { + continue; + } + + final policies = LabelerPolicies.fromJson(jsonDecode(cached.policiesJson) as Map); + definitions[did] = _interpretedLabelDefinitionsFromPolicies(policies, labelerDid: did); + } + + return definitions; + } + + Future _cachePreferences(List preferences) async { + final database = _database; + final prefsKey = _preferencesCacheKey; + if (database == null || prefsKey == null) { + return; + } + + final payload = jsonEncode(preferences.map((preference) => preference.toJson()).toList()); + await database.setSetting(prefsKey, payload); + } + + Future?> _loadCachedPreferences() async { + final database = _database; + final prefsKey = _preferencesCacheKey; + if (database == null || prefsKey == null) { + return null; + } + + final payload = await database.getSetting(prefsKey); + if (payload == null || payload.isEmpty) { + return null; + } + + final decoded = jsonDecode(payload) as List; + return decoded + .map((json) => const UPreferencesConverter().fromJson(Map.from(json as Map))) + .toList(); + } + + Future> _getSubscribedLabelerDids() async { + final preferences = await _loadPreferences(); + return _subscribedLabelerDidsFromPreferences(preferences); + } + + List _subscribedLabelerDidsFromPreferences(List preferences) { + for (final preference in preferences) { + if (preference.isLabelersPref) { + return preference.labelersPref!.labelers + .map((item) => item.did) + .where((did) => did.startsWith('did:')) + .toList(); + } + } + + return const []; + } + + List _replaceLabelersPref(List preferences, List labelers) { + final updated = List.from(preferences); + updated.removeWhere((preference) => preference.isLabelersPref); + updated.add(UPreferences.labelersPref(data: LabelersPref(labelers: labelers))); + return updated; + } + + List _replaceAdultContentPref(List preferences, bool enabled) { + final updated = List.from(preferences); + updated.removeWhere((preference) => preference.isAdultContentPref); + updated.add(UPreferences.adultContentPref(data: AdultContentPref(enabled: enabled))); + return updated; + } + + List _replaceContentLabelPref( + List preferences, { + required String label, + required ContentLabelPrefVisibility visibility, + String? labelerDid, + }) { + final updated = List.from(preferences); + updated.removeWhere( + (preference) => + preference.isContentLabelPref && + preference.contentLabelPref!.label == label && + preference.contentLabelPref!.labelerDid == labelerDid, + ); + updated.add( + UPreferences.contentLabelPref( + data: ContentLabelPref(label: label, labelerDid: labelerDid, visibility: visibility), + ), + ); + return updated; + } + + bsky_moderation.ModerationPrefs _toModerationPrefs(List preferences) { + return ActorGetPreferencesOutput( + preferences: preferences, + ).getModerationPrefs(appLabelers: const [_officialBlueskyLabelerDid]); + } + + Map> _mapLabelDefinitions( + List views, + ) { + final definitions = >{}; + + for (final view in views) { + if (!view.isLabelerViewDetailed) { + continue; + } + + final detailed = view.labelerViewDetailed!; + definitions[detailed.creator.did] = _interpretedLabelDefinitionsFromPolicies( + detailed.policies, + labelerDid: detailed.creator.did, + ); + } + + return definitions; + } + + List _interpretedLabelDefinitionsFromPolicies( + LabelerPolicies policies, { + required String labelerDid, + }) { + return policies.labelValueDefinitions + ?.map( + (definition) => bsky_moderation.getInterpretedLabelValueDefinition( + identifier: definition.identifier, + defaultSetting: + bsky_moderation.LabelPreference.valueOf(definition.defaultSetting?.toJson()) ?? + bsky_moderation.LabelPreference.warn, + severity: definition.severity.toJson(), + blurs: definition.blurs.toJson(), + adultOnly: definition.adultOnly ?? true, + definedBy: labelerDid, + ), + ) + .toList() ?? + const []; + } + + Map _buildHeadersForPrefs(bsky_moderation.ModerationPrefs prefs) { + return _buildLabelerHeaders(prefs.labelers.map((labeler) => labeler.did)); + } + + String? get _preferencesCacheKey { + final accountDid = _accountDid; + if (accountDid == null || accountDid.isEmpty) { + return null; + } + + return 'moderation_preferences::$accountDid'; + } + + String? get _resolvedUserDid { + final explicitUserDid = _userDid; + if (explicitUserDid != null && explicitUserDid.isNotEmpty) { + return explicitUserDid; + } + + final bluesky = _bluesky; + if (bluesky is Bluesky) { + return bluesky.oAuthSession?.sub ?? bluesky.session?.did; + } + + return null; + } +} + +Map _buildLabelerHeaders(Iterable subscribedLabelers) { + final dids = { + _officialBlueskyLabelerDid, + ...subscribedLabelers + .where((did) => did.startsWith('did:') && did != _officialBlueskyLabelerDid) + .take(_maxCustomLabelers), + }; + + return {'atproto-accept-labelers': dids.map((did) => '$did;redact').join(', ')}; } diff --git a/lib/features/notifications/data/notification_repository.dart b/lib/features/notifications/data/notification_repository.dart index a3be97a..0f36772 100644 --- a/lib/features/notifications/data/notification_repository.dart +++ b/lib/features/notifications/data/notification_repository.dart @@ -1,16 +1,24 @@ import 'package:bluesky/app_bsky_notification_listnotifications.dart'; import 'package:bluesky/bluesky.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; class NotificationRepository { - NotificationRepository({required Bluesky bluesky}) : _bluesky = bluesky; + NotificationRepository({required Bluesky bluesky, ModerationService? moderationService}) + : _bluesky = bluesky, + _moderationService = moderationService; final Bluesky _bluesky; + final ModerationService? _moderationService; Future listNotifications({String? cursor, int limit = 50}) async { - final response = await _bluesky.notification.listNotifications(cursor: cursor, limit: limit); + final response = await _bluesky.notification.listNotifications( + cursor: cursor, + limit: limit, + $headers: await _moderationService?.headersForRequest(), + ); return NotificationListResult( - notifications: response.data.notifications, + notifications: _filterNotifications(response.data.notifications), cursor: response.data.cursor, seenAt: response.data.seenAt, ); @@ -24,6 +32,17 @@ class NotificationRepository { Future updateSeen() async { await _bluesky.notification.updateSeen(seenAt: DateTime.now()); } + + List _filterNotifications(List notifications) { + final moderationService = _moderationService; + if (moderationService == null) { + return notifications; + } + + return notifications + .where((notification) => !moderationService.shouldFilterNotificationInList(notification)) + .toList(); + } } class NotificationListResult { diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index de78883..a524c73 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -6,32 +6,45 @@ import 'package:bluesky/bluesky.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; class ProfileRepository { - ProfileRepository({required AppDatabase database, required dynamic bluesky}) + ProfileRepository({required AppDatabase database, required dynamic bluesky, ModerationService? moderationService}) : _database = database, - _bluesky = bluesky; + _bluesky = bluesky, + _moderationService = moderationService; final AppDatabase _database; final dynamic _bluesky; + final ModerationService? _moderationService; Future getProfile(String actor) async { log.d('ProfileRepository: Loading profile for $actor via ${_describeClientContext()}'); try { - final response = await _bluesky.actor.getProfile(actor: actor); + final response = await _bluesky.actor.getProfile( + actor: actor, + $headers: await _moderationService?.headersForRequest(), + ); final profile = response.data; log.i('ProfileRepository: Loaded profile ${profile.did} (${profile.handle})'); await _database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); log.d('ProfileRepository: Cached profile ${profile.did} (${profile.handle})'); + if (_moderationService?.shouldFilterProfileDetailedInView(profile) ?? false) { + throw Exception('Profile hidden by moderation preferences'); + } + return profile; } catch (error, stackTrace) { log.e('ProfileRepository: Failed to load profile for $actor', error: error, stackTrace: stackTrace); final cachedProfile = await _getCachedProfile(actor); if (cachedProfile != null) { log.w('ProfileRepository: Using cached profile for $actor after request failure'); + if (_moderationService?.shouldFilterProfileDetailedInView(cachedProfile) ?? false) { + throw Exception('Profile hidden by moderation preferences'); + } return cachedProfile; } @@ -41,16 +54,25 @@ class ProfileRepository { Future> getProfiles(List actors) async { log.d('ProfileRepository: Loading ${actors.length} profiles via ${_describeClientContext()}'); - final response = await _bluesky.actor.getProfiles(actors: actors); - log.i('ProfileRepository: Loaded ${response.data.profiles.length} profiles'); - return response.data.profiles; + final response = await _bluesky.actor.getProfiles( + actors: actors, + $headers: await _moderationService?.headersForRequest(), + ); + final profiles = response.data.profiles + .where((profile) => !(_moderationService?.shouldFilterProfileInList(profile) ?? false)) + .toList(); + log.i('ProfileRepository: Loaded ${profiles.length} profiles'); + return profiles; } Future getCurrentUserProfile(AuthTokens tokens) async { log.d('ProfileRepository: Loading current user profile for ${tokens.did} via ${_describeClientContext()}'); try { - final response = await _bluesky.actor.getProfile(actor: tokens.did); + final response = await _bluesky.actor.getProfile( + actor: tokens.did, + $headers: await _moderationService?.headersForRequest(), + ); log.i('ProfileRepository: Loaded current user profile ${response.data.did} (${response.data.handle})'); return response.data; } catch (error, stackTrace) { diff --git a/lib/features/search/data/search_repository.dart b/lib/features/search/data/search_repository.dart index fb74798..2b78562 100644 --- a/lib/features/search/data/search_repository.dart +++ b/lib/features/search/data/search_repository.dart @@ -2,11 +2,15 @@ import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_searchposts.dart'; import 'package:bluesky/bluesky.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; class SearchRepository { - SearchRepository({required Bluesky bluesky}) : _bluesky = bluesky; + SearchRepository({required Bluesky bluesky, ModerationService? moderationService}) + : _bluesky = bluesky, + _moderationService = moderationService; final Bluesky _bluesky; + final ModerationService? _moderationService; Future searchPosts({ required String query, @@ -18,25 +22,67 @@ class SearchRepository { ? const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.latest) : const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.top); - final response = await _bluesky.feed.searchPosts(q: query, sort: sortValue, cursor: cursor, limit: limit); + final response = await _bluesky.feed.searchPosts( + q: query, + sort: sortValue, + cursor: cursor, + limit: limit, + $headers: await _moderationService?.headersForRequest(), + ); return SearchPostsResult( - posts: response.data.posts, + posts: _filterPosts(response.data.posts), cursor: response.data.cursor, hitsTotal: response.data.hitsTotal, ); } Future searchActors({required String query, String? cursor, int limit = 50}) async { - final response = await _bluesky.actor.searchActors(q: query, cursor: cursor, limit: limit); + final response = await _bluesky.actor.searchActors( + q: query, + cursor: cursor, + limit: limit, + $headers: await _moderationService?.headersForRequest(), + ); - return SearchActorsResult(actors: response.data.actors, cursor: response.data.cursor); + return SearchActorsResult(actors: _filterProfiles(response.data.actors), cursor: response.data.cursor); } Future> searchActorsTypeahead({required String query, int limit = 10}) async { - final response = await _bluesky.actor.searchActorsTypeahead(q: query, limit: limit); + final response = await _bluesky.actor.searchActorsTypeahead( + q: query, + limit: limit, + $headers: await _moderationService?.headersForRequest(), + ); + + return _filterBasicProfiles(response.data.actors); + } + + List _filterPosts(List posts) { + final moderationService = _moderationService; + if (moderationService == null) { + return posts; + } + + return posts.where((post) => !moderationService.shouldFilterPostInList(post)).toList(); + } + + List _filterProfiles(List profiles) { + final moderationService = _moderationService; + if (moderationService == null) { + return profiles; + } + + return profiles.where((profile) => !moderationService.shouldFilterProfileInList(profile)).toList(); + } + + List _filterBasicProfiles(List profiles) { + final moderationService = _moderationService; + if (moderationService == null) { + return profiles; + } - return response.data.actors; + return profiles.where((profile) => !moderationService.shouldFilterProfileBasicInList(profile)).toList(); } } diff --git a/lib/main.dart b/lib/main.dart index f4c4dd8..1e89a5a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,33 +1,38 @@ +import 'dart:async'; + import 'package:bluesky/bluesky.dart'; +import 'package:bluesky/bluesky_chat.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; -import 'package:lazurite/core/scheduler/post_scheduler.dart'; import 'package:lazurite/core/logging/logging_bloc_observer.dart'; import 'package:lazurite/core/logging/logging_navigator_observer.dart'; -import 'package:bluesky/bluesky_chat.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/core/router/app_router.dart'; -import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; -import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:lazurite/core/scheduler/post_scheduler.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; +import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; +import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/features/feed/data/post_action_repository.dart'; +import 'package:lazurite/features/feed/data/post_thread_repository.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; +import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:lazurite/features/search/bloc/search_bloc.dart'; import 'package:lazurite/features/search/data/search_repository.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; -import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; -import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; -import 'package:lazurite/features/feed/data/post_action_repository.dart'; -import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; Future main() async { @@ -127,54 +132,88 @@ class _LazuriteAppState extends State { return appShell; } - final feedRepository = FeedRepository(bluesky: bluesky); - final searchRepository = SearchRepository(bluesky: bluesky); - final postActionRepository = PostActionRepository(bluesky: bluesky); - final profileActionRepository = ProfileActionRepository(bluesky: bluesky); - final convoRepository = ConvoRepository(chat: blueskyChat); final accountDid = authState.tokens?.did ?? ''; - return MultiBlocProvider( + return MultiRepositoryProvider( providers: [ - BlocProvider( - create: (_) => ProfileBloc( - profileRepository: ProfileRepository(database: widget.database, bluesky: bluesky), - ), - ), - BlocProvider(create: (_) => FeedBloc(feedRepository: feedRepository)), - BlocProvider( - create: (_) => FeedPreferencesCubit( - feedRepository: feedRepository, - database: widget.database, - accountDid: accountDid, - )..loadPreferences(), + RepositoryProvider( + create: (_) { + final moderationService = ModerationService( + bluesky: bluesky, + database: widget.database, + accountDid: accountDid, + userDid: accountDid, + ); + unawaited(moderationService.ensureInitialized()); + return moderationService; + }, + dispose: (moderationService) => moderationService.dispose(), ), - BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), - BlocProvider( - create: (_) => - SearchBloc(searchRepository: searchRepository, database: widget.database, accountDid: accountDid), + RepositoryProvider( + create: (context) => + FeedRepository(bluesky: bluesky, moderationService: context.read()), ), - BlocProvider( - create: (_) => ConvoListBloc(convoRepository: convoRepository)..add(const ConvosRequested(limit: 100)), + RepositoryProvider( + create: (context) => + SearchRepository(bluesky: bluesky, moderationService: context.read()), ), - BlocProvider( - create: (_) => SavedPostsCubit( + RepositoryProvider( + create: (context) => ProfileRepository( database: widget.database, - accountDid: accountDid, - postActionRepository: postActionRepository, + bluesky: bluesky, + moderationService: context.read(), ), ), - RepositoryProvider.value(value: feedRepository), - RepositoryProvider.value(value: searchRepository), - RepositoryProvider.value(value: postActionRepository), + RepositoryProvider( + create: (context) => + NotificationRepository(bluesky: bluesky, moderationService: context.read()), + ), + RepositoryProvider( + create: (context) => + PostThreadRepository(bluesky: bluesky, moderationService: context.read()), + ), + RepositoryProvider(create: (_) => PostActionRepository(bluesky: bluesky)), + RepositoryProvider(create: (_) => ProfileActionRepository(bluesky: bluesky)), + RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), RepositoryProvider(create: (_) => PostActionCache()), - RepositoryProvider.value(value: profileActionRepository), RepositoryProvider.value(value: bluesky), - RepositoryProvider.value(value: convoRepository), RepositoryProvider.value(value: widget.database), RepositoryProvider.value(value: accountDid), ], - child: appShell, + child: MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => ProfileBloc(profileRepository: context.read())), + BlocProvider(create: (context) => FeedBloc(feedRepository: context.read())), + BlocProvider( + create: (context) => FeedPreferencesCubit( + feedRepository: context.read(), + database: widget.database, + accountDid: accountDid, + )..loadPreferences(), + ), + BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), + BlocProvider( + create: (context) => SearchBloc( + searchRepository: context.read(), + database: widget.database, + accountDid: accountDid, + ), + ), + BlocProvider( + create: (context) => + ConvoListBloc(convoRepository: context.read()) + ..add(const ConvosRequested(limit: 100)), + ), + BlocProvider( + create: (context) => SavedPostsCubit( + database: widget.database, + accountDid: accountDid, + postActionRepository: context.read(), + ), + ), + ], + child: appShell, + ), ); }, ), diff --git a/test/features/moderation/data/moderation_service_test.dart b/test/features/moderation/data/moderation_service_test.dart index d9d9d93..597ece9 100644 --- a/test/features/moderation/data/moderation_service_test.dart +++ b/test/features/moderation/data/moderation_service_test.dart @@ -1,109 +1,291 @@ +import 'dart:convert'; + +import 'package:atproto/com_atproto_label_defs.dart'; import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; -import 'package:bluesky/bluesky.dart'; -import 'package:bluesky/moderation.dart' as bsky_moderation; +import 'package:bluesky/app_bsky_labeler_getservices.dart'; +import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; -/// Tests for ModerationService. -/// -/// Note: Since Bluesky, ActorService, and LabelerService are sealed/base -/// classes, we cannot mock them with mocktail. Instead, we test the -/// ModerationService's behavior through its public API, focusing on: -/// - Default behavior before initialization (null opts) -/// - State management (currentOpts, optsStream) -/// - Moderation methods returning correct types +const _customLabelerDid = 'did:plc:custom-labeler'; +const _accountDid = 'did:plc:test-user'; + void main() { - late ModerationService moderationService; + late AppDatabase database; - setUp(() { - moderationService = ModerationService(bluesky: Bluesky.anonymous()); + setUp(() async { + database = AppDatabase(executor: NativeDatabase.memory()); }); - tearDown(() { - moderationService.dispose(); + tearDown(() async { + await database.close(); }); group('ModerationService', () { - group('initial state', () { - test('currentOpts is null before initialize', () { - expect(moderationService.currentOpts, isNull); - }); - - test('optsStream is a broadcast stream', () { - expect(moderationService.optsStream.isBroadcast, isTrue); - }); + test('initializes moderation opts, userDid, and accepted labeler headers', () async { + final service = ModerationService( + bluesky: _FakeBlueskyClient( + actor: _FakeActorService( + preferences: [ + const UPreferences.adultContentPref(data: AdultContentPref(enabled: false)), + const UPreferences.labelersPref( + data: LabelersPref(labelers: [LabelerPrefItem(did: _customLabelerDid)]), + ), + ], + ), + labeler: const _FakeLabelerService(), + ), + database: database, + accountDid: _accountDid, + userDid: _accountDid, + ); + + await service.ensureInitialized(); + + expect(service.currentOpts, isNotNull); + expect(service.currentOpts!.userDid, _accountDid); + expect(service.currentHeaders['atproto-accept-labelers'], contains(_customLabelerDid)); + expect(service.currentPrefs?.labelers.map((labeler) => labeler.did), contains(_customLabelerDid)); + + service.dispose(); }); - group('moderatePost', () { - test('returns ModerationDecision when opts are null', () { - final result = moderationService.moderatePost(_makeSamplePostView()); - expect(result, isA()); - }); + test('filters labeled posts in list contexts', () async { + final service = ModerationService( + bluesky: _FakeBlueskyClient( + actor: _FakeActorService( + preferences: [const UPreferences.adultContentPref(data: AdultContentPref(enabled: false))], + ), + labeler: const _FakeLabelerService(), + ), + database: database, + accountDid: _accountDid, + userDid: _accountDid, + ); + + await service.ensureInitialized(); + + final labeledPost = PostView( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), + cid: 'cid-123', + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), + record: { + r'$type': 'app.bsky.feed.post', + 'text': 'sensitive', + 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String(), + }, + indexedAt: DateTime.utc(2026, 3, 15), + labels: [ + Label( + src: 'did:plc:ar7c4by46qjdydhdevvrndac', + uri: 'at://did:plc:author/app.bsky.feed.post/abc', + val: 'porn', + cts: DateTime.utc(2026, 3, 15), + ), + ], + ); + + expect(service.shouldFilterPostInList(labeledPost), isTrue); + + service.dispose(); }); - group('moderateProfile', () { - test('returns ModerationDecision with ProfileView when opts are null', () { - final result = moderationService.moderateProfile( - const ProfileView(did: 'did:plc:user', handle: 'user.bsky.social'), - ); - expect(result, isA()); - }); + test('falls back to cached preferences after a request failure', () async { + final seededService = ModerationService( + bluesky: _FakeBlueskyClient( + actor: _FakeActorService( + preferences: [ + const UPreferences.labelersPref( + data: LabelersPref(labelers: [LabelerPrefItem(did: _customLabelerDid)]), + ), + ], + ), + labeler: const _FakeLabelerService(), + ), + database: database, + accountDid: _accountDid, + userDid: _accountDid, + ); + await seededService.ensureInitialized(); + seededService.dispose(); + + final fallbackService = ModerationService( + bluesky: _FakeBlueskyClient( + actor: _FakeActorService(error: Exception('offline')), + labeler: _FakeLabelerService(error: Exception('offline')), + ), + database: database, + accountDid: _accountDid, + userDid: _accountDid, + ); + + await fallbackService.ensureInitialized(); + + expect(fallbackService.currentPrefs, isNotNull); + expect(fallbackService.currentHeaders['atproto-accept-labelers'], contains(_customLabelerDid)); + + fallbackService.dispose(); }); - group('moderateProfileBasic', () { - test('returns ModerationDecision with ProfileViewBasic when opts are null', () { - final result = moderationService.moderateProfileBasic( - const ProfileViewBasic(did: 'did:plc:user', handle: 'user.bsky.social'), - ); - expect(result, isA()); - }); + test('subscribeToLabeler writes updated preferences and refreshes headers', () async { + final actor = _FakeActorService(preferences: const []); + final service = ModerationService( + bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), + database: database, + accountDid: _accountDid, + userDid: _accountDid, + ); + + await service.ensureInitialized(); + await service.subscribeToLabeler(_customLabelerDid); + + expect(actor.lastPutPreferences, isNotNull); + expect( + actor.lastPutPreferences! + .where((preference) => preference.isLabelersPref) + .single + .labelersPref! + .labelers + .map((labeler) => labeler.did), + contains(_customLabelerDid), + ); + expect(service.currentHeaders['atproto-accept-labelers'], contains(_customLabelerDid)); + + service.dispose(); }); - group('moderateProfileDetailed', () { - test('returns ModerationDecision with ProfileViewDetailed when opts are null', () { - const profile = ProfileViewDetailed(did: 'did:plc:user', handle: 'user.bsky.social'); - final result = moderationService.moderateProfileDetailed(profile); - expect(result, isA()); - }); + test('setLabelPreference stores contentLabelPref entries', () async { + final actor = _FakeActorService(preferences: const []); + final service = ModerationService( + bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), + database: database, + accountDid: _accountDid, + userDid: _accountDid, + ); + + await service.ensureInitialized(); + await service.setLabelPreference( + label: 'porn', + visibility: KnownContentLabelPrefVisibility.hide, + labelerDid: _customLabelerDid, + ); + + final contentPref = actor.lastPutPreferences! + .where((preference) => preference.isContentLabelPref) + .single + .contentLabelPref!; + + expect(contentPref.label, 'porn'); + expect(contentPref.labelerDid, _customLabelerDid); + expect(contentPref.visibility.toJson(), 'hide'); + + service.dispose(); }); - group('dispose', () { - test('can be called without error', () { - expect(() => moderationService.dispose(), returnsNormally); - }); + test('dispose is idempotent', () { + final service = ModerationService(bluesky: _FakeBlueskyClient()); - test('closes the opts stream', () async { - moderationService.dispose(); - final isDone = await moderationService.optsStream.isEmpty; - expect(isDone, isTrue); - }); + expect(() => service.dispose(), returnsNormally); + expect(() => service.dispose(), returnsNormally); }); - group('ModerationOpts', () { - test('can be constructed with prefs and empty labelDefs', () { - const prefs = bsky_moderation.ModerationPrefs( - adultContentEnabled: false, - labels: {}, - labelers: [], - mutedWords: [], - hiddenPosts: [], - ); - const opts = bsky_moderation.ModerationOpts(prefs: prefs); - expect(opts.prefs.adultContentEnabled, isFalse); - expect(opts.labelDefs, isEmpty); - }); + test('caches moderation preferences in the settings table', () async { + final service = ModerationService( + bluesky: _FakeBlueskyClient( + actor: _FakeActorService( + preferences: [ + const UPreferences.labelersPref( + data: LabelersPref(labelers: [LabelerPrefItem(did: _customLabelerDid)]), + ), + ], + ), + labeler: const _FakeLabelerService(), + ), + database: database, + accountDid: _accountDid, + ); + + await service.ensureInitialized(); + + final cachedPayload = await database.getSetting('moderation_preferences::$_accountDid'); + expect(cachedPayload, isNotNull); + + final decoded = jsonDecode(cachedPayload!) as List; + expect(decoded, isNotEmpty); + + service.dispose(); }); }); } -PostView _makeSamplePostView() { - return PostView( - uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), - cid: 'cid-123', - author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), - record: const {r'$type': 'app.bsky.feed.post', 'text': 'Hello world'}, - indexedAt: DateTime.utc(2026, 3, 15), - ); +class _FakeBlueskyClient { + _FakeBlueskyClient({_FakeActorService? actor, _FakeLabelerService? labeler}) + : actor = actor ?? _FakeActorService(), + labeler = labeler ?? const _FakeLabelerService(); + + final _FakeActorService actor; + final _FakeLabelerService labeler; +} + +class _FakeActorService { + _FakeActorService({this.preferences = const [], this.error}); + + final List preferences; + final Object? error; + List? lastPutPreferences; + + Future<_FakePreferencesResponse> getPreferences() async { + if (error != null) { + throw error!; + } + return _FakePreferencesResponse(_FakePreferencesData(preferences)); + } + + Future putPreferences({required List preferences}) async { + lastPutPreferences = preferences; + } +} + +class _FakeLabelerService { + const _FakeLabelerService({this.error}); + + final Object? error; + + Future<_FakeGetServicesResponse> getServices({ + required List dids, + bool? detailed, + Map? $headers, + }) async { + if (error != null) { + throw error!; + } + return const _FakeGetServicesResponse(_FakeGetServicesData([])); + } +} + +class _FakePreferencesResponse { + const _FakePreferencesResponse(this.data); + + final _FakePreferencesData data; +} + +class _FakePreferencesData { + const _FakePreferencesData(this.preferences); + + final List preferences; +} + +class _FakeGetServicesResponse { + const _FakeGetServicesResponse(this.data); + + final _FakeGetServicesData data; +} + +class _FakeGetServicesData { + const _FakeGetServicesData(this.views); + + final List views; } diff --git a/test/features/profile/data/profile_repository_test.dart b/test/features/profile/data/profile_repository_test.dart index 50e5966..755b600 100644 --- a/test/features/profile/data/profile_repository_test.dart +++ b/test/features/profile/data/profile_repository_test.dart @@ -79,11 +79,11 @@ class _FakeActorService { final Future<_FakeResponse> Function(String actor) onGetProfile; - Future<_FakeResponse> getProfile({required String actor}) { + Future<_FakeResponse> getProfile({required String actor, Map? $headers}) { return onGetProfile(actor); } - Future<_FakeProfilesResponse> getProfiles({required List actors}) async { + Future<_FakeProfilesResponse> getProfiles({required List actors, Map? $headers}) async { return _FakeProfilesResponse(const _FakeProfilesData([])); } }