diff --git a/lib/src/core/moderation/moderation_definitions.dart b/lib/src/core/moderation/moderation_definitions.dart index 553621f4..63249c05 100644 --- a/lib/src/core/moderation/moderation_definitions.dart +++ b/lib/src/core/moderation/moderation_definitions.dart @@ -4,9 +4,24 @@ import 'package:poptart_lex/com/atproto/label/defs.dart'; import 'package:spark/src/core/moderation/moderation_models.dart'; final class ModerationLabelDefinitions { - ModerationLabelDefinitions({ + factory ModerationLabelDefinitions({ Iterable definitions = const [], bool includeBuiltIns = true, + }) { + final items = definitions.toList(growable: false); + return ModerationLabelDefinitions._( + definitions: items, + configuredLabelerDids: items + .map((definition) => definition.definedBy) + .nonNulls, + includeBuiltIns: includeBuiltIns, + ); + } + + ModerationLabelDefinitions._({ + required Iterable definitions, + required Iterable configuredLabelerDids, + required bool includeBuiltIns, }) { final all = [ if (includeBuiltIns) ...builtInLabelDefinitions, @@ -36,14 +51,16 @@ final class ModerationLabelDefinitions { definition.identifier: definition, }), }); + _configuredLabelerDids = Set.unmodifiable(configuredLabelerDids); } factory ModerationLabelDefinitions.fromLabelers( Map> definitionsByLabeler, { bool includeBuiltIns = true, }) { - return ModerationLabelDefinitions( + return ModerationLabelDefinitions._( includeBuiltIns: includeBuiltIns, + configuredLabelerDids: definitionsByLabeler.keys, definitions: [ for (final entry in definitionsByLabeler.entries) for (final definition in entry.value) @@ -54,11 +71,14 @@ final class ModerationLabelDefinitions { late final Map _global; late final Map> _bySource; + late final Set _configuredLabelerDids; Map get global => _global; Map> get bySource => _bySource; + bool isConfiguredSource(String did) => _configuredLabelerDids.contains(did); + ModerationLabelDefinition? lookup(Label label) { if (label.val.startsWith('!')) return _global[label.val]; return _bySource[label.src]?[label.val] ?? _global[label.val]; @@ -203,16 +223,22 @@ ModerationLabelDefinition _imperativeDefinition({ required ModerationSetting defaultSetting, required Set flags, }) { - final allBlurred = ModerationBehavior({ + final profileBlurred = ModerationBehavior({ ModerationContext.profileList: ModerationAction.blur, ModerationContext.profileView: ModerationAction.blur, ModerationContext.avatar: ModerationAction.blur, ModerationContext.banner: ModerationAction.blur, ModerationContext.displayName: ModerationAction.blur, + }); + final contentBlurred = ModerationBehavior({ ModerationContext.contentList: ModerationAction.blur, ModerationContext.contentView: ModerationAction.blur, ModerationContext.contentMedia: ModerationAction.blur, }); + final accountBlurred = ModerationBehavior({ + ...profileBlurred.actions, + ...contentBlurred.actions, + }); return ModerationLabelDefinition( identifier: identifier, severity: identifier == '!warn' @@ -224,9 +250,9 @@ ModerationLabelDefinition _imperativeDefinition({ flags: flags, locales: const [], behaviors: { - ModerationTarget.account: allBlurred, - ModerationTarget.profile: allBlurred, - ModerationTarget.content: allBlurred, + ModerationTarget.account: accountBlurred, + ModerationTarget.profile: profileBlurred, + ModerationTarget.content: contentBlurred, }, ); } diff --git a/lib/src/core/moderation/moderation_engine.dart b/lib/src/core/moderation/moderation_engine.dart index 2ff625d3..bf97e2de 100644 --- a/lib/src/core/moderation/moderation_engine.dart +++ b/lib/src/core/moderation/moderation_engine.dart @@ -34,6 +34,9 @@ final class ModerationEngine { if (!isLabelActiveAt(label, now: evaluationTime)) continue; final isSelfLabel = subjectDid != null && label.src == subjectDid; + if (!isSelfLabel && !definitions.isConfiguredSource(label.src)) { + continue; + } final definition = isSelfLabel ? definitions.lookupSelfLabel(label, labelerDid: selfLabelerDid) : definitions.lookup(label); diff --git a/lib/src/core/moderation/moderation_provider.dart b/lib/src/core/moderation/moderation_provider.dart index de9d0fcc..d6600715 100644 --- a/lib/src/core/moderation/moderation_provider.dart +++ b/lib/src/core/moderation/moderation_provider.dart @@ -4,6 +4,7 @@ import 'package:poptart_lex/com/atproto/label/defs.dart'; import 'package:spark/src/core/moderation/moderation.dart'; import 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; +import 'package:spark/src/core/network/atproto/data/services/appview_labeler_headers.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; import 'package:spark/src/core/providers/preferences_provider.dart'; @@ -15,10 +16,11 @@ final moderationEngineProvider = FutureProvider((ref) async { final preferences = await ref.watch(userPreferencesProvider.future); final repository = GetIt.I(); final logger = GetIt.I().getLogger('ModerationEngine'); - final labelerDids = { - repository.modDid.split('#').first, - ...?preferences.labelers?.map((labeler) => labeler.did), - }; + final labelerDids = AppViewLabelerHeaders.normalize( + defaultLabelerDid: repository.modDid, + labelerDids: + preferences.labelers?.map((labeler) => labeler.did) ?? const [], + ); final definitions = >{}; final labelerHandles = {}; @@ -47,7 +49,7 @@ final moderationEngineProvider = FutureProvider((ref) async { authenticated: repository.authRepository.isAuthenticated, ), currentUserDid: repository.authRepository.did, - selfLabelerDid: repository.modDid.split('#').first, + selfLabelerDid: labelerDids.first, labelerHandles: labelerHandles, ); }); diff --git a/lib/src/core/providers/preferences_provider.dart b/lib/src/core/providers/preferences_provider.dart index c5f528ea..48e834c9 100644 --- a/lib/src/core/providers/preferences_provider.dart +++ b/lib/src/core/providers/preferences_provider.dart @@ -22,6 +22,7 @@ class UserPreferences extends _$UserPreferences { late final PrefRepository _prefRepository; late final SprkRepository _sprkRepository; late final SparkLogger _logger; + Future _operationTail = Future.value(); @override Future build() async { @@ -46,50 +47,53 @@ class UserPreferences extends _$UserPreferences { } /// Gets the current preferences synchronously if available. - /// Returns null if preferences haven't been loaded yet or there was an error. + /// Returns null if preferences have never loaded successfully. Preferences? get currentPreferences => state.asData?.value; /// Refreshes preferences from the server. /// This should be called when logging in or when syncing from another device. - Future refresh() async { - state = const AsyncValue.loading(); - + Future refresh() => _enqueue(() async { try { final preferences = await _prefRepository.getPreferences(); state = AsyncValue.data(_configureLabelers(preferences)); - } catch (e, st) { + } catch (e) { _logger.e('Error refreshing preferences: $e'); - state = AsyncValue.error(e, st); rethrow; } - } + }); - /// Updates preferences on the server and in local state. - /// This should be called whenever preferences are modified. - Future updatePreferences(Preferences preferences) async { + /// Replaces the complete preference document on the server and in local state. + /// Feature-level edits should use [updatePreferencesWithFn] so their document + /// transformation runs after any earlier mutation has completed. + Future updatePreferences(Preferences preferences) => + _enqueue(() => _persistPreferences(preferences)); + + Future _persistPreferences(Preferences preferences) async { try { await _prefRepository.putPreferences(preferences); - state = AsyncValue.data(_configureLabelers(preferences)); - } catch (e, st) { + final committed = _configureLabelers(preferences); + state = AsyncValue.data(committed); + return committed; + } catch (e) { _logger.e('Error updating preferences: $e'); - state = AsyncValue.error(e, st); rethrow; } } /// Updates preferences by applying a transformation function. /// This is useful for making partial updates without fetching first. - Future updatePreferencesWithFn( + Future updatePreferencesWithFn( Preferences Function(Preferences current) updater, - ) async { + ) => _enqueue(() async { final current = state.asData?.value; if (current == null) { throw Exception('Cannot update preferences: not loaded yet'); } final updated = updater(current); - await updatePreferences(updated); - } + if (identical(updated, current)) return current; + return _persistPreferences(updated); + }); Future setAdultContentEnabled(bool enabled) async { await updatePreferencesWithFn((current) { @@ -131,6 +135,15 @@ class UserPreferences extends _$UserPreferences { }); } + Future _enqueue(Future Function() operation) { + final result = _operationTail.then((_) => operation()); + _operationTail = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + Preferences _configureLabelers(Preferences preferences) { _sprkRepository.configureLabelers( preferences.labelers?.map((labeler) => labeler.did) ?? const [], diff --git a/lib/src/features/settings/providers/labeler_settings_controller.dart b/lib/src/features/settings/providers/labeler_settings_controller.dart index 97ff44e3..33d4a2fe 100644 --- a/lib/src/features/settings/providers/labeler_settings_controller.dart +++ b/lib/src/features/settings/providers/labeler_settings_controller.dart @@ -38,10 +38,12 @@ final class LabelerSettingsController { _ref.read(userPreferencesProvider.future); } - Future _update(Preferences preferences) async { - await _ref + Future _update( + Preferences Function(Preferences current) updater, + ) { + return _ref .read(userPreferencesProvider.notifier) - .updatePreferences(preferences); + .updatePreferencesWithFn(updater); } Future> getLabelers() async { @@ -51,8 +53,15 @@ final class LabelerSettingsController { if (!_defaultEnsured) { if (!labelers.contains(_defaultDid)) { - labelers = _normalizeLabelers(labelers); - await _update(_withLabelers(preferences, labelers)); + final updated = await _update((current) { + final currentLabelers = + current.labelers?.map((labeler) => labeler.did) ?? const []; + final normalized = _normalizeLabelers(currentLabelers); + if (_sameLabelers(currentLabelers, normalized)) return current; + return _withLabelers(current, normalized); + }); + labelers = + updated.labelers?.map((labeler) => labeler.did).toList() ?? []; } _defaultEnsured = true; } @@ -73,12 +82,20 @@ final class LabelerSettingsController { ); } await _repository.labeler.validateService(did); - await _update( - _withLabelers(preferences, [ - ...current.map((labeler) => labeler.did), + await _update((latest) { + final latestLabelers = latest.labelers ?? const []; + if (latestLabelers.any((labeler) => labeler.did == did)) return latest; + if (latestLabelers.length >= AppViewLabelerHeaders.maxLabelers) { + throw StateError( + 'Cannot subscribe to more than ' + '${AppViewLabelerHeaders.maxLabelers} labelers', + ); + } + return _withLabelers(latest, [ + ...latestLabelers.map((labeler) => labeler.did), did, - ]), - ); + ]); + }); } catch (error, stackTrace) { _logger.e( 'Could not add labeler $identifier', @@ -93,42 +110,39 @@ final class LabelerSettingsController { if (did == _defaultDid) { throw Exception('Cannot remove the default moderation service'); } - final preferences = await _preferences(); - await _update( - _withLabelers( - preferences, - (preferences.labelers ?? const []) + await _update((current) { + final currentLabelers = current.labelers ?? const []; + if (!currentLabelers.any((labeler) => labeler.did == did)) return current; + return _withLabelers( + current, + currentLabelers .map((labeler) => labeler.did) .where((labelerDid) => labelerDid != did), - ), - ); + ); + }); } Future syncLabelers() async { resetSessionCache(); await _ref.read(userPreferencesProvider.notifier).refresh(); - var preferences = await _preferences(); - var labelers = _normalizeLabelers( + final preferences = await _update((current) { + final currentLabelers = + current.labelers?.map((labeler) => labeler.did) ?? const []; + final normalized = _normalizeLabelers(currentLabelers); + if (_sameLabelers(currentLabelers, normalized)) return current; + return _withLabelers(current, normalized); + }); + final labelers = _normalizeLabelers( (preferences.labelers ?? const []).map((labeler) => labeler.did), ); - if (!_sameLabelers( - preferences.labelers?.map((labeler) => labeler.did) ?? const [], - labelers, - )) { - preferences = _withLabelers(preferences, labelers); - await _update(preferences); - } - final available = []; + final unavailable = {}; for (final did in labelers) { - if (did == _defaultDid) { - available.add(did); - continue; - } + if (did == _defaultDid) continue; try { await _repository.labeler.validateService(did); - available.add(did); } on LabelerServiceUnavailableException catch (error, stackTrace) { + unavailable.add(did); _logger.w( 'Removing unavailable labeler $did', error: error, @@ -136,10 +150,18 @@ final class LabelerSettingsController { ); } } - if (!_sameLabelers(labelers, available)) { - preferences = _withLabelers(preferences, available); - await _update(preferences); - labelers = available; + if (unavailable.isNotEmpty) { + await _update((current) { + final currentLabelers = current.labelers ?? const []; + return _withLabelers( + current, + _normalizeLabelers( + currentLabelers + .map((labeler) => labeler.did) + .where((did) => !unavailable.contains(did)), + ), + ); + }); } _defaultEnsured = true; } @@ -190,13 +212,26 @@ final class LabelerSettingsController { String value, Setting setting, ) async { - final preferences = await _preferences(); - final updated = []; - var replaced = false; - for (final preference in preferences.preferences) { - final contentLabel = preference.contentLabelPref; - if (contentLabel?.labelerDid == labelerDid && - contentLabel?.label == value) { + await _update((current) { + final updated = []; + var replaced = false; + for (final preference in current.preferences) { + final contentLabel = preference.contentLabelPref; + if (contentLabel?.labelerDid == labelerDid && + contentLabel?.label == value) { + updated.add( + contentLabelPreference( + labelerDid: labelerDid, + label: value, + visibility: setting.name, + ), + ); + replaced = true; + } else { + updated.add(preference); + } + } + if (!replaced) { updated.add( contentLabelPreference( labelerDid: labelerDid, @@ -204,21 +239,9 @@ final class LabelerSettingsController { visibility: setting.name, ), ); - replaced = true; - } else { - updated.add(preference); } - } - if (!replaced) { - updated.add( - contentLabelPreference( - labelerDid: labelerDid, - label: value, - visibility: setting.name, - ), - ); - } - await _update(Preferences(preferences: updated)); + return Preferences(preferences: updated); + }); } Map _savedSettings( diff --git a/lib/src/features/settings/providers/settings_provider.dart b/lib/src/features/settings/providers/settings_provider.dart index ca3dd9b0..3ab8b6e7 100644 --- a/lib/src/features/settings/providers/settings_provider.dart +++ b/lib/src/features/settings/providers/settings_provider.dart @@ -82,20 +82,21 @@ class Settings extends _$Settings { /// Updates preferences through the UserPreferences provider. /// This ensures all watchers are notified of changes. - Future _updatePreferences(Preferences preferences) async { - await ref + Future _updatePreferences( + Preferences Function(Preferences current) updater, + ) { + return ref .read(userPreferencesProvider.notifier) - .updatePreferences(preferences); + .updatePreferencesWithFn(updater); } - Future _getCurrentPreferencesForFeedUpdate() async { + Future _refreshPreferencesForFeedUpdate() async { try { await ref.read(userPreferencesProvider.notifier).refresh(); final preferences = _currentPreferences; if (preferences == null) { throw const SavedFeedsUnavailableException(); } - return preferences; } catch (e, st) { logger.e( 'Cannot update feeds because current saved feeds could not be loaded', @@ -109,16 +110,19 @@ class Settings extends _$Settings { Future> _updateSavedFeeds( List Function(List currentSavedFeeds) update, ) async { - final preferences = await _getCurrentPreferencesForFeedUpdate(); - final updatedSavedFeeds = update( - List.of(_getSavedFeedsFromPreferences(preferences)), - ); - - final updatedPreferencesList = - preferences.preferences.where((pref) => !pref.isSavedFeedsPref).toList() - ..add(savedFeedsPreference(updatedSavedFeeds)); - - await _updatePreferences(Preferences(preferences: updatedPreferencesList)); + await _refreshPreferencesForFeedUpdate(); + final updatedPreferences = await _updatePreferences((current) { + final updatedSavedFeeds = update( + List.of(_getSavedFeedsFromPreferences(current)), + ); + final updatedPreferencesList = + current.preferences + .where((preference) => !preference.isSavedFeedsPref) + .toList() + ..add(savedFeedsPreference(updatedSavedFeeds)); + return Preferences(preferences: updatedPreferencesList); + }); + final updatedSavedFeeds = _getSavedFeedsFromPreferences(updatedPreferences); return _loadFeedsFromSavedFeeds(updatedSavedFeeds); } @@ -245,28 +249,29 @@ class Settings extends _$Settings { final defaultPrefs = DefaultPreferences.defaultPreferences( modServiceDid: modServiceDid, ); - final mergedDefaults = Preferences( - preferences: [ - ...preferences.preferences.where( - (preference) => !preference.isSavedFeedsPref, - ), - ...defaultPrefs.preferences.where((preference) { - if (preference.isSavedFeedsPref) return true; - if (preference.isLabelersPref) { - return preferences.labelers == null; - } - if (preference.isContentLabelPref) { - return preferences.contentLabelPrefs == null; - } - return false; - }), - ], - ); - await _updatePreferences(mergedDefaults); + final updatedPreferences = await _updatePreferences((current) { + if (_getSavedFeedsFromPreferences(current).isNotEmpty) { + return current; + } + return Preferences( + preferences: [ + ...current.preferences.where( + (preference) => !preference.isSavedFeedsPref, + ), + ...defaultPrefs.preferences.where((preference) { + if (preference.isSavedFeedsPref) return true; + if (preference.isLabelersPref) { + return current.labelers == null; + } + if (preference.isContentLabelPref) { + return current.contentLabelPrefs == null; + } + return false; + }), + ], + ); + }); - // Get updated preferences from provider - final updatedPreferences = - ref.read(userPreferencesProvider).asData?.value ?? mergedDefaults; final updatedSavedFeeds = _getSavedFeedsFromPreferences( updatedPreferences, ); diff --git a/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart b/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart index cbd817e0..1f0cd3ce 100644 --- a/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart +++ b/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart @@ -37,6 +37,7 @@ class _LabelerLabelSettingsPageState Map _labelPreferences = {}; Map _labelDefinitions = {}; bool _isLoading = true; + bool _isSaving = false; String? _errorMessage; String get _defaultModServiceDid { @@ -98,6 +99,9 @@ class _LabelerLabelSettingsPageState } Future _updateLabelPreference(String label, {Setting? setting}) async { + if (_isSaving) return; + setState(() => _isSaving = true); + try { final currentPref = _labelPreferences[label]; if (currentPref != null) { @@ -107,12 +111,15 @@ class _LabelerLabelSettingsPageState .read(labelerSettingsControllerProvider) .setLabelPreference(widget.did, label, newSetting); + if (!mounted) return; setState(() { _labelPreferences[label] = currentPref.copyWith(setting: newSetting); }); } } catch (e) { _logger.e('Error updating label preference: $e'); + } finally { + if (mounted) setState(() => _isSaving = false); } } @@ -349,6 +356,7 @@ class _LabelerLabelSettingsPageState ); return LabelSettingTile( + key: Key('labeler-label-${entry.key}'), label: entry.key, controlContext: entry.value.severity == Severity.inform ? LabelSettingTileContext.informLabel @@ -366,6 +374,7 @@ class _LabelerLabelSettingsPageState ? l10n.moderationConfiguredGlobally : null, enabled: + !_isSaving && !configuredGlobally && (!entry.value.adultOnly || adultContentEnabled), ); diff --git a/lib/src/features/settings/ui/pages/moderation_page.dart b/lib/src/features/settings/ui/pages/moderation_page.dart index 6b04ea3e..7b183cea 100644 --- a/lib/src/features/settings/ui/pages/moderation_page.dart +++ b/lib/src/features/settings/ui/pages/moderation_page.dart @@ -22,10 +22,13 @@ class ModerationPage extends ConsumerStatefulWidget { class _ModerationPageState extends ConsumerState { static const _adultLabels = ['porn', 'sexual', 'graphic-media', 'nudity']; + bool _isSaving = false; Future _setAdultContentEnabled(bool enabled) async { + if (_isSaving) return; final l10n = AppLocalizations.of(context); final logger = GetIt.instance().getLogger('ModerationPage'); + setState(() => _isSaving = true); try { await ref @@ -41,6 +44,8 @@ class _ModerationPageState extends ConsumerState { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); + } finally { + if (mounted) setState(() => _isSaving = false); } } @@ -48,8 +53,10 @@ class _ModerationPageState extends ConsumerState { String label, ModerationSetting setting, ) async { + if (_isSaving) return; final l10n = AppLocalizations.of(context); final logger = GetIt.instance().getLogger('ModerationPage'); + setState(() => _isSaving = true); try { await ref @@ -65,6 +72,8 @@ class _ModerationPageState extends ConsumerState { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); + } finally { + if (mounted) setState(() => _isSaving = false); } } @@ -107,7 +116,7 @@ class _ModerationPageState extends ConsumerState { ), subtitle: Text(l10n.settingAdultContentDescription), value: adultContentEnabled, - onChanged: preferences == null + onChanged: preferences == null || _isSaving ? null : _setAdultContentEnabled, ), @@ -130,6 +139,7 @@ class _ModerationPageState extends ConsumerState { preferences!, definition, ), + enabled: !_isSaving, onChanged: (setting) => _setGlobalLabelPreference(label, setting), ); diff --git a/test/src/core/moderation/moderated_content_test.dart b/test/src/core/moderation/moderated_content_test.dart index 4e6d4560..6434afa4 100644 --- a/test/src/core/moderation/moderated_content_test.dart +++ b/test/src/core/moderation/moderated_content_test.dart @@ -549,7 +549,11 @@ ModerationEngine _engine({ Map labelerHandles = const {}, }) { return ModerationEngine( - definitions: ModerationLabelDefinitions.fromLabelers(definitions), + definitions: ModerationLabelDefinitions.fromLabelers({ + 'did:plc:author': const [], + 'did:plc:labeler': const [], + ...definitions, + }), preferences: ModerationPreferences( labels: const [], adultContentEnabled: adultContentEnabled, diff --git a/test/src/core/moderation/moderation_engine_test.dart b/test/src/core/moderation/moderation_engine_test.dart index 6ca6d08e..8176b712 100644 --- a/test/src/core/moderation/moderation_engine_test.dart +++ b/test/src/core/moderation/moderation_engine_test.dart @@ -428,6 +428,39 @@ void main() { expect(decision.forContext(ModerationContext.contentList).blur, isFalse); }); + test('keeps profile-record imperatives out of authored content', () { + for (final namespace in [ + 'app.bsky.actor.profile', + 'so.sprk.actor.profile', + ]) { + for (final value in ['!hide', '!takedown', '!warn']) { + final decision = _engine().evaluateProfileLabels( + [_label(val: value, uri: 'at://did:plc:subject/$namespace/self')], + subjectDid: 'did:plc:subject', + now: now, + ); + + expect(decision.causes.single.target, ModerationTarget.profile); + expect( + decision.forContext(ModerationContext.profileView).blur, + isTrue, + reason: '$namespace $value profile', + ); + for (final context in [ + ModerationContext.contentList, + ModerationContext.contentView, + ModerationContext.contentMedia, + ]) { + final ui = decision.forContext(context); + expect(ui.filter, isFalse, reason: '$namespace $value $context'); + expect(ui.blur, isFalse, reason: '$namespace $value $context'); + expect(ui.alert, isFalse, reason: '$namespace $value $context'); + expect(ui.inform, isFalse, reason: '$namespace $value $context'); + } + } + } + }); + test('classifies profile-record imperatives exactly once', () { final decision = _engine(authenticated: false).evaluateProfileLabels( [ @@ -442,6 +475,8 @@ void main() { expect(decision.causes, hasLength(1)); expect(decision.causes.single.target, ModerationTarget.account); + expect(decision.hideFromUnauthenticated, isTrue); + expect(decision.forContext(ModerationContext.contentView).blur, isTrue); }); test( @@ -582,6 +617,37 @@ void main() { expect(decision.causes.single.sourceDid, 'did:plc:beta'); }); + test('ignores labels from non-configured sources', () { + final engine = _engine( + configuredLabelerDids: const {'did:plc:configured'}, + ); + final decision = engine.evaluate( + [ + _label(src: 'did:plc:removed', val: '!hide'), + _label(src: 'did:plc:removed', val: 'porn'), + _label(src: 'did:plc:configured', val: '!warn'), + ], + target: ModerationTarget.content, + now: now, + ); + + expect(decision.causes, hasLength(1)); + expect(decision.causes.single.sourceDid, 'did:plc:configured'); + expect(decision.causes.single.definition.identifier, '!warn'); + }); + + test('keeps supported self labels from non-configured authors', () { + final decision = _engine(configuredLabelerDids: const {}).evaluate( + [_label(src: 'did:plc:author', val: 'sexual')], + target: ModerationTarget.content, + subjectDid: 'did:plc:author', + now: now, + ); + + expect(decision.causes.single.sourceDid, 'did:plc:author'); + expect(decision.causes.single.definition.identifier, 'sexual'); + }); + test('selects labeler-authored cause strings for the viewer locale', () { final engine = _engine( definitions: { @@ -654,9 +720,13 @@ ModerationEngine _engine({ bool authenticated = true, String? currentUserDid, String? selfLabelerDid, + Set configuredLabelerDids = const {'did:plc:labeler'}, }) { return ModerationEngine( - definitions: ModerationLabelDefinitions.fromLabelers(definitions), + definitions: ModerationLabelDefinitions.fromLabelers({ + for (final did in configuredLabelerDids) did: const [], + ...definitions, + }), preferences: ModerationPreferences( labels: labelPreferences, adultContentEnabled: adultContentEnabled, diff --git a/test/src/core/moderation/moderation_provider_test.dart b/test/src/core/moderation/moderation_provider_test.dart index a10a0d18..a4864177 100644 --- a/test/src/core/moderation/moderation_provider_test.dart +++ b/test/src/core/moderation/moderation_provider_test.dart @@ -70,16 +70,65 @@ void main() { expect(decision.causes.single.sourceHandle, 'moderator.test'); }); + + test('only accepts labels from currently configured labelers', () async { + GetIt.I + ..registerSingleton( + _FakePrefRepository( + Preferences( + preferences: [ + labelersPreference(const [ + LabelerPrefItem(did: ' did:plc:selected#atproto_labeler '), + ]), + ], + ), + ), + ) + ..registerSingleton( + _FakeSprkRepository(_SuccessfulLabelerRepository()), + ) + ..registerSingleton(LogService()); + final container = ProviderContainer.test(); + addTearDown(container.dispose); + + final engine = await container.read(moderationEngineProvider.future); + final decision = engine.evaluate( + [ + _label(src: 'did:plc:removed', value: '!hide'), + _label(src: 'did:plc:removed', value: 'porn'), + _label(src: 'did:plc:selected', value: '!warn'), + _label(src: 'did:plc:moderator', value: 'sexual'), + ], + target: ModerationTarget.content, + subjectDid: 'did:plc:author', + ); + + expect(decision.causes.map((cause) => cause.sourceDid).toSet(), { + 'did:plc:selected', + 'did:plc:moderator', + }); + }); } class _FakePrefRepository implements PrefRepository { + _FakePrefRepository([this.preferences = const Preferences(preferences: [])]); + + final Preferences preferences; + @override - Future getPreferences() async => Preferences(preferences: []); + Future getPreferences() async => preferences; @override Future putPreferences(Preferences preferences) async {} } +Label _label({required String src, required String value}) => Label( + src: src, + uri: 'at://did:plc:author/so.sprk.feed.post/example', + val: value, + cts: DateTime.utc(2026), +); + class _FakeSprkRepository implements SprkRepository { _FakeSprkRepository(this.labeler); diff --git a/test/src/core/providers/preferences_provider_test.dart b/test/src/core/providers/preferences_provider_test.dart index 010929f4..5be0f004 100644 --- a/test/src/core/providers/preferences_provider_test.dart +++ b/test/src/core/providers/preferences_provider_test.dart @@ -7,11 +7,13 @@ import 'package:poptart/poptart.dart'; import 'package:spark/src/core/auth/data/models/login_result.dart'; import 'package:spark/src/core/auth/data/repositories/auth_repository.dart'; import 'package:spark/src/core/moderation/moderation.dart'; +import 'package:spark/src/core/network/atproto/data/models/labeler_models.dart'; import 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/pref_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; import 'package:spark/src/core/providers/preferences_provider.dart'; +import 'package:spark/src/features/settings/providers/labeler_settings_controller.dart'; void main() { late _FakeAuthRepository authRepository; @@ -100,8 +102,9 @@ void main() { ]); }); - test('refresh exposes and rethrows repository errors', () async { - prefRepository.getResult = _preferences('initial'); + test('refresh rethrows errors without discarding committed data', () async { + final initial = _preferences('initial'); + prefRepository.getResult = initial; final container = createContainer(); await container.read(userPreferencesProvider.future); final notifier = container.read(userPreferencesProvider.notifier); @@ -110,7 +113,8 @@ void main() { await expectLater(notifier.refresh(), throwsA(same(error))); - expect(container.read(userPreferencesProvider).error, same(error)); + expect(container.read(userPreferencesProvider).requireValue, initial); + expect(notifier.currentPreferences, initial); }); test('update persists and publishes preferences', () async { @@ -120,8 +124,9 @@ void main() { final notifier = container.read(userPreferencesProvider.notifier); final updated = _preferences('updated'); - await notifier.updatePreferences(updated); + final committed = await notifier.updatePreferences(updated); + expect(committed, updated); expect(prefRepository.putCalls, [updated]); expect(container.read(userPreferencesProvider).value, updated); expect(sprkRepository.labelerConfigurations, [ @@ -206,7 +211,7 @@ void main() { ); }); - test('update exposes and rethrows repository errors', () async { + test('update rethrows errors without discarding committed data', () async { final initial = _preferences('initial'); prefRepository.getResult = initial; final container = createContainer(); @@ -222,7 +227,8 @@ void main() { ); expect(prefRepository.putCalls, [updated]); - expect(container.read(userPreferencesProvider).error, same(error)); + expect(container.read(userPreferencesProvider).requireValue, initial); + expect(notifier.currentPreferences, initial); }); test( @@ -236,17 +242,95 @@ void main() { final notifier = container.read(userPreferencesProvider.notifier); Preferences? updaterInput; - await notifier.updatePreferencesWithFn((current) { + final committed = await notifier.updatePreferencesWithFn((current) { updaterInput = current; return updated; }); + expect(committed, updated); expect(updaterInput, initial); expect(prefRepository.putCalls, [updated]); expect(container.read(userPreferencesProvider).value, updated); }, ); + test( + 'serializes a rapid labeler setting against the latest document', + () async { + final initial = Preferences( + preferences: [ + labelersPreference([LabelerPrefItem(did: 'did:plc:labeler')]), + ], + ); + prefRepository.getResult = initial; + final firstWriteGate = Completer(); + prefRepository.putHandler = (call, preferences) async { + if (call == 1) await firstWriteGate.future; + }; + final container = createContainer(); + await container.read(userPreferencesProvider.future); + final notifier = container.read(userPreferencesProvider.notifier); + + final adultUpdate = notifier.setAdultContentEnabled(true); + await pumpEventQueue(); + final labelUpdate = container + .read(labelerSettingsControllerProvider) + .setLabelPreference('did:plc:labeler', 'custom', Setting.hide); + await pumpEventQueue(); + + expect(prefRepository.putCalls, hasLength(1)); + firstWriteGate.complete(); + await Future.wait([adultUpdate, labelUpdate]); + + expect(prefRepository.putCalls, hasLength(2)); + final persisted = prefRepository.putCalls.last; + expect(persisted.adultContentEnabled, isTrue); + final labelPreference = persisted.contentLabelPrefs?.single; + expect(labelPreference?.labelerDid, 'did:plc:labeler'); + expect(labelPreference?.label, 'custom'); + expect(labelPreference?.visibility.toJson(), 'hide'); + }, + ); + + test('continues queued transformations after a failed write', () async { + final initial = _preferences('initial'); + prefRepository.getResult = initial; + final firstWriteGate = Completer(); + final error = StateError('first write failed'); + prefRepository.putHandler = (call, preferences) async { + if (call == 1) { + await firstWriteGate.future; + throw error; + } + }; + final container = createContainer(); + await container.read(userPreferencesProvider.future); + final notifier = container.read(userPreferencesProvider.notifier); + + final failedUpdate = notifier.setAdultContentEnabled(true); + await pumpEventQueue(); + final succeedingUpdate = notifier.setGlobalLabelPreference( + 'porn', + ModerationSetting.warn, + ); + firstWriteGate.complete(); + + await expectLater(failedUpdate, throwsA(same(error))); + await succeedingUpdate; + + expect(prefRepository.putCalls, hasLength(2)); + final persisted = prefRepository.putCalls.last; + expect(persisted.adultContentEnabled, isFalse); + expect( + persisted.contentLabelPrefs + ?.singleWhere((preference) => preference.label == 'porn') + .visibility + .toJson(), + 'warn', + ); + expect(container.read(userPreferencesProvider).requireValue, persisted); + }); + test('updatePreferencesWithFn rejects unloaded state', () async { final initialization = Completer(); authRepository.initializationComplete = initialization.future; @@ -287,6 +371,7 @@ class _FakePrefRepository implements PrefRepository { Preferences getResult = Preferences(preferences: []); Object? getError; Object? putError; + Future Function(int call, Preferences preferences)? putHandler; int getCalls = 0; final List putCalls = []; @@ -301,6 +386,7 @@ class _FakePrefRepository implements PrefRepository { @override Future putPreferences(Preferences preferences) async { putCalls.add(preferences); + await putHandler?.call(putCalls.length, preferences); final error = putError; if (error != null) throw error; } diff --git a/test/src/features/feed/providers/feed_provider_test.dart b/test/src/features/feed/providers/feed_provider_test.dart index 59e6c49f..c28edea0 100644 --- a/test/src/features/feed/providers/feed_provider_test.dart +++ b/test/src/features/feed/providers/feed_provider_test.dart @@ -556,6 +556,7 @@ ModerationEngine _moderationEngine(Iterable hiddenLabels) { for (final value in hiddenLabels) ModerationLabelDefinition( identifier: value, + definedBy: 'did:plc:moderator', severity: ModerationSeverity.alert, blurs: ModerationBlur.content, defaultSetting: ModerationSetting.hide, diff --git a/test/src/features/feed/providers/visible_pinned_feeds_provider_test.dart b/test/src/features/feed/providers/visible_pinned_feeds_provider_test.dart index 01307174..138273e2 100644 --- a/test/src/features/feed/providers/visible_pinned_feeds_provider_test.dart +++ b/test/src/features/feed/providers/visible_pinned_feeds_provider_test.dart @@ -136,6 +136,7 @@ ModerationEngine _engine() => ModerationEngine( definitions: [ ModerationLabelDefinition( identifier: 'blocked', + definedBy: 'did:plc:moderator', severity: ModerationSeverity.alert, blurs: ModerationBlur.content, defaultSetting: ModerationSetting.hide, diff --git a/test/src/features/feed/ui/pages/feeds_page_test.dart b/test/src/features/feed/ui/pages/feeds_page_test.dart index 918933bd..93388f70 100644 --- a/test/src/features/feed/ui/pages/feeds_page_test.dart +++ b/test/src/features/feed/ui/pages/feeds_page_test.dart @@ -237,6 +237,7 @@ ModerationEngine _engine({required bool hideLabeledFeed}) => ModerationEngine( definitions: [ ModerationLabelDefinition( identifier: 'blocked', + definedBy: 'did:plc:moderator', severity: ModerationSeverity.alert, blurs: ModerationBlur.content, defaultSetting: hideLabeledFeed diff --git a/test/src/features/feed/ui/widgets/post_likes_sheet_test.dart b/test/src/features/feed/ui/widgets/post_likes_sheet_test.dart index 530d2bfa..58323212 100644 --- a/test/src/features/feed/ui/widgets/post_likes_sheet_test.dart +++ b/test/src/features/feed/ui/widgets/post_likes_sheet_test.dart @@ -89,7 +89,9 @@ PostLike _like(String name, {bool hidden = false}) { } ModerationEngine _engine() => ModerationEngine( - definitions: ModerationLabelDefinitions(), + definitions: ModerationLabelDefinitions.fromLabelers(const { + 'did:plc:moderator': [], + }), preferences: ModerationPreferences( labels: const [], adultContentEnabled: true, diff --git a/test/src/features/search/providers/post_search_provider_test.dart b/test/src/features/search/providers/post_search_provider_test.dart index 930d27db..73b4631b 100644 --- a/test/src/features/search/providers/post_search_provider_test.dart +++ b/test/src/features/search/providers/post_search_provider_test.dart @@ -68,6 +68,7 @@ void main() { definitions: [ ModerationLabelDefinition( identifier: 'blocked', + definedBy: 'did:plc:mod', severity: ModerationSeverity.alert, blurs: ModerationBlur.content, defaultSetting: ModerationSetting.hide, @@ -364,7 +365,9 @@ Label _authorLabel({bool profileRecord = false}) => Label( ); ModerationEngine _engine() => ModerationEngine( - definitions: ModerationLabelDefinitions(), + definitions: ModerationLabelDefinitions.fromLabelers(const { + 'did:plc:mod': [], + }), preferences: ModerationPreferences( labels: const [], adultContentEnabled: true, diff --git a/test/src/features/search/providers/suggested_feeds_provider_test.dart b/test/src/features/search/providers/suggested_feeds_provider_test.dart index 884d5512..a40a6841 100644 --- a/test/src/features/search/providers/suggested_feeds_provider_test.dart +++ b/test/src/features/search/providers/suggested_feeds_provider_test.dart @@ -191,7 +191,9 @@ Label _label(String value) => Label( ); ModerationEngine _engine() => ModerationEngine( - definitions: ModerationLabelDefinitions(), + definitions: ModerationLabelDefinitions.fromLabelers(const { + 'did:plc:moderator': [], + }), preferences: ModerationPreferences( labels: const [], adultContentEnabled: true, diff --git a/test/src/features/settings/providers/labeler_settings_controller_test.dart b/test/src/features/settings/providers/labeler_settings_controller_test.dart index de6bad04..90a24415 100644 --- a/test/src/features/settings/providers/labeler_settings_controller_test.dart +++ b/test/src/features/settings/providers/labeler_settings_controller_test.dart @@ -115,10 +115,21 @@ class _FakeUserPreferences extends UserPreferences { } @override - Future updatePreferences(Preferences preferences) async { + Future updatePreferences(Preferences preferences) async { controller.writes.add(preferences); controller.current = preferences; state = AsyncValue.data(preferences); + return preferences; + } + + @override + Future updatePreferencesWithFn( + Preferences Function(Preferences current) updater, + ) async { + final current = controller.current; + final updated = updater(current); + if (identical(updated, current)) return current; + return updatePreferences(updated); } } diff --git a/test/src/features/settings/providers/settings_provider_test.dart b/test/src/features/settings/providers/settings_provider_test.dart index b2986a80..7b31a0e5 100644 --- a/test/src/features/settings/providers/settings_provider_test.dart +++ b/test/src/features/settings/providers/settings_provider_test.dart @@ -596,7 +596,6 @@ class _FakeUserPreferences extends UserPreferences { controller.refreshCalls++; final error = controller.refreshError; if (error != null) { - state = AsyncValue.error(error, StackTrace.current); throw error; } final refreshed = controller.refreshResult ?? controller.current; @@ -605,15 +604,25 @@ class _FakeUserPreferences extends UserPreferences { } @override - Future updatePreferences(Preferences preferences) async { + Future updatePreferences(Preferences preferences) async { controller.writes.add(preferences); final error = controller.updateError; if (error != null) { - state = AsyncValue.error(error, StackTrace.current); throw error; } controller.current = preferences; state = AsyncValue.data(_configureLabelers(preferences)); + return preferences; + } + + @override + Future updatePreferencesWithFn( + Preferences Function(Preferences current) updater, + ) async { + final current = controller.current; + final updated = updater(current); + if (identical(updated, current)) return current; + return updatePreferences(updated); } Preferences _configureLabelers(Preferences preferences) { diff --git a/test/src/features/settings/ui/pages/labeler_label_settings_page_test.dart b/test/src/features/settings/ui/pages/labeler_label_settings_page_test.dart index e1ac2cf3..c07170c2 100644 --- a/test/src/features/settings/ui/pages/labeler_label_settings_page_test.dart +++ b/test/src/features/settings/ui/pages/labeler_label_settings_page_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:poptart/poptart.dart'; import 'package:poptart_lex/com/atproto/label/defs.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; import 'package:spark/src/core/design_system/components/molecules/app_choice_group.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/moderation/moderation.dart'; @@ -89,6 +90,67 @@ void main() { expect(tester.takeException(), isNull); }); + + testWidgets('disables every label control while a save is pending', ( + tester, + ) async { + await GetIt.I.unregister(); + GetIt.I.registerSingleton( + _FakeSprkRepository(_customService(labelerDid)), + ); + final preferences = _PendingPreferences(); + + await tester.pumpWidget(_page(labelerDid, preferences: preferences)); + await tester.pumpAndSettle(); + + final firstControl = find.byKey(const Key('labeler-label-topic-one')); + await tester.tap( + find + .descendant( + of: firstControl, + matching: find.byType(InteractivePressable), + ) + .last, + ); + final secondControl = find.byKey(const Key('labeler-label-topic-two')); + await tester.tap( + find + .descendant( + of: secondControl, + matching: find.byType(InteractivePressable), + ) + .last, + ); + expect(preferences.updateCount, 1); + + await tester.pump(); + + expect(preferences.updateCount, 1); + expect( + tester + .widgetList>( + find.byType(AppChoiceGroup), + ) + .every((group) => !group.enabled), + isTrue, + ); + + await tester.tap( + find + .descendant( + of: secondControl, + matching: find.byType(InteractivePressable), + ) + .last, + ); + expect(preferences.updateCount, 1); + + await tester.pumpWidget(const SizedBox.shrink()); + preferences.save.complete(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); } Future _pumpPage(WidgetTester tester, String labelerDid) async { @@ -96,14 +158,19 @@ Future _pumpPage(WidgetTester tester, String labelerDid) async { await tester.pumpAndSettle(); } -Widget _page(String labelerDid) => ProviderScope( - overrides: [userPreferencesProvider.overrideWith(_FakePreferences.new)], - child: MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: LabelerLabelSettingsPage(did: labelerDid), - ), -); +Widget _page(String labelerDid, {UserPreferences? preferences}) => + ProviderScope( + overrides: [ + userPreferencesProvider.overrideWith( + preferences == null ? _FakePreferences.new : () => preferences, + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: LabelerLabelSettingsPage(did: labelerDid), + ), + ); LabelerViewDetailed _service(String did) => LabelerViewDetailed( uri: AtUri.parse('at://$did/app.bsky.labeler.service/self'), @@ -133,6 +200,19 @@ LabelerViewDetailed _informService(String did) => LabelerViewDetailed( indexedAt: DateTime.utc(2026), ); +LabelerViewDetailed _customService(String did) => LabelerViewDetailed( + uri: AtUri.parse('at://$did/app.bsky.labeler.service/self'), + cid: 'cid', + creator: ProfileView(did: did, handle: 'labeler.test'), + policies: LabelerPolicies( + labelValues: const [ + LabelValue.unknown(data: 'topic-one'), + LabelValue.unknown(data: 'topic-two'), + ], + ), + indexedAt: DateTime.utc(2026), +); + class _FakeActorRepository implements ActorRepository { @override Future> getProfiles( @@ -195,3 +275,19 @@ class _FakePreferences extends UserPreferences { @override Future build() async => Preferences(preferences: []); } + +class _PendingPreferences extends _FakePreferences { + final save = Completer(); + int updateCount = 0; + Preferences current = Preferences(preferences: []); + + @override + Future updatePreferencesWithFn( + Preferences Function(Preferences current) updater, + ) async { + updateCount++; + current = updater(current); + await save.future; + return current; + } +} diff --git a/test/src/features/settings/ui/pages/moderation_page_test.dart b/test/src/features/settings/ui/pages/moderation_page_test.dart index 0c47a32f..1f4f0e15 100644 --- a/test/src/features/settings/ui/pages/moderation_page_test.dart +++ b/test/src/features/settings/ui/pages/moderation_page_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -39,6 +41,10 @@ void main() { await tester.pumpAndSettle(); expect(find.text('An error occurred'), findsOneWidget); + expect( + tester.widget(find.byType(SwitchListTile)).onChanged, + isNotNull, + ); expect(tester.takeException(), isNull); }); @@ -108,6 +114,71 @@ void main() { expect(find.text(strings.value), findsOneWidget); } }); + + testWidgets('disables all moderation controls while a save is pending', ( + tester, + ) async { + final preferences = _PendingAdultPreferences(); + await _pumpPage(tester, () => preferences); + + final pornControl = find.byKey(const Key('global-label-porn')); + await tester.tap( + find + .descendant( + of: pornControl, + matching: find.byType(InteractivePressable), + ) + .first, + ); + final sexualControl = find.byKey(const Key('global-label-sexual')); + await tester.tap( + find + .descendant( + of: sexualControl, + matching: find.byType(InteractivePressable), + ) + .last, + ); + expect(preferences.updateCount, 1); + + await tester.pump(); + + expect(preferences.updateCount, 1); + expect( + tester + .widgetList>( + find.byType(AppChoiceGroup), + ) + .every((group) => !group.enabled), + isTrue, + ); + expect( + tester.widget(find.byType(SwitchListTile)).onChanged, + isNull, + ); + + await tester.tap( + find + .descendant( + of: sexualControl, + matching: find.byType(InteractivePressable), + ) + .last, + ); + expect(preferences.updateCount, 1); + + preferences.save.complete(); + await tester.pumpAndSettle(); + + expect( + tester + .widgetList>( + find.byType(AppChoiceGroup), + ) + .every((group) => group.enabled), + isTrue, + ); + }); } Future _pumpPage( @@ -164,3 +235,21 @@ class _AdultPreferences extends UserPreferences { lastSetting = setting; } } + +class _PendingAdultPreferences extends UserPreferences { + final save = Completer(); + int updateCount = 0; + + @override + Future build() async => + Preferences(preferences: [adultContentPreference(enabled: true)]); + + @override + Future setGlobalLabelPreference( + String label, + ModerationSetting setting, + ) async { + updateCount++; + await save.future; + } +} diff --git a/test/src/features/sound/ui/widgets/audio_selection_bottom_sheet_test.dart b/test/src/features/sound/ui/widgets/audio_selection_bottom_sheet_test.dart index 9175be2a..6120601b 100644 --- a/test/src/features/sound/ui/widgets/audio_selection_bottom_sheet_test.dart +++ b/test/src/features/sound/ui/widgets/audio_selection_bottom_sheet_test.dart @@ -371,7 +371,9 @@ AudioView _audio({required bool hidden}) { } ModerationEngine _engine() => ModerationEngine( - definitions: ModerationLabelDefinitions(), + definitions: ModerationLabelDefinitions.fromLabelers(const { + 'did:plc:moderator': [], + }), preferences: ModerationPreferences( labels: const [], adultContentEnabled: true, diff --git a/test/src/features/stories/ui/pages/story_page_test.dart b/test/src/features/stories/ui/pages/story_page_test.dart index fc24ae70..9a88c3ea 100644 --- a/test/src/features/stories/ui/pages/story_page_test.dart +++ b/test/src/features/stories/ui/pages/story_page_test.dart @@ -208,7 +208,9 @@ ModeratedStoryView _story( } ModerationEngine _engine() => ModerationEngine( - definitions: ModerationLabelDefinitions(), + definitions: ModerationLabelDefinitions.fromLabelers(const { + 'did:plc:moderator': [], + }), preferences: ModerationPreferences( labels: const [], adultContentEnabled: true,