diff --git a/lib/src/core/moderation/moderation_report_catalog.dart b/lib/src/core/moderation/moderation_report_catalog.dart new file mode 100644 index 00000000..718fcef4 --- /dev/null +++ b/lib/src/core/moderation/moderation_report_catalog.dart @@ -0,0 +1,291 @@ +import 'package:poptart_lex/com/atproto/moderation/defs.dart'; + +enum ReportCategory { + violence('Violence'), + sexual('Sexual Content'), + childSafety('Child Safety'), + harassment('Harassment'), + misleading('Misleading'), + ruleViolations('Rule Violations'), + selfHarm('Self-Harm'), + other('Other'); + + const ReportCategory(this.displayName); + final String displayName; +} + +class ReportReason { + final String value; + final String displayName; + final String? description; + final KnownReasonType? knownType; + + const ReportReason({ + required this.value, + required this.displayName, + this.description, + this.knownType, + }); + + ReasonType get reasonType => knownType != null + ? ReasonType.knownValue(data: knownType!) + : ReasonType.unknown(data: value); +} + +final Map> reportCategoryReasons = { + ReportCategory.violence: [ + const ReportReason( + value: 'tools.ozone.report.defs#reasonViolenceAnimal', + displayName: 'Animal Abuse', + description: 'Content depicting harm to animals', + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceAnimal, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonViolenceThreats', + displayName: 'Threats', + description: 'Threats of violence', + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceThreats, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonViolenceGraphicContent', + displayName: 'Graphic Content', + description: 'Graphic or violent imagery', + knownType: + KnownReasonType.toolsOzoneReportDefsReasonViolenceGraphicContent, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonViolenceGlorification', + displayName: 'Glorification of Violence', + description: 'Content that glorifies violence', + knownType: + KnownReasonType.toolsOzoneReportDefsReasonViolenceGlorification, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonViolenceExtremistContent', + displayName: 'Extremist Content', + description: 'Content promoting extremist ideologies', + knownType: + KnownReasonType.toolsOzoneReportDefsReasonViolenceExtremistContent, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonViolenceTrafficking', + displayName: 'Trafficking', + description: 'Content related to human trafficking', + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceTrafficking, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonViolenceOther', + displayName: 'Other Violence', + description: 'Other violent content', + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceOther, + ), + ], + ReportCategory.sexual: [ + const ReportReason( + value: 'tools.ozone.report.defs#reasonSexualAbuseContent', + displayName: 'Abuse Content', + description: 'Sexual abuse content', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualAbuseContent, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonSexualNCII', + displayName: 'Non-Consensual Intimate Images', + description: 'Sharing intimate images without consent', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualNCII, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonSexualDeepfake', + displayName: 'Deepfake', + description: 'AI-generated sexual content', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualDeepfake, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonSexualAnimal', + displayName: 'Animal Sexual Content', + description: 'Sexual content involving animals', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualAnimal, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonSexualUnlabeled', + displayName: 'Unlabeled Sexual Content', + description: 'Sexual content without proper warnings', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualUnlabeled, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonSexualOther', + displayName: 'Other Sexual Content', + description: 'Other sexual content violations', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualOther, + ), + ], + ReportCategory.childSafety: [ + const ReportReason( + value: 'tools.ozone.report.defs#reasonChildSafetyCSAM', + displayName: 'CSAM', + description: 'Child sexual abuse material', + knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyCSAM, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonChildSafetyGroom', + displayName: 'Grooming', + description: 'Grooming behavior targeting minors', + knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyGroom, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonChildSafetyPrivacy', + displayName: 'Privacy Violation', + description: 'Sharing private information about minors', + knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyPrivacy, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonChildSafetyHarassment', + displayName: 'Harassment', + description: 'Harassment targeting minors', + knownType: + KnownReasonType.toolsOzoneReportDefsReasonChildSafetyHarassment, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonChildSafetyOther', + displayName: 'Other Child Safety', + description: 'Other child safety concerns', + knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyOther, + ), + ], + ReportCategory.harassment: [ + const ReportReason( + value: 'tools.ozone.report.defs#reasonHarassmentTroll', + displayName: 'Trolling', + description: 'Trolling or disruptive behavior', + knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentTroll, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonHarassmentTargeted', + displayName: 'Targeted Harassment', + description: 'Targeted harassment or bullying', + knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentTargeted, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonHarassmentHateSpeech', + displayName: 'Hate Speech', + description: 'Hate speech or discriminatory content', + knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentHateSpeech, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonHarassmentDoxxing', + displayName: 'Doxxing', + description: 'Sharing private information without consent', + knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentDoxxing, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonHarassmentOther', + displayName: 'Other Harassment', + description: 'Other harassment violations', + knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentOther, + ), + ], + ReportCategory.misleading: [ + const ReportReason( + value: 'tools.ozone.report.defs#reasonMisleadingBot', + displayName: 'Bot Account', + description: 'Automated or bot account', + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingBot, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonMisleadingImpersonation', + displayName: 'Impersonation', + description: 'Impersonating another person or entity', + knownType: + KnownReasonType.toolsOzoneReportDefsReasonMisleadingImpersonation, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonMisleadingSpam', + displayName: 'Spam', + description: 'Spam or repetitive content', + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingSpam, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonMisleadingScam', + displayName: 'Scam', + description: 'Fraudulent or scam content', + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingScam, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonMisleadingElections', + displayName: 'Election Misinformation', + description: 'False information about elections', + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingElections, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonMisleadingOther', + displayName: 'Other Misleading', + description: 'Other misleading content', + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingOther, + ), + ], + ReportCategory.ruleViolations: [ + const ReportReason( + value: 'tools.ozone.report.defs#reasonRuleSiteSecurity', + displayName: 'Site Security', + description: 'Violation of site security rules', + knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleSiteSecurity, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonRuleProhibitedSales', + displayName: 'Prohibited Sales', + description: 'Prohibited goods or services', + knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleProhibitedSales, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonRuleBanEvasion', + displayName: 'Ban Evasion', + description: 'Attempting to evade a ban', + knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleBanEvasion, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonRuleOther', + displayName: 'Other Rule Violation', + description: 'Other rule violations', + knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleOther, + ), + ], + ReportCategory.selfHarm: [ + const ReportReason( + value: 'tools.ozone.report.defs#reasonSelfHarmContent', + displayName: 'Self-Harm Content', + description: 'Content promoting self-harm', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmContent, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonSelfHarmED', + displayName: 'Eating Disorder', + description: 'Content promoting eating disorders', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmED, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonSelfHarmStunts', + displayName: 'Dangerous Stunts', + description: 'Content showing dangerous stunts', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmStunts, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonSelfHarmSubstances', + displayName: 'Substance Abuse', + description: 'Content promoting substance abuse', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmSubstances, + ), + const ReportReason( + value: 'tools.ozone.report.defs#reasonSelfHarmOther', + displayName: 'Other Self-Harm', + description: 'Other self-harm related content', + knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmOther, + ), + ], + ReportCategory.other: [ + const ReportReason( + value: 'tools.ozone.report.defs#reasonOther', + displayName: 'Other', + description: 'Other issues not listed above', + knownType: KnownReasonType.toolsOzoneReportDefsReasonOther, + ), + ], +}; diff --git a/lib/src/core/moderation/moderation_report_service.dart b/lib/src/core/moderation/moderation_report_service.dart new file mode 100644 index 00000000..5180fa0b --- /dev/null +++ b/lib/src/core/moderation/moderation_report_service.dart @@ -0,0 +1,71 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get_it/get_it.dart'; +import 'package:poptart_lex/com/atproto/moderation/create_report.dart'; +import 'package:poptart_lex/com/atproto/moderation/defs.dart'; +import 'package:poptart_lex/com/atproto/repo/strong_ref.dart'; +import 'package:spark/src/core/network/atproto/data/repositories/labeler_repository.dart'; +import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; + +typedef ModerationReportSubmitter = + Future Function(ModerationCreateReportInput input, String serviceDid); + +final moderationReportServiceProvider = Provider( + (ref) => ModerationReportService(GetIt.I()), +); + +final class ModerationReportOptions { + const ModerationReportOptions(this.services); + + final List services; + + String? get defaultServiceDid => services.firstOrNull?.did; +} + +final class ModerationReportService { + const ModerationReportService(this._repository); + + final SprkRepository _repository; + + Future loadOptions({ + required UModerationCreateReportSubject subject, + required ReasonType reasonType, + String? fallbackServiceDid, + }) async { + final subjectData = subject.data; + final isBskyRecord = + subjectData is RepoStrongRef && + subjectData.uri.collection.toString().startsWith('app.bsky'); + final fallbackProxyDid = + fallbackServiceDid ?? + (isBskyRecord ? _repository.bskyModDid : _repository.modDid); + final fallbackDid = fallbackProxyDid.split('#').first; + final services = await _repository.labeler.getCompatibleModerationServices( + _repository.labelerDids, + ModerationServiceQuery.forReport( + fallbackDid: fallbackDid, + subject: subject, + reasonType: reasonType.toJson(), + ), + ); + return ModerationReportOptions(services); + } + + Future submit({ + required UModerationCreateReportSubject subject, + required ReasonType reasonType, + required String serviceDid, + String? reason, + ModerationReportSubmitter? submitter, + }) async { + final input = ModerationCreateReportInput( + subject: subject, + reasonType: reasonType, + reason: reason, + ); + if (submitter != null) { + await submitter(input, serviceDid); + return; + } + await _repository.repo.createReport(input: input, serviceDid: serviceDid); + } +} diff --git a/lib/src/core/network/atproto/data/models/labeler_models.dart b/lib/src/core/network/atproto/data/models/labeler_models.dart index ab1d5f40..0059700f 100644 --- a/lib/src/core/network/atproto/data/models/labeler_models.dart +++ b/lib/src/core/network/atproto/data/models/labeler_models.dart @@ -1,88 +1,6 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:sprk_poptart/so/sprk/labeler/defs.dart' as labeler_defs; -part 'labeler_models.freezed.dart'; -part 'labeler_models.g.dart'; - typedef LabelerView = labeler_defs.LabelerView; typedef LabelerViewDetailed = labeler_defs.LabelerViewDetailed; typedef LabelerViewerState = labeler_defs.LabelerViewerState; typedef LabelerPolicies = labeler_defs.LabelerPolicies; - -const defaultLabels = [ - '!hide', - '!no-promote', - '!warn', - '!no-unauthenticated', - 'dmca-violation', - 'doxxing', - 'porn', - 'sexual', - 'nudity', - 'nsfl', - 'gore', -]; - -enum Blurs { - content('content'), - media('media'), - none('none'); - - final String value; - const Blurs(this.value); - - static Blurs fromValue(String value) { - if (Blurs.values.any((e) => e.value == value)) { - return Blurs.values.firstWhere((e) => e.value == value); - } - throw Exception('Invalid blur: $value'); - } -} - -enum Severity { - alert('alert'), - inform('inform'), - none('none'); - - final String value; - const Severity(this.value); - - static Severity fromValue(String value) { - if (Severity.values.any((e) => e.value == value)) { - return Severity.values.firstWhere((e) => e.value == value); - } - throw Exception('Invalid severity: $value'); - } -} - -enum Setting { - hide('hide'), - warn('warn'), - ignore('ignore'); - - final String value; - const Setting(this.value); - - static Setting fromValue(String value) { - if (Setting.values.any((e) => e.value == value)) { - return Setting.values.firstWhere((e) => e.value == value); - } - throw Exception('Invalid default setting: $value'); - } -} - -@freezed -abstract class LabelPreference with _$LabelPreference { - factory LabelPreference({ - required String value, - required Blurs blurs, - required Severity severity, - required Setting defaultSetting, - required Setting setting, - required bool adultOnly, - }) = _LabelPreference; - const LabelPreference._(); - - factory LabelPreference.fromJson(Map json) => - _$LabelPreferenceFromJson(json); -} diff --git a/lib/src/core/network/atproto/data/repositories/repo_repository.dart b/lib/src/core/network/atproto/data/repositories/repo_repository.dart index 15d2fde6..e5239e7a 100644 --- a/lib/src/core/network/atproto/data/repositories/repo_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/repo_repository.dart @@ -73,12 +73,10 @@ abstract class RepoRepository { /// [subject] The subject of the report (content or account) /// [reasonType] The reason for the report /// [reason] Optional additional context about the violation - /// [service] Optional moderation service to use + /// [serviceDid] Moderation service receiving the report /// - /// Returns true if the report was successfully created - Future createReport({ + Future createReport({ required ModerationCreateReportInput input, - dynamic service, - String? serviceDid, + required String serviceDid, }); } diff --git a/lib/src/core/network/atproto/data/repositories/repo_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/repo_repository_impl.dart index 69d5cdf6..0c069a39 100644 --- a/lib/src/core/network/atproto/data/repositories/repo_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/repo_repository_impl.dart @@ -17,7 +17,6 @@ import 'package:poptart/poptart.dart'; import 'package:get_it/get_it.dart'; import 'package:spark/src/core/network/atproto/data/adapters/bsky/repo_adapter.dart'; import 'package:spark/src/core/network/atproto/data/models/record_models.dart'; -import 'package:spark/src/core/network/atproto/data/repositories/labeler_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/repo_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; @@ -260,10 +259,9 @@ class RepoRepositoryImpl implements RepoRepository { } @override - Future createReport({ + Future createReport({ required ModerationCreateReportInput input, - dynamic service, - String? serviceDid, + required String serviceDid, }) async { _logger.i('Creating moderation report for reason: ${input.reasonType}'); @@ -277,78 +275,34 @@ class RepoRepositoryImpl implements RepoRepository { if (atproto == null || atproto.oAuthSession == null) { _logger.e('AtProto not initialized'); throw Exception('AtProto not initialized'); - } else if (service != null) { - _logger.d('Using provided moderation service'); - try { - final report = await service.createReport( - subject: input.subject, - reasonType: input.reasonType, - reason: input.reason, - ); - return report.status.code == 200; - } catch (e) { - _logger.e('Error creating report with service', error: e); - throw Exception('Failed to create report: $e'); - } - } else { - _logger.d('Using direct API call for moderation report'); - final subjectData = input.subject.data; - final isBskyPost = - subjectData is RepoStrongRef && - subjectData.uri.collection.toString().startsWith('app.bsky'); - final fallbackServiceDid = isBskyPost - ? _client.bskyModDid - : _client.modDid; - final modServiceDid = - serviceDid ?? - await _resolveCompatibleModerationService( - input, - fallbackDid: fallbackServiceDid, - ); - _logger.d('Routing report to moderation service: $modServiceDid'); - - final headers = {'atproto-proxy': _moderationProxyDid(modServiceDid)}; + } - try { - final response = await atproto.call( - comAtprotoModerationCreateReport, - headers: headers, - input: input, - ); + _logger.d('Routing report to moderation service: $serviceDid'); + final headers = {'atproto-proxy': _moderationProxyDid(serviceDid)}; - if (response.status != HttpStatus.ok) { - _logger.e( - 'Failed to create report: ${response.data}', - error: 'HTTP ${response.status}', - ); - throw Exception('Failed to create report: ${response.data}'); - } + try { + final response = await atproto.call( + comAtprotoModerationCreateReport, + headers: headers, + input: input, + ); - _logger.i('Report created successfully'); - return true; - } catch (e) { - _logger.e('Error creating report', error: e); - throw Exception('Failed to create report: $e'); + if (response.status != HttpStatus.ok) { + _logger.e( + 'Failed to create report: ${response.data}', + error: 'HTTP ${response.status}', + ); + throw Exception('Failed to create report: ${response.data}'); } + + _logger.i('Report created successfully'); + } catch (e) { + _logger.e('Error creating report', error: e); + throw Exception('Failed to create report: $e'); } }); } - Future _resolveCompatibleModerationService( - ModerationCreateReportInput input, { - required String fallbackDid, - }) async { - final compatible = await _client.labeler.getCompatibleModerationServices( - _client.labelerDids, - ModerationServiceQuery.forReport( - fallbackDid: fallbackDid, - subject: input.subject, - reasonType: input.reasonType.toJson(), - ), - ); - return compatible.firstOrNull?.did ?? fallbackDid; - } - String _moderationProxyDid(String did) { return did.contains('#') ? did : '$did#atproto_labeler'; } diff --git a/lib/src/core/ui/widgets/report_dialog.dart b/lib/src/core/ui/widgets/report_dialog.dart index 0dbcb8a6..7b50d53f 100644 --- a/lib/src/core/ui/widgets/report_dialog.dart +++ b/lib/src/core/ui/widgets/report_dialog.dart @@ -1,55 +1,17 @@ import 'dart:async'; import 'package:poptart_lex/com/atproto/moderation/create_report.dart'; -import 'package:poptart_lex/com/atproto/moderation/defs.dart'; import 'package:auto_route/auto_route.dart'; -import 'package:poptart_lex/com/atproto/repo/strong_ref.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; -import 'package:spark/src/core/network/atproto/data/repositories/labeler_repository.dart'; -import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; +import 'package:spark/src/core/moderation/moderation_report_catalog.dart'; +import 'package:spark/src/core/moderation/moderation_report_service.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; import 'package:spark/src/core/utils/logging/logger.dart'; -typedef ReportSubmitCallback = - Future Function( - UModerationCreateReportSubject subject, - ReasonType reasonType, - String? reason, - String serviceDid, - ); - -enum ReportCategory { - violence('Violence'), - sexual('Sexual Content'), - childSafety('Child Safety'), - harassment('Harassment'), - misleading('Misleading'), - ruleViolations('Rule Violations'), - selfHarm('Self-Harm'), - other('Other'); - - const ReportCategory(this.displayName); - final String displayName; -} - -class ReportReason { - final String value; - final String displayName; - final String? description; - final KnownReasonType? knownType; - - const ReportReason({ - required this.value, - required this.displayName, - this.description, - this.knownType, - }); -} - class ReportDialog extends ConsumerStatefulWidget { const ReportDialog({ required this.subject, @@ -60,9 +22,7 @@ class ReportDialog extends ConsumerStatefulWidget { final UModerationCreateReportSubject subject; final String? fallbackServiceDid; - /// Callback for report submission. Uses [ReasonType] directly to support - /// known & unknown reason types. - final ReportSubmitCallback? onSubmit; + final ModerationReportSubmitter? onSubmit; @override ConsumerState createState() => _ReportDialogState(); @@ -78,272 +38,9 @@ class _ReportDialogState extends ConsumerState { TextEditingController(); bool _isSubmitting = false; String? _errorMessage; - Future>? _compatibleServicesFuture; + Future? _reportOptionsFuture; String? _selectedServiceDid; - // Map categories to their reasons - static final Map> _categoryReasons = { - ReportCategory.violence: [ - const ReportReason( - value: 'tools.ozone.report.defs#reasonViolenceAnimal', - displayName: 'Animal Abuse', - description: 'Content depicting harm to animals', - knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceAnimal, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonViolenceThreats', - displayName: 'Threats', - description: 'Threats of violence', - knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceThreats, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonViolenceGraphicContent', - displayName: 'Graphic Content', - description: 'Graphic or violent imagery', - knownType: - KnownReasonType.toolsOzoneReportDefsReasonViolenceGraphicContent, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonViolenceGlorification', - displayName: 'Glorification of Violence', - description: 'Content that glorifies violence', - knownType: - KnownReasonType.toolsOzoneReportDefsReasonViolenceGlorification, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonViolenceExtremistContent', - displayName: 'Extremist Content', - description: 'Content promoting extremist ideologies', - knownType: - KnownReasonType.toolsOzoneReportDefsReasonViolenceExtremistContent, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonViolenceTrafficking', - displayName: 'Trafficking', - description: 'Content related to human trafficking', - knownType: - KnownReasonType.toolsOzoneReportDefsReasonViolenceTrafficking, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonViolenceOther', - displayName: 'Other Violence', - description: 'Other violent content', - knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceOther, - ), - ], - ReportCategory.sexual: [ - const ReportReason( - value: 'tools.ozone.report.defs#reasonSexualAbuseContent', - displayName: 'Abuse Content', - description: 'Sexual abuse content', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualAbuseContent, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonSexualNCII', - displayName: 'Non-Consensual Intimate Images', - description: 'Sharing intimate images without consent', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualNCII, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonSexualDeepfake', - displayName: 'Deepfake', - description: 'AI-generated sexual content', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualDeepfake, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonSexualAnimal', - displayName: 'Animal Sexual Content', - description: 'Sexual content involving animals', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualAnimal, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonSexualUnlabeled', - displayName: 'Unlabeled Sexual Content', - description: 'Sexual content without proper warnings', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualUnlabeled, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonSexualOther', - displayName: 'Other Sexual Content', - description: 'Other sexual content violations', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualOther, - ), - ], - ReportCategory.childSafety: [ - const ReportReason( - value: 'tools.ozone.report.defs#reasonChildSafetyCSAM', - displayName: 'CSAM', - description: 'Child sexual abuse material', - knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyCSAM, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonChildSafetyGroom', - displayName: 'Grooming', - description: 'Grooming behavior targeting minors', - knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyGroom, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonChildSafetyPrivacy', - displayName: 'Privacy Violation', - description: 'Sharing private information about minors', - knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyPrivacy, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonChildSafetyHarassment', - displayName: 'Harassment', - description: 'Harassment targeting minors', - knownType: - KnownReasonType.toolsOzoneReportDefsReasonChildSafetyHarassment, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonChildSafetyOther', - displayName: 'Other Child Safety', - description: 'Other child safety concerns', - knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyOther, - ), - ], - ReportCategory.harassment: [ - const ReportReason( - value: 'tools.ozone.report.defs#reasonHarassmentTroll', - displayName: 'Trolling', - description: 'Trolling or disruptive behavior', - knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentTroll, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonHarassmentTargeted', - displayName: 'Targeted Harassment', - description: 'Targeted harassment or bullying', - knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentTargeted, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonHarassmentHateSpeech', - displayName: 'Hate Speech', - description: 'Hate speech or discriminatory content', - knownType: - KnownReasonType.toolsOzoneReportDefsReasonHarassmentHateSpeech, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonHarassmentDoxxing', - displayName: 'Doxxing', - description: 'Sharing private information without consent', - knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentDoxxing, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonHarassmentOther', - displayName: 'Other Harassment', - description: 'Other harassment violations', - knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentOther, - ), - ], - ReportCategory.misleading: [ - const ReportReason( - value: 'tools.ozone.report.defs#reasonMisleadingBot', - displayName: 'Bot Account', - description: 'Automated or bot account', - knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingBot, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonMisleadingImpersonation', - displayName: 'Impersonation', - description: 'Impersonating another person or entity', - knownType: - KnownReasonType.toolsOzoneReportDefsReasonMisleadingImpersonation, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonMisleadingSpam', - displayName: 'Spam', - description: 'Spam or repetitive content', - knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingSpam, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonMisleadingScam', - displayName: 'Scam', - description: 'Fraudulent or scam content', - knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingScam, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonMisleadingElections', - displayName: 'Election Misinformation', - description: 'False information about elections', - knownType: - KnownReasonType.toolsOzoneReportDefsReasonMisleadingElections, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonMisleadingOther', - displayName: 'Other Misleading', - description: 'Other misleading content', - knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingOther, - ), - ], - ReportCategory.ruleViolations: [ - const ReportReason( - value: 'tools.ozone.report.defs#reasonRuleSiteSecurity', - displayName: 'Site Security', - description: 'Violation of site security rules', - knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleSiteSecurity, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonRuleProhibitedSales', - displayName: 'Prohibited Sales', - description: 'Prohibited goods or services', - knownType: - KnownReasonType.toolsOzoneReportDefsReasonRuleProhibitedSales, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonRuleBanEvasion', - displayName: 'Ban Evasion', - description: 'Attempting to evade a ban', - knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleBanEvasion, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonRuleOther', - displayName: 'Other Rule Violation', - description: 'Other rule violations', - knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleOther, - ), - ], - ReportCategory.selfHarm: [ - const ReportReason( - value: 'tools.ozone.report.defs#reasonSelfHarmContent', - displayName: 'Self-Harm Content', - description: 'Content promoting self-harm', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmContent, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonSelfHarmED', - displayName: 'Eating Disorder', - description: 'Content promoting eating disorders', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmED, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonSelfHarmStunts', - displayName: 'Dangerous Stunts', - description: 'Content showing dangerous stunts', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmStunts, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonSelfHarmSubstances', - displayName: 'Substance Abuse', - description: 'Content promoting substance abuse', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmSubstances, - ), - const ReportReason( - value: 'tools.ozone.report.defs#reasonSelfHarmOther', - displayName: 'Other Self-Harm', - description: 'Other self-harm related content', - knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmOther, - ), - ], - ReportCategory.other: [ - const ReportReason( - value: 'tools.ozone.report.defs#reasonOther', - displayName: 'Other', - description: 'Other issues not listed above', - knownType: KnownReasonType.toolsOzoneReportDefsReasonOther, - ), - ], - }; - @override void dispose() { _additionalInfoController.dispose(); @@ -358,24 +55,30 @@ class _ReportDialogState extends ConsumerState { } void _selectReason(ReportReason reason) { - final servicesFuture = _loadCompatibleServices(reason); + final optionsFuture = ref + .read(moderationReportServiceProvider) + .loadOptions( + subject: widget.subject, + reasonType: reason.reasonType, + fallbackServiceDid: widget.fallbackServiceDid, + ); setState(() { _selectedReason = reason; - _compatibleServicesFuture = servicesFuture; + _reportOptionsFuture = optionsFuture; _selectedServiceDid = null; }); - unawaited(_selectDefaultService(servicesFuture, reason)); + unawaited(_selectDefaultService(optionsFuture, reason)); } Future _selectDefaultService( - Future> servicesFuture, + Future optionsFuture, ReportReason reason, ) async { try { - final services = await servicesFuture; - if (!mounted || _selectedReason != reason || services.isEmpty) return; + final options = await optionsFuture; + if (!mounted || _selectedReason != reason) return; setState(() { - _selectedServiceDid = services.first.did; + _selectedServiceDid = options.defaultServiceDid; }); } catch (error, stackTrace) { _logger.w( @@ -390,39 +93,11 @@ class _ReportDialogState extends ConsumerState { setState(() { _selectedCategory = null; _selectedReason = null; - _compatibleServicesFuture = null; + _reportOptionsFuture = null; _selectedServiceDid = null; }); } - ReasonType _reasonTypeFor(ReportReason reason) { - return reason.knownType != null - ? ReasonType.knownValue(data: reason.knownType!) - : ReasonType.unknown(data: reason.value); - } - - Future> _loadCompatibleServices( - ReportReason reason, - ) async { - final repository = GetIt.instance(); - final subject = widget.subject.data; - final isBskyRecord = - subject is RepoStrongRef && - subject.uri.collection.toString().startsWith('app.bsky'); - final fallbackProxyDid = - widget.fallbackServiceDid ?? - (isBskyRecord ? repository.bskyModDid : repository.modDid); - final fallbackDid = fallbackProxyDid.split('#').first; - return repository.labeler.getCompatibleModerationServices( - repository.labelerDids, - ModerationServiceQuery.forReport( - fallbackDid: fallbackDid, - subject: widget.subject, - reasonType: _reasonTypeFor(reason).toJson(), - ), - ); - } - Future _submitReport() async { if (_selectedReason == null || _selectedServiceDid == null) return; @@ -438,37 +113,17 @@ class _ReportDialogState extends ConsumerState { }); try { - // Build the ReasonType: use known type if available, otherwise unknown - // with the raw value - final reasonType = _reasonTypeFor(_selectedReason!); - - if (widget.onSubmit != null) { - await widget.onSubmit!( - subject, - reasonType, - reason, - _selectedServiceDid!, - ); - if (mounted) { - context.router.maybePop(); - } - } else { - // Get the repository directly and create the report - final repoRepository = GetIt.instance().repo; - _logger.d('Creating report with reason: ${_selectedReason!.value}'); - - final success = await repoRepository.createReport( - input: ModerationCreateReportInput( + await ref + .read(moderationReportServiceProvider) + .submit( subject: subject, - reasonType: reasonType, + reasonType: _selectedReason!.reasonType, reason: reason, - ), - serviceDid: _selectedServiceDid, - ); - - if (success && mounted) { - context.router.maybePop(); - } + serviceDid: _selectedServiceDid!, + submitter: widget.onSubmit, + ); + if (mounted) { + context.router.maybePop(); } } catch (e) { _logger.e('Error creating report', error: e); @@ -495,7 +150,7 @@ class _ReportDialogState extends ConsumerState { (theme.brightness == Brightness.dark ? Colors.white : Colors.black); final isStep2 = _selectedCategory != null; final reasons = isStep2 - ? (_categoryReasons[_selectedCategory!] ?? []) + ? (reportCategoryReasons[_selectedCategory!] ?? []) : []; return AlertDialog( @@ -564,8 +219,8 @@ class _ReportDialogState extends ConsumerState { if (isStep2 && _selectedReason != null) ...[ const SizedBox(height: 8), - FutureBuilder>( - future: _compatibleServicesFuture, + FutureBuilder( + future: _reportOptionsFuture, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center( @@ -578,7 +233,7 @@ class _ReportDialogState extends ConsumerState { if (snapshot.hasError) { return Text(l10n.moderationServiceLoadFailed); } - final services = snapshot.data ?? const []; + final services = snapshot.data?.services ?? const []; if (services.isEmpty) { return Text(l10n.moderationNoCompatibleService); } diff --git a/lib/src/features/comments/ui/widgets/comment_item.dart b/lib/src/features/comments/ui/widgets/comment_item.dart index 75a68ca0..52d65b76 100644 --- a/lib/src/features/comments/ui/widgets/comment_item.dart +++ b/lib/src/features/comments/ui/widgets/comment_item.dart @@ -11,7 +11,6 @@ import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/moderation/moderated_content.dart'; import 'package:spark/src/core/moderation/moderation.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; -import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; import 'package:spark/src/core/ui/widgets/image_content.dart'; @@ -105,7 +104,6 @@ class _CommentBodyState extends ConsumerState { } void _handleReportComment() { - final sprkRepository = GetIt.instance(); showDialog( context: context, builder: (context) => ReportDialog( @@ -115,20 +113,6 @@ class _CommentBodyState extends ConsumerState { cid: commentState.thread.post.cid, ), ), - onSubmit: (subject, reasonType, reason, serviceDid) async { - try { - await sprkRepository.repo.createReport( - input: ModerationCreateReportInput( - subject: subject, - reasonType: reasonType, - reason: reason, - ), - serviceDid: serviceDid, - ); - } catch (e) { - _logger.e('Error creating report', error: e); - } - }, ), ); } diff --git a/lib/src/features/profile/providers/profile_provider.dart b/lib/src/features/profile/providers/profile_provider.dart index 8e855166..0e392dfa 100644 --- a/lib/src/features/profile/providers/profile_provider.dart +++ b/lib/src/features/profile/providers/profile_provider.dart @@ -291,8 +291,8 @@ class ProfileNotifier extends _$ProfileNotifier { Future createReport({ required String did, required ReasonType reasonType, + required String serviceDid, String? reason, - String? serviceDid, }) async { if (!authRepository.isAuthenticated) { logger.w('Cannot create report, user not authenticated'); @@ -308,7 +308,7 @@ class ProfileNotifier extends _$ProfileNotifier { final subject = UModerationCreateReportSubject.repoRef( data: RepoRef(did: did), ); - final result = await sprkRepository.repo.createReport( + await sprkRepository.repo.createReport( input: ModerationCreateReportInput( subject: subject, reasonType: reasonType, @@ -317,7 +317,7 @@ class ProfileNotifier extends _$ProfileNotifier { serviceDid: serviceDid, ); logger.i('Report created successfully for $did'); - return result; + return true; } catch (e, s) { logger.e('Error creating report for $did', error: e, stackTrace: s); throw Exception('Failed to create report: $e'); diff --git a/lib/src/features/profile/ui/pages/profile_page.dart b/lib/src/features/profile/ui/pages/profile_page.dart index 775749f5..f1a47cf5 100644 --- a/lib/src/features/profile/ui/pages/profile_page.dart +++ b/lib/src/features/profile/ui/pages/profile_page.dart @@ -365,19 +365,14 @@ class _ProfilePageState extends ConsumerState { fallbackServiceDid: widget.bsky ? _sprkRepository.bskyModDid : _sprkRepository.modDid, - onSubmit: - (subject, reasonType, reason, serviceDid) async { - try { - await notifier.createReport( - did: profile.did, - reasonType: reasonType, - reason: reason, - serviceDid: serviceDid, - ); - } catch (e) { - _logger.e('Error creating report', error: e); - } - }, + onSubmit: (input, serviceDid) async { + await notifier.createReport( + did: profile.did, + reasonType: input.reasonType, + reason: input.reason, + serviceDid: serviceDid, + ); + }, ), ), onBlock: () async { diff --git a/lib/src/features/settings/providers/labeler_policy_preferences.dart b/lib/src/features/settings/providers/labeler_policy_preferences.dart index 77b578b3..6a72deaa 100644 --- a/lib/src/features/settings/providers/labeler_policy_preferences.dart +++ b/lib/src/features/settings/providers/labeler_policy_preferences.dart @@ -1,37 +1,22 @@ import 'package:spark/src/core/moderation/moderation.dart'; -import 'package:spark/src/core/network/atproto/data/models/labeler_models.dart'; final class LabelerPreferenceSnapshot { const LabelerPreferenceSnapshot({ - required this.preferences, + required this.settings, required this.definitions, }); - final Map preferences; + final Map settings; final Map definitions; } -LabelPreference labelPreferenceFromPolicy({ - required String value, - required Setting? savedSetting, - required Setting? globalSetting, +ModerationSetting moderationSettingFromPolicy({ + required ModerationSetting? savedSetting, + required ModerationSetting? globalSetting, required ModerationLabelDefinition? definition, }) { - final defaultSetting = definition == null - ? Setting.warn - : Setting.fromValue(definition.defaultSetting.name); - return LabelPreference( - value: value, - blurs: definition == null - ? Blurs.media - : Blurs.fromValue(definition.blurs.name), - severity: definition == null - ? Severity.alert - : Severity.fromValue(definition.severity.name), - defaultSetting: defaultSetting, - setting: globalAdultContentLabelValues.contains(value) - ? globalSetting ?? savedSetting ?? defaultSetting - : savedSetting ?? defaultSetting, - adultOnly: definition?.adultOnly ?? false, - ); + return globalSetting ?? + savedSetting ?? + definition?.defaultSetting ?? + ModerationSetting.warn; } diff --git a/lib/src/features/settings/providers/labeler_settings_controller.dart b/lib/src/features/settings/providers/labeler_settings_controller.dart index 33d4a2fe..d4e72edf 100644 --- a/lib/src/features/settings/providers/labeler_settings_controller.dart +++ b/lib/src/features/settings/providers/labeler_settings_controller.dart @@ -1,7 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.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/labeler_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; @@ -125,48 +124,44 @@ final class LabelerSettingsController { Future syncLabelers() async { resetSessionCache(); await _ref.read(userPreferencesProvider.notifier).refresh(); - 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 preferences = await _preferences(); final labelers = _normalizeLabelers( (preferences.labelers ?? const []).map((labeler) => labeler.did), ); - final unavailable = {}; - for (final did in labelers) { - if (did == _defaultDid) continue; - try { - await _repository.labeler.validateService(did); - } on LabelerServiceUnavailableException catch (error, stackTrace) { - unavailable.add(did); - _logger.w( - 'Removing unavailable labeler $did', - error: error, - stackTrace: stackTrace, - ); - } - } - 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)), - ), - ); - }); - } + final unavailable = (await Future.wait([ + for (final did in labelers) + if (did != _defaultDid) + () async { + try { + await _repository.labeler.validateService(did); + return null; + } on LabelerServiceUnavailableException catch (error, stackTrace) { + _logger.w( + 'Removing unavailable labeler $did', + error: error, + stackTrace: stackTrace, + ); + return did; + } + }(), + ])).nonNulls.toSet(); + + await _update((current) { + final currentLabelers = + current.labelers?.map((labeler) => labeler.did) ?? const []; + final available = _normalizeLabelers( + currentLabelers.where((did) => !unavailable.contains(did)), + ); + if (_sameLabelers(currentLabelers, available)) return current; + return _withLabelers(current, available); + }); _defaultEnsured = true; } - Future> getLabelSettings(String labelerDid) async { + Future> getLabelSettings( + String labelerDid, + ) async { final preferences = await _preferences(); return _savedSettings(preferences, labelerDid); } @@ -195,12 +190,13 @@ final class LabelerSettingsController { final global = _savedSettings(preferences, null); return LabelerPreferenceSnapshot( definitions: definitionMap, - preferences: { + settings: { for (final value in labelValues) - value: labelPreferenceFromPolicy( - value: value, + value: moderationSettingFromPolicy( savedSetting: saved[value], - globalSetting: global[value], + globalSetting: globalAdultContentLabelValues.contains(value) + ? global[value] + : null, definition: definitionMap[value], ), }, @@ -210,7 +206,7 @@ final class LabelerSettingsController { Future setLabelPreference( String labelerDid, String value, - Setting setting, + ModerationSetting setting, ) async { await _update((current) { final updated = []; @@ -244,17 +240,19 @@ final class LabelerSettingsController { }); } - Map _savedSettings( + Map _savedSettings( Preferences preferences, String? labelerDid, ) { - final result = {}; + final result = {}; for (final preference in preferences.contentLabelPrefs ?? const []) { if (preference.labelerDid == labelerDid) { - result[preference.label] = Setting.fromValue( - preference.visibility.toJson(), - ); + result[preference.label] = switch (preference.visibility.toJson()) { + 'hide' => ModerationSetting.hide, + 'warn' => ModerationSetting.warn, + _ => ModerationSetting.ignore, + }; } } return result; 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 1f0cd3ce..14a974e9 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 @@ -5,7 +5,6 @@ import 'package:get_it/get_it.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; import 'package:spark/src/core/l10n/app_localizations.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/actor_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; @@ -34,7 +33,7 @@ class _LabelerLabelSettingsPageState final SprkRepository _sprkRepository = GetIt.instance(); ProfileViewDetailed? _labelerProfile; - Map _labelPreferences = {}; + Map _labelSettings = {}; Map _labelDefinitions = {}; bool _isLoading = true; bool _isSaving = false; @@ -84,7 +83,7 @@ class _LabelerLabelSettingsPageState if (!mounted) return; setState(() { - _labelPreferences = snapshot.preferences; + _labelSettings = snapshot.settings; _labelDefinitions = snapshot.definitions; _isLoading = false; }); @@ -98,22 +97,22 @@ class _LabelerLabelSettingsPageState } } - Future _updateLabelPreference(String label, {Setting? setting}) async { + Future _updateLabelPreference( + String label, + ModerationSetting setting, + ) async { if (_isSaving) return; setState(() => _isSaving = true); try { - final currentPref = _labelPreferences[label]; - if (currentPref != null) { - final newSetting = setting ?? currentPref.setting; - + if (_labelSettings.containsKey(label)) { await ref .read(labelerSettingsControllerProvider) - .setLabelPreference(widget.did, label, newSetting); + .setLabelPreference(widget.did, label, setting); if (!mounted) return; setState(() { - _labelPreferences[label] = currentPref.copyWith(setting: newSetting); + _labelSettings[label] = setting; }); } } catch (e) { @@ -308,7 +307,7 @@ class _LabelerLabelSettingsPageState ), // Label preferences - if (_labelPreferences.isEmpty) + if (_labelSettings.isEmpty) Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Padding( @@ -342,7 +341,7 @@ class _LabelerLabelSettingsPageState ), ) else - ..._labelPreferences.entries + ..._labelSettings.entries .where((entry) => !entry.key.startsWith('!')) .map((entry) { final definition = _labelDefinitions[entry.key]; @@ -358,16 +357,13 @@ class _LabelerLabelSettingsPageState return LabelSettingTile( key: Key('labeler-label-${entry.key}'), label: entry.key, - controlContext: entry.value.severity == Severity.inform + controlContext: + definition?.severity == ModerationSeverity.inform ? LabelSettingTileContext.informLabel : LabelSettingTileContext.label, - setting: ModerationSetting.values.byName( - entry.value.setting.name, - ), - onChanged: (setting) => _updateLabelPreference( - entry.key, - setting: Setting.values.byName(setting.name), - ), + setting: entry.value, + onChanged: (setting) => + _updateLabelPreference(entry.key, setting), labelName: strings?.name, labelDescription: strings?.description, disabledMessage: configuredGlobally @@ -376,7 +372,8 @@ class _LabelerLabelSettingsPageState enabled: !_isSaving && !configuredGlobally && - (!entry.value.adultOnly || adultContentEnabled), + (!(definition?.adultOnly ?? false) || + adultContentEnabled), ); }), diff --git a/test/src/core/providers/preferences_provider_test.dart b/test/src/core/providers/preferences_provider_test.dart index 5be0f004..54425fa8 100644 --- a/test/src/core/providers/preferences_provider_test.dart +++ b/test/src/core/providers/preferences_provider_test.dart @@ -7,7 +7,6 @@ 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'; @@ -275,7 +274,11 @@ void main() { await pumpEventQueue(); final labelUpdate = container .read(labelerSettingsControllerProvider) - .setLabelPreference('did:plc:labeler', 'custom', Setting.hide); + .setLabelPreference( + 'did:plc:labeler', + 'custom', + ModerationSetting.hide, + ); await pumpEventQueue(); expect(prefRepository.putCalls, hasLength(1)); diff --git a/test/src/core/ui/widgets/report_dialog_test.dart b/test/src/core/ui/widgets/report_dialog_test.dart index 4707dd7d..4faaba9f 100644 --- a/test/src/core/ui/widgets/report_dialog_test.dart +++ b/test/src/core/ui/widgets/report_dialog_test.dart @@ -169,7 +169,7 @@ void main() { data: RepoRef(did: 'did:plc:account'), ), fallbackServiceDid: 'did:plc:bsky#atproto_labeler', - onSubmit: (_, _, _, _) async { + onSubmit: (_, _) async { throw StateError('private transport detail'); }, ), 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 90a24415..9e9b107f 100644 --- a/test/src/features/settings/providers/labeler_settings_controller_test.dart +++ b/test/src/features/settings/providers/labeler_settings_controller_test.dart @@ -85,6 +85,31 @@ void main() { expect(preferencesController.writes, isEmpty); expect(labelerRepository.detailedServiceCalls, isEmpty); }); + + test( + 'sync validates concurrently and persists one final subscription list', + () async { + preferencesController.current = _preferencesWithLabelers([ + 'did:plc:available', + 'did:plc:unavailable', + ]); + labelerRepository.unavailableDids.add('did:plc:unavailable'); + final controller = container.read(labelerSettingsControllerProvider); + + await controller.syncLabelers(); + + expect( + labelerRepository.validationCalls, + unorderedEquals(['did:plc:available', 'did:plc:unavailable']), + ); + expect(labelerRepository.maxConcurrentValidations, 2); + expect(preferencesController.writes, hasLength(1)); + expect( + preferencesController.current.labelers?.map((item) => item.did), + ['did:plc:mod', 'did:plc:available'], + ); + }, + ); }); } @@ -159,6 +184,10 @@ class _FakeSprkRepository implements SprkRepository { class _FakeLabelerRepository implements LabelerRepository { final List> detailedServiceCalls = []; + final Set unavailableDids = {}; + final List validationCalls = []; + int activeValidations = 0; + int maxConcurrentValidations = 0; @override Future resolveIdentifier(String identifier) async { @@ -166,7 +195,21 @@ class _FakeLabelerRepository implements LabelerRepository { } @override - Future validateService(String did) async {} + Future validateService(String did) async { + validationCalls.add(did); + activeValidations++; + if (activeValidations > maxConcurrentValidations) { + maxConcurrentValidations = activeValidations; + } + try { + await Future.delayed(const Duration(milliseconds: 1)); + if (unavailableDids.contains(did)) { + throw const LabelerServiceUnavailableException('Unavailable labeler'); + } + } finally { + activeValidations--; + } + } @override Future getServicesDetailed(List dids) async { diff --git a/test/src/features/settings/providers/settings_provider_test.dart b/test/src/features/settings/providers/settings_provider_test.dart index 7b31a0e5..f4099c2e 100644 --- a/test/src/features/settings/providers/settings_provider_test.dart +++ b/test/src/features/settings/providers/settings_provider_test.dart @@ -7,8 +7,8 @@ import 'package:poptart/poptart.dart'; import 'package:shared_preferences/shared_preferences.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/feed_models.dart'; -import 'package:spark/src/core/network/atproto/data/models/models.dart'; import 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/feed_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/labeler_repository.dart'; @@ -351,7 +351,11 @@ void main() { await loadSettings(container); final labelers = container.read(labelerSettingsControllerProvider); - await labelers.setLabelPreference('did:plc:a', 'custom', Setting.hide); + await labelers.setLabelPreference( + 'did:plc:a', + 'custom', + ModerationSetting.hide, + ); final written = preferencesController.writes.single.contentLabelPrefs!; expect(