mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: moderation service for filtering profiles, feed posts, and notifications

+1050 -193
+2 -6
lib/core/router/app_router.dart
··· 141 141 providers: [ 142 142 if (existingUnreadCubit == null) 143 143 BlocProvider( 144 - create: (_) => UnreadCountCubit( 145 - notificationRepository: NotificationRepository(bluesky: context.read<Bluesky>()), 146 - ), 144 + create: (_) => UnreadCountCubit(notificationRepository: context.read<NotificationRepository>()), 147 145 ), 148 146 ], 149 147 child: AppShell(navigationShell: navigationShell), ··· 201 199 GoRoute( 202 200 path: '/notifications', 203 201 builder: (context, state) => BlocProvider( 204 - create: (_) => NotificationBloc( 205 - notificationRepository: NotificationRepository(bluesky: context.read<Bluesky>()), 206 - ), 202 + create: (_) => NotificationBloc(notificationRepository: context.read<NotificationRepository>()), 207 203 child: const NotificationsScreen(), 208 204 ), 209 205 ),
+3 -3
lib/core/router/app_shell.dart
··· 1 - import 'package:bluesky/bluesky.dart'; 2 1 import 'package:flutter/material.dart'; 3 2 import 'package:flutter_bloc/flutter_bloc.dart'; 4 3 import 'package:go_router/go_router.dart'; 5 4 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 6 5 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 6 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 7 7 import 'package:provider/provider.dart'; 8 8 9 9 class AppShellScope extends InheritedWidget { ··· 367 367 } 368 368 369 369 try { 370 - final profile = await context.read<Bluesky>().actor.getProfile(actor: did); 371 - return profile.data.avatar; 370 + final profile = await context.read<ProfileRepository>().getProfile(did); 371 + return profile.avatar; 372 372 } catch (_) { 373 373 return null; 374 374 }
+36 -7
lib/features/feed/data/feed_repository.dart
··· 3 3 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 4 import 'package:bluesky/app_bsky_feed_getauthorfeed.dart'; 5 5 import 'package:bluesky/bluesky.dart'; 6 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 6 7 7 8 class FeedRepository { 8 - FeedRepository({required Bluesky bluesky}) : _bluesky = bluesky; 9 + FeedRepository({required Bluesky bluesky, ModerationService? moderationService}) 10 + : _bluesky = bluesky, 11 + _moderationService = moderationService; 9 12 10 13 final Bluesky _bluesky; 14 + final ModerationService? _moderationService; 11 15 12 16 Future<FeedResult> getAuthorFeed({ 13 17 required String actor, ··· 16 20 int limit = 50, 17 21 }) async { 18 22 final bskyFilter = _mapToBskyFilter(filter); 23 + final headers = await _moderationService?.headersForRequest(); 19 24 20 - final response = await _bluesky.feed.getAuthorFeed(actor: actor, cursor: cursor, limit: limit, filter: bskyFilter); 25 + final response = await _bluesky.feed.getAuthorFeed( 26 + actor: actor, 27 + cursor: cursor, 28 + limit: limit, 29 + filter: bskyFilter, 30 + $headers: headers, 31 + ); 21 32 22 - return FeedResult(posts: response.data.feed, cursor: response.data.cursor); 33 + return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); 23 34 } 24 35 25 36 Future<FeedResult> getTimeline({String? cursor, int limit = 50}) async { 26 - final response = await _bluesky.feed.getTimeline(cursor: cursor, limit: limit); 37 + final response = await _bluesky.feed.getTimeline( 38 + cursor: cursor, 39 + limit: limit, 40 + $headers: await _moderationService?.headersForRequest(), 41 + ); 27 42 28 - return FeedResult(posts: response.data.feed, cursor: response.data.cursor); 43 + return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); 29 44 } 30 45 31 46 Future<FeedResult> getFeed({required AtUri feedUri, String? cursor, int limit = 50}) async { 32 - final response = await _bluesky.feed.getFeed(feed: feedUri, cursor: cursor, limit: limit); 47 + final response = await _bluesky.feed.getFeed( 48 + feed: feedUri, 49 + cursor: cursor, 50 + limit: limit, 51 + $headers: await _moderationService?.headersForRequest(), 52 + ); 33 53 34 - return FeedResult(posts: response.data.feed, cursor: response.data.cursor); 54 + return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); 35 55 } 36 56 37 57 Future<PreferencesResult> getPreferences() async { ··· 68 88 case FeedFilter.postsAndAuthorThreads: 69 89 return const FeedGetAuthorFeedFilter.knownValue(data: KnownFeedGetAuthorFeedFilter.posts_and_author_threads); 70 90 } 91 + } 92 + 93 + List<FeedViewPost> _filterFeedPosts(List<FeedViewPost> posts) { 94 + final moderationService = _moderationService; 95 + if (moderationService == null) { 96 + return posts; 97 + } 98 + 99 + return posts.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(); 71 100 } 72 101 } 73 102
+51 -3
lib/features/feed/data/post_thread_repository.dart
··· 2 2 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 3 import 'package:bluesky/app_bsky_feed_getpostthread.dart'; 4 4 import 'package:bluesky/bluesky.dart'; 5 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 5 6 6 7 class PostThreadRepository { 7 - PostThreadRepository({required Bluesky bluesky}) : _bluesky = bluesky; 8 + PostThreadRepository({required Bluesky bluesky, ModerationService? moderationService}) 9 + : _bluesky = bluesky, 10 + _moderationService = moderationService; 8 11 9 12 final Bluesky _bluesky; 13 + final ModerationService? _moderationService; 10 14 11 15 Future<ThreadViewPost> getPostThread(String uri) async { 12 - final response = await _bluesky.feed.getPostThread(uri: AtUri.parse(uri)); 16 + final response = await _bluesky.feed.getPostThread( 17 + uri: AtUri.parse(uri), 18 + $headers: await _moderationService?.headersForRequest(), 19 + ); 13 20 final thread = response.data.thread; 14 21 15 22 if (thread.isThreadViewPost) { 16 - return thread.threadViewPost!; 23 + final threadViewPost = thread.threadViewPost!; 24 + if (_moderationService?.shouldFilterPostInView(threadViewPost.post) ?? false) { 25 + throw Exception('Post hidden by moderation preferences'); 26 + } 27 + 28 + return _pruneThread(threadViewPost); 17 29 } 18 30 19 31 if (thread.isNotFoundPost) { ··· 25 37 } 26 38 27 39 throw Exception('Unable to load thread'); 40 + } 41 + 42 + ThreadViewPost _pruneThread(ThreadViewPost thread) { 43 + final prunedParent = _pruneParent(thread.parent); 44 + final prunedReplies = thread.replies?.map(_pruneReply).whereType<UThreadViewPostReplies>().toList(); 45 + 46 + return thread.copyWith(parent: prunedParent, replies: prunedReplies); 47 + } 48 + 49 + UThreadViewPostParent? _pruneParent(UThreadViewPostParent? parent) { 50 + if (parent == null) { 51 + return null; 52 + } 53 + if (parent.isNotThreadViewPost) { 54 + return parent; 55 + } 56 + 57 + final thread = parent.threadViewPost!; 58 + if (_moderationService?.shouldFilterPostInList(thread.post) ?? false) { 59 + return _pruneParent(thread.parent); 60 + } 61 + 62 + return UThreadViewPostParent.threadViewPost(data: _pruneThread(thread)); 63 + } 64 + 65 + UThreadViewPostReplies? _pruneReply(UThreadViewPostReplies reply) { 66 + if (reply.isNotThreadViewPost) { 67 + return reply; 68 + } 69 + 70 + final thread = reply.threadViewPost!; 71 + if (_moderationService?.shouldFilterPostInList(thread.post) ?? false) { 72 + return null; 73 + } 74 + 75 + return UThreadViewPostReplies.threadViewPost(data: _pruneThread(thread)); 28 76 } 29 77 }
+1 -3
lib/features/feed/presentation/post_thread_screen.dart
··· 3 3 4 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 5 5 import 'package:bluesky/app_bsky_feed_post.dart'; 6 - import 'package:bluesky/bluesky.dart'; 7 6 import 'package:flutter/material.dart'; 8 7 import 'package:flutter/services.dart'; 9 8 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 31 30 @override 32 31 Widget build(BuildContext context) { 33 32 return BlocProvider( 34 - create: (_) => 35 - PostThreadCubit(postThreadRepository: PostThreadRepository(bluesky: context.read<Bluesky>()))..load(postUri), 33 + create: (_) => PostThreadCubit(postThreadRepository: context.read<PostThreadRepository>())..load(postUri), 36 34 child: _PostThreadContent(postUri: postUri), 37 35 ); 38 36 }
+511 -33
lib/features/moderation/data/moderation_service.dart
··· 1 1 import 'dart:async'; 2 + import 'dart:convert'; 2 3 3 - import 'package:bluesky/bluesky.dart'; 4 - import 'package:bluesky/moderation.dart' as bsky_moderation; 5 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 5 + import 'package:bluesky/app_bsky_actor_getpreferences.dart'; 6 6 import 'package:bluesky/app_bsky_feed_defs.dart'; 7 + import 'package:bluesky/app_bsky_labeler_defs.dart'; 8 + import 'package:bluesky/app_bsky_labeler_getservices.dart'; 7 9 import 'package:bluesky/app_bsky_notification_listnotifications.dart' as notifications; 10 + import 'package:bluesky/bluesky.dart'; 11 + import 'package:bluesky/moderation.dart' as bsky_moderation; 12 + import 'package:lazurite/core/database/app_database.dart'; 8 13 import 'package:lazurite/core/logging/app_logger.dart'; 9 14 15 + const _officialBlueskyLabelerDid = 'did:plc:ar7c4by46qjdydhdevvrndac'; 16 + const _maxCustomLabelers = 20; 17 + 10 18 class ModerationService { 11 - ModerationService({required Bluesky bluesky}) : _bluesky = bluesky; 19 + ModerationService({required dynamic bluesky, AppDatabase? database, String? accountDid, String? userDid}) 20 + : _bluesky = bluesky, 21 + _database = database, 22 + _accountDid = accountDid, 23 + _userDid = userDid; 12 24 13 - final Bluesky _bluesky; 25 + final dynamic _bluesky; 26 + final AppDatabase? _database; 27 + final String? _accountDid; 28 + final String? _userDid; 29 + 14 30 bsky_moderation.ModerationOpts? _opts; 31 + List<UPreferences> _preferences = const []; 32 + Map<String, String> _headers = _buildLabelerHeaders(const []); 33 + Future<void>? _initializationFuture; 34 + bool _disposed = false; 15 35 final _optsController = StreamController<bsky_moderation.ModerationOpts>.broadcast(); 16 36 17 37 Stream<bsky_moderation.ModerationOpts> get optsStream => _optsController.stream; 18 38 bsky_moderation.ModerationOpts? get currentOpts => _opts; 39 + bsky_moderation.ModerationPrefs? get currentPrefs => _opts?.prefs; 40 + List<UPreferences> get currentPreferences => List.unmodifiable(_preferences); 41 + Map<String, String> get currentHeaders => Map.unmodifiable(_headers); 19 42 20 - Future<void> initialize() async { 21 - await _rebuildOpts(); 43 + Future<void> initialize() => ensureInitialized(); 44 + 45 + Future<void> ensureInitialized() async { 46 + if (_disposed) return; 47 + if (_opts != null) return; 48 + 49 + final inFlight = _initializationFuture; 50 + if (inFlight != null) { 51 + await inFlight; 52 + return; 53 + } 54 + 55 + final future = _rebuildOpts(); 56 + _initializationFuture = future; 57 + try { 58 + await future; 59 + } finally { 60 + if (identical(_initializationFuture, future)) { 61 + _initializationFuture = null; 62 + } 63 + } 22 64 } 23 65 24 - Future<void> updatePreferences() async { 25 - await _rebuildOpts(); 66 + Future<void> updatePreferences({List<UPreferences>? preferences}) async { 67 + if (_disposed) return; 68 + await _rebuildOpts(preferences: preferences, forceRefresh: true); 26 69 } 27 70 28 - Future<void> _rebuildOpts() async { 29 - try { 30 - final prefsResponse = await _bluesky.actor.getPreferences(); 31 - final prefs = prefsResponse.data.getModerationPrefs(); 71 + Future<Map<String, String>> headersForRequest() async { 72 + await ensureInitialized(); 73 + return currentHeaders; 74 + } 32 75 33 - final labelDefs = await _bluesky.labeler.getLabelDefinitions(prefs); 76 + Future<List<ULabelerGetServicesViews>> getSubscribedLabelers() async { 77 + final labelerDids = await _getSubscribedLabelerDids(); 78 + if (labelerDids.isEmpty) { 79 + return const []; 80 + } 34 81 35 - final opts = bsky_moderation.ModerationOpts(prefs: prefs, labelDefs: labelDefs); 82 + final response = await _bluesky.labeler.getServices( 83 + dids: labelerDids, 84 + detailed: true, 85 + $headers: await headersForRequest(), 86 + ); 36 87 37 - _opts = opts; 38 - _optsController.add(opts); 39 - } catch (error) { 40 - log.w('Failed to build moderation opts: $error'); 88 + await _cacheLabelerPolicies(response.data.views); 89 + return response.data.views; 90 + } 91 + 92 + Future<LabelerViewDetailed?> getLabelerDetails(String did) async { 93 + final response = await _bluesky.labeler.getServices( 94 + dids: [did], 95 + detailed: true, 96 + $headers: await headersForRequest(), 97 + ); 98 + 99 + await _cacheLabelerPolicies(response.data.views); 100 + 101 + for (final view in response.data.views) { 102 + if (view.isLabelerViewDetailed) { 103 + return view.labelerViewDetailed!; 104 + } 41 105 } 106 + 107 + return null; 42 108 } 43 109 110 + Future<void> subscribeToLabeler(String did) async { 111 + if (did == _officialBlueskyLabelerDid) { 112 + return; 113 + } 114 + 115 + final preferences = await _loadPreferences(); 116 + final currentLabelers = _subscribedLabelerDidsFromPreferences(preferences); 117 + if (currentLabelers.contains(did)) { 118 + return; 119 + } 120 + if (currentLabelers.length >= _maxCustomLabelers) { 121 + throw StateError('A maximum of $_maxCustomLabelers labelers can be subscribed.'); 122 + } 123 + 124 + final updated = _replaceLabelersPref(preferences, [ 125 + ...currentLabelers.map((labelerDid) => LabelerPrefItem(did: labelerDid)), 126 + LabelerPrefItem(did: did), 127 + ]); 128 + 129 + await _putAndRefresh(updated); 130 + } 131 + 132 + Future<void> unsubscribeFromLabeler(String did) async { 133 + if (did == _officialBlueskyLabelerDid) { 134 + return; 135 + } 136 + 137 + final preferences = await _loadPreferences(); 138 + final currentLabelers = _subscribedLabelerDidsFromPreferences(preferences); 139 + final updatedItems = currentLabelers 140 + .where((labelerDid) => labelerDid != did) 141 + .map((labelerDid) => LabelerPrefItem(did: labelerDid)) 142 + .toList(); 143 + 144 + final updated = _replaceLabelersPref(preferences, updatedItems); 145 + await _putAndRefresh(updated); 146 + } 147 + 148 + Future<void> setAdultContentEnabled(bool enabled) async { 149 + final preferences = await _loadPreferences(); 150 + final updated = _replaceAdultContentPref(preferences, enabled); 151 + await _putAndRefresh(updated); 152 + } 153 + 154 + Future<void> setLabelPreference({ 155 + required String label, 156 + required KnownContentLabelPrefVisibility visibility, 157 + String? labelerDid, 158 + }) async { 159 + final preferences = await _loadPreferences(); 160 + final updated = _replaceContentLabelPref( 161 + preferences, 162 + label: label, 163 + labelerDid: labelerDid, 164 + visibility: ContentLabelPrefVisibility.knownValue(data: visibility), 165 + ); 166 + 167 + await _putAndRefresh(updated); 168 + } 169 + 170 + bsky_moderation.ModerationDecision moderateFeedViewPost(FeedViewPost post) => moderatePost(post.post); 171 + 44 172 bsky_moderation.ModerationDecision moderatePost(PostView post) { 45 - if (_opts == null) { 46 - return bsky_moderation.ModerationDecision.merge([]); 173 + final opts = _opts; 174 + if (opts == null) { 175 + return bsky_moderation.ModerationDecision.merge(const []); 47 176 } 48 - return bsky_moderation.moderatePost(bsky_moderation.ModerationSubjectPost.postView(data: post), _opts!); 177 + 178 + return bsky_moderation.moderatePost(bsky_moderation.ModerationSubjectPost.postView(data: post), opts); 49 179 } 50 180 51 181 bsky_moderation.ModerationDecision moderateProfile(ProfileView profile) { 52 - if (_opts == null) { 53 - return bsky_moderation.ModerationDecision.merge([]); 182 + final opts = _opts; 183 + if (opts == null) { 184 + return bsky_moderation.ModerationDecision.merge(const []); 54 185 } 55 - return bsky_moderation.moderateProfile(bsky_moderation.ModerationSubjectProfile.profileView(data: profile), _opts!); 186 + 187 + return bsky_moderation.moderateProfile(bsky_moderation.ModerationSubjectProfile.profileView(data: profile), opts); 56 188 } 57 189 58 190 bsky_moderation.ModerationDecision moderateProfileBasic(ProfileViewBasic profile) { 59 - if (_opts == null) { 60 - return bsky_moderation.ModerationDecision.merge([]); 191 + final opts = _opts; 192 + if (opts == null) { 193 + return bsky_moderation.ModerationDecision.merge(const []); 61 194 } 195 + 62 196 return bsky_moderation.moderateProfile( 63 197 bsky_moderation.ModerationSubjectProfile.profileViewBasic(data: profile), 64 - _opts!, 198 + opts, 65 199 ); 66 200 } 67 201 68 202 bsky_moderation.ModerationDecision moderateProfileDetailed(ProfileViewDetailed profile) { 69 - if (_opts == null) { 70 - return bsky_moderation.ModerationDecision.merge([]); 203 + final opts = _opts; 204 + if (opts == null) { 205 + return bsky_moderation.ModerationDecision.merge(const []); 71 206 } 207 + 72 208 return bsky_moderation.moderateProfile( 73 209 bsky_moderation.ModerationSubjectProfile.profileViewDetailed(data: profile), 74 - _opts!, 210 + opts, 75 211 ); 76 212 } 77 213 78 214 bsky_moderation.ModerationDecision moderateNotification(notifications.Notification notification) { 79 - if (_opts == null) { 80 - return bsky_moderation.ModerationDecision.merge([]); 215 + final opts = _opts; 216 + if (opts == null) { 217 + return bsky_moderation.ModerationDecision.merge(const []); 81 218 } 219 + 82 220 return bsky_moderation.moderateNotification( 83 221 bsky_moderation.ModerationSubjectNotification.notification(data: notification), 84 - _opts!, 222 + opts, 85 223 ); 86 224 } 87 225 226 + bsky_moderation.ModerationUI postUi(PostView post, bsky_moderation.ModerationBehaviorContext context) => 227 + moderatePost(post).getUI(context); 228 + 229 + bsky_moderation.ModerationUI profileUi(ProfileView profile, bsky_moderation.ModerationBehaviorContext context) => 230 + moderateProfile(profile).getUI(context); 231 + 232 + bsky_moderation.ModerationUI profileBasicUi( 233 + ProfileViewBasic profile, 234 + bsky_moderation.ModerationBehaviorContext context, 235 + ) => moderateProfileBasic(profile).getUI(context); 236 + 237 + bsky_moderation.ModerationUI profileDetailedUi( 238 + ProfileViewDetailed profile, 239 + bsky_moderation.ModerationBehaviorContext context, 240 + ) => moderateProfileDetailed(profile).getUI(context); 241 + 242 + bsky_moderation.ModerationUI notificationUi( 243 + notifications.Notification notification, 244 + bsky_moderation.ModerationBehaviorContext context, 245 + ) => moderateNotification(notification).getUI(context); 246 + 247 + bool shouldFilterFeedViewPostInList(FeedViewPost post) => shouldFilterPostInList(post.post); 248 + 249 + bool shouldFilterPostInList(PostView post) => 250 + postUi(post, bsky_moderation.ModerationBehaviorContext.contentList).filter; 251 + 252 + bool shouldFilterPostInView(PostView post) => 253 + postUi(post, bsky_moderation.ModerationBehaviorContext.contentView).filter; 254 + 255 + bool shouldFilterProfileInList(ProfileView profile) => 256 + profileUi(profile, bsky_moderation.ModerationBehaviorContext.profileList).filter; 257 + 258 + bool shouldFilterProfileBasicInList(ProfileViewBasic profile) => 259 + profileBasicUi(profile, bsky_moderation.ModerationBehaviorContext.profileList).filter; 260 + 261 + bool shouldFilterProfileDetailedInView(ProfileViewDetailed profile) => 262 + profileDetailedUi(profile, bsky_moderation.ModerationBehaviorContext.profileView).filter; 263 + 264 + bool shouldFilterNotificationInList(notifications.Notification notification) { 265 + final decision = moderateNotification(notification); 266 + return decision.getUI(bsky_moderation.ModerationBehaviorContext.contentList).filter || 267 + decision.getUI(bsky_moderation.ModerationBehaviorContext.profileList).filter; 268 + } 269 + 88 270 void dispose() { 271 + if (_disposed) { 272 + return; 273 + } 274 + 275 + _disposed = true; 89 276 _optsController.close(); 90 277 } 278 + 279 + Future<void> _rebuildOpts({List<UPreferences>? preferences, bool forceRefresh = false}) async { 280 + try { 281 + final resolvedPreferences = await _loadPreferences(providedPreferences: preferences, forceRefresh: forceRefresh); 282 + 283 + final moderationPrefs = _toModerationPrefs(resolvedPreferences); 284 + final labelDefs = await _loadLabelDefinitions(moderationPrefs); 285 + final opts = bsky_moderation.ModerationOpts( 286 + userDid: _resolvedUserDid, 287 + prefs: moderationPrefs, 288 + labelDefs: labelDefs, 289 + ); 290 + 291 + _preferences = resolvedPreferences; 292 + _headers = _buildHeadersForPrefs(moderationPrefs); 293 + _opts = opts; 294 + 295 + if (!_disposed) { 296 + _optsController.add(opts); 297 + } 298 + } catch (error, stackTrace) { 299 + log.w('Failed to build moderation opts: $error'); 300 + log.d('$stackTrace'); 301 + } 302 + } 303 + 304 + Future<List<UPreferences>> _loadPreferences({ 305 + List<UPreferences>? providedPreferences, 306 + bool forceRefresh = false, 307 + }) async { 308 + if (providedPreferences != null) { 309 + await _cachePreferences(providedPreferences); 310 + return providedPreferences; 311 + } 312 + 313 + if (!forceRefresh && _preferences.isNotEmpty) { 314 + return _preferences; 315 + } 316 + 317 + try { 318 + final prefsResponse = await _bluesky.actor.getPreferences(); 319 + final preferences = prefsResponse.data.preferences; 320 + await _cachePreferences(preferences); 321 + return preferences; 322 + } catch (error) { 323 + final cached = await _loadCachedPreferences(); 324 + if (cached != null) { 325 + log.w('Using cached moderation preferences after request failure: $error'); 326 + return cached; 327 + } 328 + 329 + rethrow; 330 + } 331 + } 332 + 333 + Future<void> _putAndRefresh(List<UPreferences> preferences) async { 334 + await _bluesky.actor.putPreferences(preferences: preferences); 335 + await updatePreferences(preferences: preferences); 336 + } 337 + 338 + Future<Map<String, List<bsky_moderation.InterpretedLabelValueDefinition>>> _loadLabelDefinitions( 339 + bsky_moderation.ModerationPrefs prefs, 340 + ) async { 341 + final labelerDids = { 342 + _officialBlueskyLabelerDid, 343 + ...prefs.labelers.map((labeler) => labeler.did), 344 + }.where((did) => did.startsWith('did:')).toList(); 345 + 346 + try { 347 + final response = await _bluesky.labeler.getServices( 348 + dids: labelerDids, 349 + detailed: true, 350 + $headers: _buildHeadersForPrefs(prefs), 351 + ); 352 + 353 + await _cacheLabelerPolicies(response.data.views); 354 + return _mapLabelDefinitions(response.data.views); 355 + } catch (error) { 356 + final cached = await _loadCachedLabelDefinitions(labelerDids); 357 + if (cached.isNotEmpty) { 358 + log.w('Using cached label definitions after request failure: $error'); 359 + return cached; 360 + } 361 + 362 + log.w('Proceeding without cached label definitions after request failure: $error'); 363 + return const {}; 364 + } 365 + } 366 + 367 + Future<void> _cacheLabelerPolicies(List<ULabelerGetServicesViews> views) async { 368 + if (_database == null) { 369 + return; 370 + } 371 + 372 + for (final view in views) { 373 + if (!view.isLabelerViewDetailed) { 374 + continue; 375 + } 376 + 377 + final detailed = view.labelerViewDetailed!; 378 + await _database.upsertLabelerCache(detailed.creator.did, jsonEncode(detailed.policies.toJson())); 379 + } 380 + } 381 + 382 + Future<Map<String, List<bsky_moderation.InterpretedLabelValueDefinition>>> _loadCachedLabelDefinitions( 383 + List<String> labelerDids, 384 + ) async { 385 + if (_database == null) { 386 + return const {}; 387 + } 388 + 389 + final definitions = <String, List<bsky_moderation.InterpretedLabelValueDefinition>>{}; 390 + for (final did in labelerDids) { 391 + final cached = await _database.getLabelerCache(did); 392 + if (cached == null) { 393 + continue; 394 + } 395 + 396 + final policies = LabelerPolicies.fromJson(jsonDecode(cached.policiesJson) as Map<String, dynamic>); 397 + definitions[did] = _interpretedLabelDefinitionsFromPolicies(policies, labelerDid: did); 398 + } 399 + 400 + return definitions; 401 + } 402 + 403 + Future<void> _cachePreferences(List<UPreferences> preferences) async { 404 + final database = _database; 405 + final prefsKey = _preferencesCacheKey; 406 + if (database == null || prefsKey == null) { 407 + return; 408 + } 409 + 410 + final payload = jsonEncode(preferences.map((preference) => preference.toJson()).toList()); 411 + await database.setSetting(prefsKey, payload); 412 + } 413 + 414 + Future<List<UPreferences>?> _loadCachedPreferences() async { 415 + final database = _database; 416 + final prefsKey = _preferencesCacheKey; 417 + if (database == null || prefsKey == null) { 418 + return null; 419 + } 420 + 421 + final payload = await database.getSetting(prefsKey); 422 + if (payload == null || payload.isEmpty) { 423 + return null; 424 + } 425 + 426 + final decoded = jsonDecode(payload) as List<dynamic>; 427 + return decoded 428 + .map((json) => const UPreferencesConverter().fromJson(Map<String, dynamic>.from(json as Map))) 429 + .toList(); 430 + } 431 + 432 + Future<List<String>> _getSubscribedLabelerDids() async { 433 + final preferences = await _loadPreferences(); 434 + return _subscribedLabelerDidsFromPreferences(preferences); 435 + } 436 + 437 + List<String> _subscribedLabelerDidsFromPreferences(List<UPreferences> preferences) { 438 + for (final preference in preferences) { 439 + if (preference.isLabelersPref) { 440 + return preference.labelersPref!.labelers 441 + .map((item) => item.did) 442 + .where((did) => did.startsWith('did:')) 443 + .toList(); 444 + } 445 + } 446 + 447 + return const []; 448 + } 449 + 450 + List<UPreferences> _replaceLabelersPref(List<UPreferences> preferences, List<LabelerPrefItem> labelers) { 451 + final updated = List<UPreferences>.from(preferences); 452 + updated.removeWhere((preference) => preference.isLabelersPref); 453 + updated.add(UPreferences.labelersPref(data: LabelersPref(labelers: labelers))); 454 + return updated; 455 + } 456 + 457 + List<UPreferences> _replaceAdultContentPref(List<UPreferences> preferences, bool enabled) { 458 + final updated = List<UPreferences>.from(preferences); 459 + updated.removeWhere((preference) => preference.isAdultContentPref); 460 + updated.add(UPreferences.adultContentPref(data: AdultContentPref(enabled: enabled))); 461 + return updated; 462 + } 463 + 464 + List<UPreferences> _replaceContentLabelPref( 465 + List<UPreferences> preferences, { 466 + required String label, 467 + required ContentLabelPrefVisibility visibility, 468 + String? labelerDid, 469 + }) { 470 + final updated = List<UPreferences>.from(preferences); 471 + updated.removeWhere( 472 + (preference) => 473 + preference.isContentLabelPref && 474 + preference.contentLabelPref!.label == label && 475 + preference.contentLabelPref!.labelerDid == labelerDid, 476 + ); 477 + updated.add( 478 + UPreferences.contentLabelPref( 479 + data: ContentLabelPref(label: label, labelerDid: labelerDid, visibility: visibility), 480 + ), 481 + ); 482 + return updated; 483 + } 484 + 485 + bsky_moderation.ModerationPrefs _toModerationPrefs(List<UPreferences> preferences) { 486 + return ActorGetPreferencesOutput( 487 + preferences: preferences, 488 + ).getModerationPrefs(appLabelers: const [_officialBlueskyLabelerDid]); 489 + } 490 + 491 + Map<String, List<bsky_moderation.InterpretedLabelValueDefinition>> _mapLabelDefinitions( 492 + List<ULabelerGetServicesViews> views, 493 + ) { 494 + final definitions = <String, List<bsky_moderation.InterpretedLabelValueDefinition>>{}; 495 + 496 + for (final view in views) { 497 + if (!view.isLabelerViewDetailed) { 498 + continue; 499 + } 500 + 501 + final detailed = view.labelerViewDetailed!; 502 + definitions[detailed.creator.did] = _interpretedLabelDefinitionsFromPolicies( 503 + detailed.policies, 504 + labelerDid: detailed.creator.did, 505 + ); 506 + } 507 + 508 + return definitions; 509 + } 510 + 511 + List<bsky_moderation.InterpretedLabelValueDefinition> _interpretedLabelDefinitionsFromPolicies( 512 + LabelerPolicies policies, { 513 + required String labelerDid, 514 + }) { 515 + return policies.labelValueDefinitions 516 + ?.map( 517 + (definition) => bsky_moderation.getInterpretedLabelValueDefinition( 518 + identifier: definition.identifier, 519 + defaultSetting: 520 + bsky_moderation.LabelPreference.valueOf(definition.defaultSetting?.toJson()) ?? 521 + bsky_moderation.LabelPreference.warn, 522 + severity: definition.severity.toJson(), 523 + blurs: definition.blurs.toJson(), 524 + adultOnly: definition.adultOnly ?? true, 525 + definedBy: labelerDid, 526 + ), 527 + ) 528 + .toList() ?? 529 + const []; 530 + } 531 + 532 + Map<String, String> _buildHeadersForPrefs(bsky_moderation.ModerationPrefs prefs) { 533 + return _buildLabelerHeaders(prefs.labelers.map((labeler) => labeler.did)); 534 + } 535 + 536 + String? get _preferencesCacheKey { 537 + final accountDid = _accountDid; 538 + if (accountDid == null || accountDid.isEmpty) { 539 + return null; 540 + } 541 + 542 + return 'moderation_preferences::$accountDid'; 543 + } 544 + 545 + String? get _resolvedUserDid { 546 + final explicitUserDid = _userDid; 547 + if (explicitUserDid != null && explicitUserDid.isNotEmpty) { 548 + return explicitUserDid; 549 + } 550 + 551 + final bluesky = _bluesky; 552 + if (bluesky is Bluesky) { 553 + return bluesky.oAuthSession?.sub ?? bluesky.session?.did; 554 + } 555 + 556 + return null; 557 + } 558 + } 559 + 560 + Map<String, String> _buildLabelerHeaders(Iterable<String> subscribedLabelers) { 561 + final dids = <String>{ 562 + _officialBlueskyLabelerDid, 563 + ...subscribedLabelers 564 + .where((did) => did.startsWith('did:') && did != _officialBlueskyLabelerDid) 565 + .take(_maxCustomLabelers), 566 + }; 567 + 568 + return {'atproto-accept-labelers': dids.map((did) => '$did;redact').join(', ')}; 91 569 }
+22 -3
lib/features/notifications/data/notification_repository.dart
··· 1 1 import 'package:bluesky/app_bsky_notification_listnotifications.dart'; 2 2 import 'package:bluesky/bluesky.dart'; 3 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 3 4 4 5 class NotificationRepository { 5 - NotificationRepository({required Bluesky bluesky}) : _bluesky = bluesky; 6 + NotificationRepository({required Bluesky bluesky, ModerationService? moderationService}) 7 + : _bluesky = bluesky, 8 + _moderationService = moderationService; 6 9 7 10 final Bluesky _bluesky; 11 + final ModerationService? _moderationService; 8 12 9 13 Future<NotificationListResult> listNotifications({String? cursor, int limit = 50}) async { 10 - final response = await _bluesky.notification.listNotifications(cursor: cursor, limit: limit); 14 + final response = await _bluesky.notification.listNotifications( 15 + cursor: cursor, 16 + limit: limit, 17 + $headers: await _moderationService?.headersForRequest(), 18 + ); 11 19 12 20 return NotificationListResult( 13 - notifications: response.data.notifications, 21 + notifications: _filterNotifications(response.data.notifications), 14 22 cursor: response.data.cursor, 15 23 seenAt: response.data.seenAt, 16 24 ); ··· 23 31 24 32 Future<void> updateSeen() async { 25 33 await _bluesky.notification.updateSeen(seenAt: DateTime.now()); 34 + } 35 + 36 + List<Notification> _filterNotifications(List<Notification> notifications) { 37 + final moderationService = _moderationService; 38 + if (moderationService == null) { 39 + return notifications; 40 + } 41 + 42 + return notifications 43 + .where((notification) => !moderationService.shouldFilterNotificationInList(notification)) 44 + .toList(); 26 45 } 27 46 } 28 47
+29 -7
lib/features/profile/data/profile_repository.dart
··· 6 6 import 'package:lazurite/core/database/app_database.dart'; 7 7 import 'package:lazurite/core/logging/app_logger.dart'; 8 8 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 9 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 9 10 10 11 class ProfileRepository { 11 - ProfileRepository({required AppDatabase database, required dynamic bluesky}) 12 + ProfileRepository({required AppDatabase database, required dynamic bluesky, ModerationService? moderationService}) 12 13 : _database = database, 13 - _bluesky = bluesky; 14 + _bluesky = bluesky, 15 + _moderationService = moderationService; 14 16 15 17 final AppDatabase _database; 16 18 final dynamic _bluesky; 19 + final ModerationService? _moderationService; 17 20 18 21 Future<ProfileViewDetailed> getProfile(String actor) async { 19 22 log.d('ProfileRepository: Loading profile for $actor via ${_describeClientContext()}'); 20 23 21 24 try { 22 - final response = await _bluesky.actor.getProfile(actor: actor); 25 + final response = await _bluesky.actor.getProfile( 26 + actor: actor, 27 + $headers: await _moderationService?.headersForRequest(), 28 + ); 23 29 final profile = response.data; 24 30 log.i('ProfileRepository: Loaded profile ${profile.did} (${profile.handle})'); 25 31 26 32 await _database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); 27 33 log.d('ProfileRepository: Cached profile ${profile.did} (${profile.handle})'); 28 34 35 + if (_moderationService?.shouldFilterProfileDetailedInView(profile) ?? false) { 36 + throw Exception('Profile hidden by moderation preferences'); 37 + } 38 + 29 39 return profile; 30 40 } catch (error, stackTrace) { 31 41 log.e('ProfileRepository: Failed to load profile for $actor', error: error, stackTrace: stackTrace); 32 42 final cachedProfile = await _getCachedProfile(actor); 33 43 if (cachedProfile != null) { 34 44 log.w('ProfileRepository: Using cached profile for $actor after request failure'); 45 + if (_moderationService?.shouldFilterProfileDetailedInView(cachedProfile) ?? false) { 46 + throw Exception('Profile hidden by moderation preferences'); 47 + } 35 48 return cachedProfile; 36 49 } 37 50 ··· 41 54 42 55 Future<List<ProfileView>> getProfiles(List<String> actors) async { 43 56 log.d('ProfileRepository: Loading ${actors.length} profiles via ${_describeClientContext()}'); 44 - final response = await _bluesky.actor.getProfiles(actors: actors); 45 - log.i('ProfileRepository: Loaded ${response.data.profiles.length} profiles'); 46 - return response.data.profiles; 57 + final response = await _bluesky.actor.getProfiles( 58 + actors: actors, 59 + $headers: await _moderationService?.headersForRequest(), 60 + ); 61 + final profiles = response.data.profiles 62 + .where((profile) => !(_moderationService?.shouldFilterProfileInList(profile) ?? false)) 63 + .toList(); 64 + log.i('ProfileRepository: Loaded ${profiles.length} profiles'); 65 + return profiles; 47 66 } 48 67 49 68 Future<ProfileViewDetailed?> getCurrentUserProfile(AuthTokens tokens) async { 50 69 log.d('ProfileRepository: Loading current user profile for ${tokens.did} via ${_describeClientContext()}'); 51 70 52 71 try { 53 - final response = await _bluesky.actor.getProfile(actor: tokens.did); 72 + final response = await _bluesky.actor.getProfile( 73 + actor: tokens.did, 74 + $headers: await _moderationService?.headersForRequest(), 75 + ); 54 76 log.i('ProfileRepository: Loaded current user profile ${response.data.did} (${response.data.handle})'); 55 77 return response.data; 56 78 } catch (error, stackTrace) {
+53 -7
lib/features/search/data/search_repository.dart
··· 2 2 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 3 import 'package:bluesky/app_bsky_feed_searchposts.dart'; 4 4 import 'package:bluesky/bluesky.dart'; 5 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 5 6 6 7 class SearchRepository { 7 - SearchRepository({required Bluesky bluesky}) : _bluesky = bluesky; 8 + SearchRepository({required Bluesky bluesky, ModerationService? moderationService}) 9 + : _bluesky = bluesky, 10 + _moderationService = moderationService; 8 11 9 12 final Bluesky _bluesky; 13 + final ModerationService? _moderationService; 10 14 11 15 Future<SearchPostsResult> searchPosts({ 12 16 required String query, ··· 18 22 ? const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.latest) 19 23 : const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.top); 20 24 21 - final response = await _bluesky.feed.searchPosts(q: query, sort: sortValue, cursor: cursor, limit: limit); 25 + final response = await _bluesky.feed.searchPosts( 26 + q: query, 27 + sort: sortValue, 28 + cursor: cursor, 29 + limit: limit, 30 + $headers: await _moderationService?.headersForRequest(), 31 + ); 22 32 23 33 return SearchPostsResult( 24 - posts: response.data.posts, 34 + posts: _filterPosts(response.data.posts), 25 35 cursor: response.data.cursor, 26 36 hitsTotal: response.data.hitsTotal, 27 37 ); 28 38 } 29 39 30 40 Future<SearchActorsResult> searchActors({required String query, String? cursor, int limit = 50}) async { 31 - final response = await _bluesky.actor.searchActors(q: query, cursor: cursor, limit: limit); 41 + final response = await _bluesky.actor.searchActors( 42 + q: query, 43 + cursor: cursor, 44 + limit: limit, 45 + $headers: await _moderationService?.headersForRequest(), 46 + ); 32 47 33 - return SearchActorsResult(actors: response.data.actors, cursor: response.data.cursor); 48 + return SearchActorsResult(actors: _filterProfiles(response.data.actors), cursor: response.data.cursor); 34 49 } 35 50 36 51 Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 10}) async { 37 - final response = await _bluesky.actor.searchActorsTypeahead(q: query, limit: limit); 52 + final response = await _bluesky.actor.searchActorsTypeahead( 53 + q: query, 54 + limit: limit, 55 + $headers: await _moderationService?.headersForRequest(), 56 + ); 38 57 39 - return response.data.actors; 58 + return _filterBasicProfiles(response.data.actors); 59 + } 60 + 61 + List<PostView> _filterPosts(List<PostView> posts) { 62 + final moderationService = _moderationService; 63 + if (moderationService == null) { 64 + return posts; 65 + } 66 + 67 + return posts.where((post) => !moderationService.shouldFilterPostInList(post)).toList(); 68 + } 69 + 70 + List<ProfileView> _filterProfiles(List<ProfileView> profiles) { 71 + final moderationService = _moderationService; 72 + if (moderationService == null) { 73 + return profiles; 74 + } 75 + 76 + return profiles.where((profile) => !moderationService.shouldFilterProfileInList(profile)).toList(); 77 + } 78 + 79 + List<ProfileViewBasic> _filterBasicProfiles(List<ProfileViewBasic> profiles) { 80 + final moderationService = _moderationService; 81 + if (moderationService == null) { 82 + return profiles; 83 + } 84 + 85 + return profiles.where((profile) => !moderationService.shouldFilterProfileBasicInList(profile)).toList(); 40 86 } 41 87 } 42 88
+81 -42
lib/main.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:bluesky/bluesky.dart'; 4 + import 'package:bluesky/bluesky_chat.dart'; 2 5 import 'package:flutter/material.dart'; 3 6 import 'package:flutter_bloc/flutter_bloc.dart'; 4 7 import 'package:go_router/go_router.dart'; 5 8 import 'package:lazurite/core/database/app_database.dart'; 6 9 import 'package:lazurite/core/logging/app_logger.dart'; 7 - import 'package:lazurite/core/scheduler/post_scheduler.dart'; 8 10 import 'package:lazurite/core/logging/logging_bloc_observer.dart'; 9 11 import 'package:lazurite/core/logging/logging_navigator_observer.dart'; 10 - import 'package:bluesky/bluesky_chat.dart'; 11 12 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 12 13 import 'package:lazurite/core/router/app_router.dart'; 13 - import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 14 - import 'package:lazurite/features/messages/data/convo_repository.dart'; 14 + import 'package:lazurite/core/scheduler/post_scheduler.dart'; 15 15 import 'package:lazurite/core/theme/app_theme.dart'; 16 16 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 17 17 import 'package:lazurite/features/auth/data/auth_repository.dart'; 18 18 import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 19 19 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 20 20 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 21 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 22 + import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 21 23 import 'package:lazurite/features/feed/data/feed_repository.dart'; 24 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 25 + import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 26 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 27 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 28 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 29 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 22 30 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 31 + import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 23 32 import 'package:lazurite/features/profile/data/profile_repository.dart'; 24 33 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 25 34 import 'package:lazurite/features/search/data/search_repository.dart'; 26 35 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 27 - import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 28 - import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 29 - import 'package:lazurite/features/feed/data/post_action_repository.dart'; 30 - import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 31 36 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 32 37 33 38 Future<void> main() async { ··· 127 132 return appShell; 128 133 } 129 134 130 - final feedRepository = FeedRepository(bluesky: bluesky); 131 - final searchRepository = SearchRepository(bluesky: bluesky); 132 - final postActionRepository = PostActionRepository(bluesky: bluesky); 133 - final profileActionRepository = ProfileActionRepository(bluesky: bluesky); 134 - final convoRepository = ConvoRepository(chat: blueskyChat); 135 135 final accountDid = authState.tokens?.did ?? ''; 136 136 137 - return MultiBlocProvider( 137 + return MultiRepositoryProvider( 138 138 providers: [ 139 - BlocProvider( 140 - create: (_) => ProfileBloc( 141 - profileRepository: ProfileRepository(database: widget.database, bluesky: bluesky), 142 - ), 139 + RepositoryProvider( 140 + create: (_) { 141 + final moderationService = ModerationService( 142 + bluesky: bluesky, 143 + database: widget.database, 144 + accountDid: accountDid, 145 + userDid: accountDid, 146 + ); 147 + unawaited(moderationService.ensureInitialized()); 148 + return moderationService; 149 + }, 150 + dispose: (moderationService) => moderationService.dispose(), 143 151 ), 144 - BlocProvider(create: (_) => FeedBloc(feedRepository: feedRepository)), 145 - BlocProvider( 146 - create: (_) => FeedPreferencesCubit( 147 - feedRepository: feedRepository, 148 - database: widget.database, 149 - accountDid: accountDid, 150 - )..loadPreferences(), 152 + RepositoryProvider( 153 + create: (context) => 154 + FeedRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 151 155 ), 152 - BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), 153 - BlocProvider( 154 - create: (_) => 155 - SearchBloc(searchRepository: searchRepository, database: widget.database, accountDid: accountDid), 156 + RepositoryProvider( 157 + create: (context) => 158 + SearchRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 156 159 ), 157 - BlocProvider( 158 - create: (_) => ConvoListBloc(convoRepository: convoRepository)..add(const ConvosRequested(limit: 100)), 159 - ), 160 - BlocProvider( 161 - create: (_) => SavedPostsCubit( 160 + RepositoryProvider( 161 + create: (context) => ProfileRepository( 162 162 database: widget.database, 163 - accountDid: accountDid, 164 - postActionRepository: postActionRepository, 163 + bluesky: bluesky, 164 + moderationService: context.read<ModerationService>(), 165 165 ), 166 166 ), 167 - RepositoryProvider.value(value: feedRepository), 168 - RepositoryProvider.value(value: searchRepository), 169 - RepositoryProvider.value(value: postActionRepository), 167 + RepositoryProvider( 168 + create: (context) => 169 + NotificationRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 170 + ), 171 + RepositoryProvider( 172 + create: (context) => 173 + PostThreadRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 174 + ), 175 + RepositoryProvider(create: (_) => PostActionRepository(bluesky: bluesky)), 176 + RepositoryProvider(create: (_) => ProfileActionRepository(bluesky: bluesky)), 177 + RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), 170 178 RepositoryProvider(create: (_) => PostActionCache()), 171 - RepositoryProvider.value(value: profileActionRepository), 172 179 RepositoryProvider.value(value: bluesky), 173 - RepositoryProvider.value(value: convoRepository), 174 180 RepositoryProvider.value(value: widget.database), 175 181 RepositoryProvider.value(value: accountDid), 176 182 ], 177 - child: appShell, 183 + child: MultiBlocProvider( 184 + providers: [ 185 + BlocProvider(create: (context) => ProfileBloc(profileRepository: context.read<ProfileRepository>())), 186 + BlocProvider(create: (context) => FeedBloc(feedRepository: context.read<FeedRepository>())), 187 + BlocProvider( 188 + create: (context) => FeedPreferencesCubit( 189 + feedRepository: context.read<FeedRepository>(), 190 + database: widget.database, 191 + accountDid: accountDid, 192 + )..loadPreferences(), 193 + ), 194 + BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), 195 + BlocProvider( 196 + create: (context) => SearchBloc( 197 + searchRepository: context.read<SearchRepository>(), 198 + database: widget.database, 199 + accountDid: accountDid, 200 + ), 201 + ), 202 + BlocProvider( 203 + create: (context) => 204 + ConvoListBloc(convoRepository: context.read<ConvoRepository>()) 205 + ..add(const ConvosRequested(limit: 100)), 206 + ), 207 + BlocProvider( 208 + create: (context) => SavedPostsCubit( 209 + database: widget.database, 210 + accountDid: accountDid, 211 + postActionRepository: context.read<PostActionRepository>(), 212 + ), 213 + ), 214 + ], 215 + child: appShell, 216 + ), 178 217 ); 179 218 }, 180 219 ),
+259 -77
test/features/moderation/data/moderation_service_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto/com_atproto_label_defs.dart'; 1 4 import 'package:atproto_core/atproto_core.dart'; 2 5 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 6 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 - import 'package:bluesky/bluesky.dart'; 5 - import 'package:bluesky/moderation.dart' as bsky_moderation; 7 + import 'package:bluesky/app_bsky_labeler_getservices.dart'; 8 + import 'package:drift/native.dart'; 6 9 import 'package:flutter_test/flutter_test.dart'; 10 + import 'package:lazurite/core/database/app_database.dart'; 7 11 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 8 12 9 - /// Tests for ModerationService. 10 - /// 11 - /// Note: Since Bluesky, ActorService, and LabelerService are sealed/base 12 - /// classes, we cannot mock them with mocktail. Instead, we test the 13 - /// ModerationService's behavior through its public API, focusing on: 14 - /// - Default behavior before initialization (null opts) 15 - /// - State management (currentOpts, optsStream) 16 - /// - Moderation methods returning correct types 13 + const _customLabelerDid = 'did:plc:custom-labeler'; 14 + const _accountDid = 'did:plc:test-user'; 15 + 17 16 void main() { 18 - late ModerationService moderationService; 17 + late AppDatabase database; 19 18 20 - setUp(() { 21 - moderationService = ModerationService(bluesky: Bluesky.anonymous()); 19 + setUp(() async { 20 + database = AppDatabase(executor: NativeDatabase.memory()); 22 21 }); 23 22 24 - tearDown(() { 25 - moderationService.dispose(); 23 + tearDown(() async { 24 + await database.close(); 26 25 }); 27 26 28 27 group('ModerationService', () { 29 - group('initial state', () { 30 - test('currentOpts is null before initialize', () { 31 - expect(moderationService.currentOpts, isNull); 32 - }); 28 + test('initializes moderation opts, userDid, and accepted labeler headers', () async { 29 + final service = ModerationService( 30 + bluesky: _FakeBlueskyClient( 31 + actor: _FakeActorService( 32 + preferences: [ 33 + const UPreferences.adultContentPref(data: AdultContentPref(enabled: false)), 34 + const UPreferences.labelersPref( 35 + data: LabelersPref(labelers: [LabelerPrefItem(did: _customLabelerDid)]), 36 + ), 37 + ], 38 + ), 39 + labeler: const _FakeLabelerService(), 40 + ), 41 + database: database, 42 + accountDid: _accountDid, 43 + userDid: _accountDid, 44 + ); 33 45 34 - test('optsStream is a broadcast stream', () { 35 - expect(moderationService.optsStream.isBroadcast, isTrue); 36 - }); 46 + await service.ensureInitialized(); 47 + 48 + expect(service.currentOpts, isNotNull); 49 + expect(service.currentOpts!.userDid, _accountDid); 50 + expect(service.currentHeaders['atproto-accept-labelers'], contains(_customLabelerDid)); 51 + expect(service.currentPrefs?.labelers.map((labeler) => labeler.did), contains(_customLabelerDid)); 52 + 53 + service.dispose(); 37 54 }); 38 55 39 - group('moderatePost', () { 40 - test('returns ModerationDecision when opts are null', () { 41 - final result = moderationService.moderatePost(_makeSamplePostView()); 42 - expect(result, isA<bsky_moderation.ModerationDecision>()); 43 - }); 56 + test('filters labeled posts in list contexts', () async { 57 + final service = ModerationService( 58 + bluesky: _FakeBlueskyClient( 59 + actor: _FakeActorService( 60 + preferences: [const UPreferences.adultContentPref(data: AdultContentPref(enabled: false))], 61 + ), 62 + labeler: const _FakeLabelerService(), 63 + ), 64 + database: database, 65 + accountDid: _accountDid, 66 + userDid: _accountDid, 67 + ); 68 + 69 + await service.ensureInitialized(); 70 + 71 + final labeledPost = PostView( 72 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), 73 + cid: 'cid-123', 74 + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 75 + record: { 76 + r'$type': 'app.bsky.feed.post', 77 + 'text': 'sensitive', 78 + 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String(), 79 + }, 80 + indexedAt: DateTime.utc(2026, 3, 15), 81 + labels: [ 82 + Label( 83 + src: 'did:plc:ar7c4by46qjdydhdevvrndac', 84 + uri: 'at://did:plc:author/app.bsky.feed.post/abc', 85 + val: 'porn', 86 + cts: DateTime.utc(2026, 3, 15), 87 + ), 88 + ], 89 + ); 90 + 91 + expect(service.shouldFilterPostInList(labeledPost), isTrue); 92 + 93 + service.dispose(); 44 94 }); 45 95 46 - group('moderateProfile', () { 47 - test('returns ModerationDecision with ProfileView when opts are null', () { 48 - final result = moderationService.moderateProfile( 49 - const ProfileView(did: 'did:plc:user', handle: 'user.bsky.social'), 50 - ); 51 - expect(result, isA<bsky_moderation.ModerationDecision>()); 52 - }); 96 + test('falls back to cached preferences after a request failure', () async { 97 + final seededService = ModerationService( 98 + bluesky: _FakeBlueskyClient( 99 + actor: _FakeActorService( 100 + preferences: [ 101 + const UPreferences.labelersPref( 102 + data: LabelersPref(labelers: [LabelerPrefItem(did: _customLabelerDid)]), 103 + ), 104 + ], 105 + ), 106 + labeler: const _FakeLabelerService(), 107 + ), 108 + database: database, 109 + accountDid: _accountDid, 110 + userDid: _accountDid, 111 + ); 112 + await seededService.ensureInitialized(); 113 + seededService.dispose(); 114 + 115 + final fallbackService = ModerationService( 116 + bluesky: _FakeBlueskyClient( 117 + actor: _FakeActorService(error: Exception('offline')), 118 + labeler: _FakeLabelerService(error: Exception('offline')), 119 + ), 120 + database: database, 121 + accountDid: _accountDid, 122 + userDid: _accountDid, 123 + ); 124 + 125 + await fallbackService.ensureInitialized(); 126 + 127 + expect(fallbackService.currentPrefs, isNotNull); 128 + expect(fallbackService.currentHeaders['atproto-accept-labelers'], contains(_customLabelerDid)); 129 + 130 + fallbackService.dispose(); 53 131 }); 54 132 55 - group('moderateProfileBasic', () { 56 - test('returns ModerationDecision with ProfileViewBasic when opts are null', () { 57 - final result = moderationService.moderateProfileBasic( 58 - const ProfileViewBasic(did: 'did:plc:user', handle: 'user.bsky.social'), 59 - ); 60 - expect(result, isA<bsky_moderation.ModerationDecision>()); 61 - }); 133 + test('subscribeToLabeler writes updated preferences and refreshes headers', () async { 134 + final actor = _FakeActorService(preferences: const []); 135 + final service = ModerationService( 136 + bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), 137 + database: database, 138 + accountDid: _accountDid, 139 + userDid: _accountDid, 140 + ); 141 + 142 + await service.ensureInitialized(); 143 + await service.subscribeToLabeler(_customLabelerDid); 144 + 145 + expect(actor.lastPutPreferences, isNotNull); 146 + expect( 147 + actor.lastPutPreferences! 148 + .where((preference) => preference.isLabelersPref) 149 + .single 150 + .labelersPref! 151 + .labelers 152 + .map((labeler) => labeler.did), 153 + contains(_customLabelerDid), 154 + ); 155 + expect(service.currentHeaders['atproto-accept-labelers'], contains(_customLabelerDid)); 156 + 157 + service.dispose(); 62 158 }); 63 159 64 - group('moderateProfileDetailed', () { 65 - test('returns ModerationDecision with ProfileViewDetailed when opts are null', () { 66 - const profile = ProfileViewDetailed(did: 'did:plc:user', handle: 'user.bsky.social'); 67 - final result = moderationService.moderateProfileDetailed(profile); 68 - expect(result, isA<bsky_moderation.ModerationDecision>()); 69 - }); 160 + test('setLabelPreference stores contentLabelPref entries', () async { 161 + final actor = _FakeActorService(preferences: const []); 162 + final service = ModerationService( 163 + bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), 164 + database: database, 165 + accountDid: _accountDid, 166 + userDid: _accountDid, 167 + ); 168 + 169 + await service.ensureInitialized(); 170 + await service.setLabelPreference( 171 + label: 'porn', 172 + visibility: KnownContentLabelPrefVisibility.hide, 173 + labelerDid: _customLabelerDid, 174 + ); 175 + 176 + final contentPref = actor.lastPutPreferences! 177 + .where((preference) => preference.isContentLabelPref) 178 + .single 179 + .contentLabelPref!; 180 + 181 + expect(contentPref.label, 'porn'); 182 + expect(contentPref.labelerDid, _customLabelerDid); 183 + expect(contentPref.visibility.toJson(), 'hide'); 184 + 185 + service.dispose(); 70 186 }); 71 187 72 - group('dispose', () { 73 - test('can be called without error', () { 74 - expect(() => moderationService.dispose(), returnsNormally); 75 - }); 188 + test('dispose is idempotent', () { 189 + final service = ModerationService(bluesky: _FakeBlueskyClient()); 76 190 77 - test('closes the opts stream', () async { 78 - moderationService.dispose(); 79 - final isDone = await moderationService.optsStream.isEmpty; 80 - expect(isDone, isTrue); 81 - }); 191 + expect(() => service.dispose(), returnsNormally); 192 + expect(() => service.dispose(), returnsNormally); 82 193 }); 83 194 84 - group('ModerationOpts', () { 85 - test('can be constructed with prefs and empty labelDefs', () { 86 - const prefs = bsky_moderation.ModerationPrefs( 87 - adultContentEnabled: false, 88 - labels: {}, 89 - labelers: [], 90 - mutedWords: [], 91 - hiddenPosts: [], 92 - ); 93 - const opts = bsky_moderation.ModerationOpts(prefs: prefs); 94 - expect(opts.prefs.adultContentEnabled, isFalse); 95 - expect(opts.labelDefs, isEmpty); 96 - }); 195 + test('caches moderation preferences in the settings table', () async { 196 + final service = ModerationService( 197 + bluesky: _FakeBlueskyClient( 198 + actor: _FakeActorService( 199 + preferences: [ 200 + const UPreferences.labelersPref( 201 + data: LabelersPref(labelers: [LabelerPrefItem(did: _customLabelerDid)]), 202 + ), 203 + ], 204 + ), 205 + labeler: const _FakeLabelerService(), 206 + ), 207 + database: database, 208 + accountDid: _accountDid, 209 + ); 210 + 211 + await service.ensureInitialized(); 212 + 213 + final cachedPayload = await database.getSetting('moderation_preferences::$_accountDid'); 214 + expect(cachedPayload, isNotNull); 215 + 216 + final decoded = jsonDecode(cachedPayload!) as List<dynamic>; 217 + expect(decoded, isNotEmpty); 218 + 219 + service.dispose(); 97 220 }); 98 221 }); 99 222 } 100 223 101 - PostView _makeSamplePostView() { 102 - return PostView( 103 - uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), 104 - cid: 'cid-123', 105 - author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 106 - record: const {r'$type': 'app.bsky.feed.post', 'text': 'Hello world'}, 107 - indexedAt: DateTime.utc(2026, 3, 15), 108 - ); 224 + class _FakeBlueskyClient { 225 + _FakeBlueskyClient({_FakeActorService? actor, _FakeLabelerService? labeler}) 226 + : actor = actor ?? _FakeActorService(), 227 + labeler = labeler ?? const _FakeLabelerService(); 228 + 229 + final _FakeActorService actor; 230 + final _FakeLabelerService labeler; 231 + } 232 + 233 + class _FakeActorService { 234 + _FakeActorService({this.preferences = const [], this.error}); 235 + 236 + final List<UPreferences> preferences; 237 + final Object? error; 238 + List<UPreferences>? lastPutPreferences; 239 + 240 + Future<_FakePreferencesResponse> getPreferences() async { 241 + if (error != null) { 242 + throw error!; 243 + } 244 + return _FakePreferencesResponse(_FakePreferencesData(preferences)); 245 + } 246 + 247 + Future<void> putPreferences({required List<UPreferences> preferences}) async { 248 + lastPutPreferences = preferences; 249 + } 250 + } 251 + 252 + class _FakeLabelerService { 253 + const _FakeLabelerService({this.error}); 254 + 255 + final Object? error; 256 + 257 + Future<_FakeGetServicesResponse> getServices({ 258 + required List<String> dids, 259 + bool? detailed, 260 + Map<String, String>? $headers, 261 + }) async { 262 + if (error != null) { 263 + throw error!; 264 + } 265 + return const _FakeGetServicesResponse(_FakeGetServicesData([])); 266 + } 267 + } 268 + 269 + class _FakePreferencesResponse { 270 + const _FakePreferencesResponse(this.data); 271 + 272 + final _FakePreferencesData data; 273 + } 274 + 275 + class _FakePreferencesData { 276 + const _FakePreferencesData(this.preferences); 277 + 278 + final List<UPreferences> preferences; 279 + } 280 + 281 + class _FakeGetServicesResponse { 282 + const _FakeGetServicesResponse(this.data); 283 + 284 + final _FakeGetServicesData data; 285 + } 286 + 287 + class _FakeGetServicesData { 288 + const _FakeGetServicesData(this.views); 289 + 290 + final List<ULabelerGetServicesViews> views; 109 291 }
+2 -2
test/features/profile/data/profile_repository_test.dart
··· 79 79 80 80 final Future<_FakeResponse<ProfileViewDetailed>> Function(String actor) onGetProfile; 81 81 82 - Future<_FakeResponse<ProfileViewDetailed>> getProfile({required String actor}) { 82 + Future<_FakeResponse<ProfileViewDetailed>> getProfile({required String actor, Map<String, String>? $headers}) { 83 83 return onGetProfile(actor); 84 84 } 85 85 86 - Future<_FakeProfilesResponse> getProfiles({required List<String> actors}) async { 86 + Future<_FakeProfilesResponse> getProfiles({required List<String> actors, Map<String, String>? $headers}) async { 87 87 return _FakeProfilesResponse(const _FakeProfilesData([])); 88 88 } 89 89 }