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.

fix: proper moderation badge resolution

+293 -12
+103
lib/features/moderation/data/moderation_service.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:convert'; 3 3 4 + import 'package:atproto/com_atproto_label_defs.dart'; 4 5 import 'package:bluesky/app_bsky_actor_defs.dart'; 5 6 import 'package:bluesky/app_bsky_actor_getpreferences.dart'; 6 7 import 'package:bluesky/app_bsky_feed_defs.dart'; ··· 47 48 Future<void>? _initializationFuture; 48 49 bool _disposed = false; 49 50 final _optsController = StreamController<bsky_moderation.ModerationOpts>.broadcast(); 51 + final Map<String, LabelerPolicies> _labelerPoliciesByDid = {}; 50 52 51 53 Stream<bsky_moderation.ModerationOpts> get optsStream => _optsController.stream; 52 54 bsky_moderation.ModerationOpts? get currentOpts => _opts; ··· 258 260 bsky_moderation.ModerationBehaviorContext context, 259 261 ) => moderateNotification(notification).getUI(context); 260 262 263 + String? resolveLabelDisplayName({ 264 + required String identifier, 265 + String? labelerDid, 266 + Iterable<String> preferredLanguages = const [], 267 + }) { 268 + if (identifier.isEmpty) { 269 + return null; 270 + } 271 + 272 + final requestedDid = (labelerDid == null || labelerDid.isEmpty) ? null : labelerDid; 273 + final candidateDids = <String>{_officialBlueskyLabelerDid}; 274 + if (requestedDid != null) { 275 + candidateDids.add(requestedDid); 276 + } 277 + 278 + for (final did in candidateDids) { 279 + final definition = _labelValueDefinitionForIdentifier(_labelerPoliciesByDid[did], identifier); 280 + if (definition == null) { 281 + continue; 282 + } 283 + 284 + final localized = _localizedLabelName(definition.locales, preferredLanguages); 285 + if (localized != null && localized.isNotEmpty) { 286 + return localized; 287 + } 288 + } 289 + 290 + if (requestedDid != null) { 291 + return null; 292 + } 293 + 294 + for (final policies in _labelerPoliciesByDid.values) { 295 + final definition = _labelValueDefinitionForIdentifier(policies, identifier); 296 + if (definition == null) { 297 + continue; 298 + } 299 + final localized = _localizedLabelName(definition.locales, preferredLanguages); 300 + if (localized != null && localized.isNotEmpty) { 301 + return localized; 302 + } 303 + } 304 + 305 + return null; 306 + } 307 + 261 308 bool shouldFilterFeedViewPostInList(FeedViewPost post) => shouldFilterPostInList(post.post); 262 309 263 310 bool shouldFilterPostInList(PostView post) => ··· 385 432 386 433 Future<void> _cacheLabelerPolicies(List<ULabelerGetServicesViews> views) async { 387 434 if (_database == null) { 435 + for (final view in views) { 436 + if (!view.isLabelerViewDetailed) { 437 + continue; 438 + } 439 + final detailed = view.labelerViewDetailed!; 440 + _labelerPoliciesByDid[detailed.creator.did] = detailed.policies; 441 + } 388 442 return; 389 443 } 390 444 ··· 394 448 } 395 449 396 450 final detailed = view.labelerViewDetailed!; 451 + _labelerPoliciesByDid[detailed.creator.did] = detailed.policies; 397 452 await _database.upsertLabelerCache(detailed.creator.did, jsonEncode(detailed.policies.toJson())); 398 453 } 399 454 } ··· 413 468 } 414 469 415 470 final policies = LabelerPolicies.fromJson(jsonDecode(cached.policiesJson) as Map<String, dynamic>); 471 + _labelerPoliciesByDid[did] = policies; 416 472 definitions[did] = _interpretedLabelDefinitionsFromPolicies(policies, labelerDid: did); 417 473 } 418 474 ··· 570 626 final bluesky = _bluesky; 571 627 if (bluesky is Bluesky) { 572 628 return bluesky.oAuthSession?.sub ?? bluesky.session?.did; 629 + } 630 + 631 + return null; 632 + } 633 + 634 + LabelValueDefinition? _labelValueDefinitionForIdentifier(LabelerPolicies? policies, String identifier) { 635 + if (policies == null) { 636 + return null; 637 + } 638 + for (final definition in policies.labelValueDefinitions ?? const <LabelValueDefinition>[]) { 639 + if (definition.identifier == identifier) { 640 + return definition; 641 + } 642 + } 643 + return null; 644 + } 645 + 646 + String? _localizedLabelName(List<LabelValueDefinitionStrings> locales, Iterable<String> preferredLanguages) { 647 + if (locales.isEmpty) { 648 + return null; 649 + } 650 + 651 + final normalizedLanguages = preferredLanguages 652 + .map((language) => language.trim().toLowerCase()) 653 + .where((language) => language.isNotEmpty) 654 + .toList(growable: false); 655 + 656 + for (final language in normalizedLanguages) { 657 + for (final entry in locales) { 658 + if (entry.lang.toLowerCase() == language) { 659 + return entry.name; 660 + } 661 + } 662 + 663 + final baseLanguage = language.split(RegExp(r'[-_]')).first; 664 + for (final entry in locales) { 665 + final lang = entry.lang.toLowerCase(); 666 + if (lang == baseLanguage || lang.startsWith('$baseLanguage-')) { 667 + return entry.name; 668 + } 669 + } 670 + } 671 + 672 + for (final entry in locales) { 673 + if (entry.name.isNotEmpty) { 674 + return entry.name; 675 + } 573 676 } 574 677 575 678 return null;
+27 -7
lib/features/moderation/presentation/moderation_ui_helpers.dart
··· 9 9 10 10 enum ModerationBadgeTone { alert, inform } 11 11 12 + typedef ModerationLabelResolver = String? Function({required String identifier, String? labelerDid}); 13 + 12 14 class ModerationBadgeDescriptor { 13 15 const ModerationBadgeDescriptor({required this.label, required this.description, required this.tone}); 14 16 ··· 69 71 return locales.first.description; 70 72 } 71 73 72 - List<ModerationBadgeDescriptor> moderationBadgesForUi(bsky_moderation.ModerationUI ui) { 74 + List<ModerationBadgeDescriptor> moderationBadgesForUi( 75 + bsky_moderation.ModerationUI ui, { 76 + ModerationLabelResolver? labelResolver, 77 + }) { 73 78 final badges = <ModerationBadgeDescriptor>[]; 74 79 final seen = <String>{}; 75 80 76 81 void addDescriptors(List<bsky_moderation.ModerationCause> causes, ModerationBadgeTone tone) { 77 82 for (final cause in causes) { 78 - final descriptor = moderationDescriptorForCause(cause, tone: tone); 83 + final descriptor = moderationDescriptorForCause(cause, tone: tone, labelResolver: labelResolver); 79 84 final key = '${tone.name}:${descriptor.label}:${descriptor.description}'; 80 85 if (seen.add(key)) { 81 86 badges.add(descriptor); ··· 88 93 return badges; 89 94 } 90 95 91 - List<String> moderationBlurLabels(bsky_moderation.ModerationUI ui) { 96 + List<String> moderationBlurLabels(bsky_moderation.ModerationUI ui, {ModerationLabelResolver? labelResolver}) { 92 97 final labels = <String>[]; 93 98 final seen = <String>{}; 94 99 95 100 for (final cause in ui.blurs) { 96 - final descriptor = moderationDescriptorForCause(cause, tone: ModerationBadgeTone.alert); 101 + final descriptor = moderationDescriptorForCause( 102 + cause, 103 + tone: ModerationBadgeTone.alert, 104 + labelResolver: labelResolver, 105 + ); 97 106 if (seen.add(descriptor.label)) { 98 107 labels.add(descriptor.label); 99 108 } ··· 105 114 ModerationBadgeDescriptor moderationDescriptorForCause( 106 115 bsky_moderation.ModerationCause cause, { 107 116 required ModerationBadgeTone tone, 117 + ModerationLabelResolver? labelResolver, 108 118 }) { 109 119 return cause.maybeWhen( 110 120 label: (data) { 111 - final label = humanizeModerationLabel(data.labelDef.identifier); 121 + final resolvedLabel = labelResolver?.call( 122 + identifier: data.labelDef.identifier, 123 + labelerDid: data.label.src.isEmpty ? null : data.label.src, 124 + ); 125 + final label = (resolvedLabel == null || resolvedLabel.isEmpty) 126 + ? humanizeModerationLabel(data.labelDef.identifier) 127 + : resolvedLabel; 112 128 final source = data.labelDef.definedBy == officialBlueskyLabelerDid ? 'Bluesky' : 'Subscribed labeler'; 113 129 return ModerationBadgeDescriptor(label: label, description: '$source label', tone: tone); 114 130 }, ··· 144 160 ); 145 161 } 146 162 147 - String moderationOverlayTitle(bsky_moderation.ModerationUI ui, {String fallback = 'Sensitive content'}) { 148 - final labels = moderationBlurLabels(ui); 163 + String moderationOverlayTitle( 164 + bsky_moderation.ModerationUI ui, { 165 + String fallback = 'Sensitive content', 166 + ModerationLabelResolver? labelResolver, 167 + }) { 168 + final labels = moderationBlurLabels(ui, labelResolver: labelResolver); 149 169 if (labels.isEmpty) { 150 170 return fallback; 151 171 }
+18 -1
lib/features/moderation/presentation/widgets/moderated_blur_overlay.dart
··· 13 13 this.borderRadius, 14 14 this.fallbackLabel = 'Sensitive content', 15 15 this.fillWidth = true, 16 + this.labelResolver, 16 17 }); 17 18 18 19 final bsky_moderation.ModerationUI ui; ··· 20 21 final BorderRadius? borderRadius; 21 22 final String fallbackLabel; 22 23 final bool fillWidth; 24 + final ModerationLabelResolver? labelResolver; 23 25 24 26 @override 25 27 State<ModeratedBlurOverlay> createState() => _ModeratedBlurOverlayState(); ··· 36 38 37 39 final colorScheme = context.colorScheme; 38 40 final canReveal = !widget.ui.noOverride; 41 + final moderationService = maybeModerationService(context); 42 + final locale = Localizations.localeOf(context); 43 + final effectiveResolver = 44 + widget.labelResolver ?? 45 + (moderationService == null 46 + ? null 47 + : ({required String identifier, String? labelerDid}) => moderationService.resolveLabelDisplayName( 48 + identifier: identifier, 49 + labelerDid: labelerDid, 50 + preferredLanguages: [locale.toLanguageTag(), locale.languageCode], 51 + )); 39 52 40 53 Widget content = Stack( 41 54 fit: StackFit.passthrough, ··· 64 77 Icon(Icons.visibility_off_outlined, color: colorScheme.onSurface, size: 24), 65 78 const SizedBox(height: 10), 66 79 Text( 67 - moderationOverlayTitle(widget.ui, fallback: widget.fallbackLabel), 80 + moderationOverlayTitle( 81 + widget.ui, 82 + fallback: widget.fallbackLabel, 83 + labelResolver: effectiveResolver, 84 + ), 68 85 textAlign: TextAlign.center, 69 86 style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), 70 87 ),
+15 -2
lib/features/moderation/presentation/widgets/moderation_badge_row.dart
··· 4 4 import 'package:lazurite/core/theme/theme_extensions.dart'; 5 5 6 6 class ModerationBadgeRow extends StatelessWidget { 7 - const ModerationBadgeRow({super.key, required this.ui, this.padding = EdgeInsets.zero}); 7 + const ModerationBadgeRow({super.key, required this.ui, this.padding = EdgeInsets.zero, this.labelResolver}); 8 8 9 9 final bsky_moderation.ModerationUI ui; 10 10 final EdgeInsetsGeometry padding; 11 + final ModerationLabelResolver? labelResolver; 11 12 12 13 @override 13 14 Widget build(BuildContext context) { 14 - final badges = moderationBadgesForUi(ui); 15 + final moderationService = maybeModerationService(context); 16 + final locale = Localizations.localeOf(context); 17 + final effectiveResolver = 18 + labelResolver ?? 19 + (moderationService == null 20 + ? null 21 + : ({required String identifier, String? labelerDid}) => moderationService.resolveLabelDisplayName( 22 + identifier: identifier, 23 + labelerDid: labelerDid, 24 + preferredLanguages: [locale.toLanguageTag(), locale.languageCode], 25 + )); 26 + 27 + final badges = moderationBadgesForUi(ui, labelResolver: effectiveResolver); 15 28 if (badges.isEmpty) { 16 29 return const SizedBox.shrink(); 17 30 }
+85 -2
test/features/moderation/data/moderation_service_test.dart
··· 4 4 import 'package:atproto_core/atproto_core.dart'; 5 5 import 'package:bluesky/app_bsky_actor_defs.dart'; 6 6 import 'package:bluesky/app_bsky_feed_defs.dart'; 7 + import 'package:bluesky/app_bsky_labeler_defs.dart'; 7 8 import 'package:bluesky/app_bsky_labeler_getservices.dart'; 8 9 import 'package:drift/native.dart'; 9 10 import 'package:flutter_test/flutter_test.dart'; ··· 269 270 270 271 service.dispose(); 271 272 }); 273 + 274 + test('resolves localized custom label names from labeler policies', () async { 275 + final service = ModerationService( 276 + bluesky: _FakeBlueskyClient( 277 + actor: _FakeActorService( 278 + preferences: [ 279 + const UPreferences.labelersPref( 280 + data: LabelersPref(labelers: [LabelerPrefItem(did: _customLabelerDid)]), 281 + ), 282 + ], 283 + ), 284 + labeler: _FakeLabelerService( 285 + views: [ 286 + ULabelerGetServicesViews.labelerViewDetailed( 287 + data: _buildLabeler( 288 + did: _customLabelerDid, 289 + handle: 'labels.example', 290 + displayName: 'Custom Labels', 291 + description: 'Community labels.', 292 + definitionIdentifier: 'aaa', 293 + definitionName: '🅰️', 294 + ), 295 + ), 296 + ], 297 + ), 298 + ), 299 + database: database, 300 + accountDid: _accountDid, 301 + userDid: _accountDid, 302 + ); 303 + 304 + await service.ensureInitialized(); 305 + 306 + expect( 307 + service.resolveLabelDisplayName( 308 + identifier: 'aaa', 309 + labelerDid: _customLabelerDid, 310 + preferredLanguages: const ['en-US', 'en'], 311 + ), 312 + '🅰️', 313 + ); 314 + 315 + service.dispose(); 316 + }); 272 317 }); 273 318 } 274 319 ··· 329 374 } 330 375 331 376 class _FakeLabelerService { 332 - const _FakeLabelerService({this.error}); 377 + const _FakeLabelerService({this.error, this.views = const []}); 333 378 334 379 final Object? error; 380 + final List<ULabelerGetServicesViews> views; 335 381 336 382 Future<_FakeGetServicesResponse> getServices({ 337 383 required List<String> dids, ··· 341 387 if (error != null) { 342 388 throw error!; 343 389 } 344 - return const _FakeGetServicesResponse(_FakeGetServicesData([])); 390 + return _FakeGetServicesResponse(_FakeGetServicesData(views)); 345 391 } 346 392 } 347 393 ··· 380 426 ), 381 427 ); 382 428 } 429 + 430 + LabelerViewDetailed _buildLabeler({ 431 + required String did, 432 + required String handle, 433 + required String displayName, 434 + required String description, 435 + required String definitionIdentifier, 436 + required String definitionName, 437 + }) { 438 + return LabelerViewDetailed( 439 + uri: AtUri.parse('at://$did/app.bsky.labeler.service/self'), 440 + cid: 'cid-$did', 441 + creator: ProfileView( 442 + did: did, 443 + handle: handle, 444 + displayName: displayName, 445 + description: description, 446 + avatar: 'https://example.com/$handle.png', 447 + ), 448 + policies: LabelerPolicies( 449 + labelValues: [LabelValue.unknown(data: definitionIdentifier)], 450 + labelValueDefinitions: [ 451 + LabelValueDefinition( 452 + identifier: definitionIdentifier, 453 + severity: const LabelValueDefinitionSeverity.knownValue(data: KnownLabelValueDefinitionSeverity.alert), 454 + blurs: const LabelValueDefinitionBlurs.knownValue(data: KnownLabelValueDefinitionBlurs.content), 455 + defaultSetting: const LabelValueDefinitionDefaultSetting.knownValue( 456 + data: KnownLabelValueDefinitionDefaultSetting.warn, 457 + ), 458 + adultOnly: false, 459 + locales: [LabelValueDefinitionStrings(lang: 'en', name: definitionName, description: 'Example description')], 460 + ), 461 + ], 462 + ), 463 + indexedAt: DateTime.utc(2026, 4, 30), 464 + ); 465 + }
+45
test/features/moderation/presentation/widgets/moderation_badge_row_test.dart
··· 1 + import 'package:atproto/com_atproto_label_defs.dart'; 1 2 import 'package:bluesky/moderation.dart' as bsky_moderation; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter_test/flutter_test.dart'; ··· 32 33 33 34 expect(errors.where((error) => error.exceptionAsString().contains('A RenderFlex overflowed')), isEmpty); 34 35 expect(find.text('Blocked relationship'), findsOneWidget); 36 + }); 37 + 38 + testWidgets('renders resolver-provided label text for custom moderation labels', (tester) async { 39 + final cause = bsky_moderation.ModerationCause.label( 40 + data: bsky_moderation.ModerationCauseLabel( 41 + source: const bsky_moderation.ModerationCauseSource.user(data: bsky_moderation.ModerationCauseSourceUser()), 42 + label: Label( 43 + src: 'did:plc:custom-labeler', 44 + uri: 'at://did:plc:author/app.bsky.feed.post/abc', 45 + val: 'aaa', 46 + cts: DateTime.utc(2026, 4, 30), 47 + ), 48 + labelDef: bsky_moderation.InterpretedLabelValueDefinition( 49 + identifier: 'aaa', 50 + severity: bsky_moderation.ModerationBehavior.inform.name, 51 + blurs: 'none', 52 + definedBy: 'did:plc:custom-labeler', 53 + ), 54 + target: bsky_moderation.LabelTarget.content, 55 + setting: bsky_moderation.LabelPreference.warn, 56 + behavior: const {}, 57 + ), 58 + ); 59 + 60 + final ui = bsky_moderation.ModerationUI(alerts: [cause]); 61 + 62 + await tester.pumpWidget( 63 + MaterialApp( 64 + home: Scaffold( 65 + body: ModerationBadgeRow( 66 + ui: ui, 67 + labelResolver: ({required String identifier, String? labelerDid}) { 68 + if (identifier == 'aaa' && labelerDid == 'did:plc:custom-labeler') { 69 + return '🅰️'; 70 + } 71 + return null; 72 + }, 73 + ), 74 + ), 75 + ), 76 + ); 77 + 78 + expect(find.text('🅰️'), findsOneWidget); 79 + expect(find.text('Aaa'), findsNothing); 35 80 }); 36 81 }