From bccfec479278933dd9990a5cc9510da085297e71 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Fri, 14 Aug 2026 15:43:43 -0400 Subject: [PATCH] feat: implement source-aware label moderation --- .../atoms/surfaces/app_surface.dart | 47 ++ .../molecules/app_choice_group.dart | 116 +++ .../templates/chat_list_page_template.dart | 15 +- .../templates/image_review_page_template.dart | 6 + .../templates/video_review_page_template.dart | 6 + lib/src/core/l10n/app_localizations.dart | 234 +++++++ lib/src/core/l10n/app_localizations_en.dart | 126 ++++ lib/src/core/l10n/intl_en.arb | 158 +++++ .../core/moderation/moderated_content.dart | 528 ++++++++++++++ lib/src/core/moderation/moderation.dart | 4 + .../moderation/moderation_definitions.dart | 338 +++++++++ .../core/moderation/moderation_engine.dart | 248 +++++++ .../moderation_label_localizations.dart | 45 ++ .../core/moderation/moderation_models.dart | 271 +++++++ .../core/moderation/moderation_provider.dart | 50 ++ .../data/adapters/bsky/actor_adapter.dart | 12 +- .../atproto/data/models/feed_models.dart | 5 + .../atproto/data/models/pref_models.dart | 24 +- .../atproto/data/models/record_models.dart | 2 + .../repositories/actor_repository_impl.dart | 11 +- .../data/repositories/feed_repository.dart | 2 + .../repositories/feed_repository_impl.dart | 83 ++- .../repositories/graph_repository_impl.dart | 8 +- .../data/repositories/labeler_repository.dart | 15 + .../repositories/labeler_repository_impl.dart | 65 +- .../notification_repository_impl.dart | 4 +- .../data/repositories/repo_repository.dart | 1 + .../repositories/repo_repository_impl.dart | 80 ++- .../repositories/sound_repository_impl.dart | 6 +- .../data/repositories/sprk_repository.dart | 12 + .../repositories/sprk_repository_impl.dart | 19 + .../repositories/story_repository_impl.dart | 4 +- .../services/appview_labeler_headers.dart | 65 ++ lib/src/core/routing/app_router.dart | 1 + lib/src/core/routing/pages.dart | 1 + .../preferences/default_preferences.dart | 45 -- .../ui/widgets/content_warning_overlay.dart | 97 --- lib/src/core/ui/widgets/report_dialog.dart | 327 +++++++-- lib/src/core/ui/widgets/widgets.dart | 1 - lib/src/core/utils/label_utils.dart | 173 ----- .../comments/ui/widgets/comment_item.dart | 38 +- .../feed/providers/feed_provider.dart | 165 +++-- .../visible_pinned_feeds_provider.dart | 50 ++ .../features/feed/ui/pages/feeds_page.dart | 26 +- .../feed/ui/pages/standalone_post_page.dart | 74 +- .../feed/ui/widgets/feed/feeds_bar.dart | 35 +- .../ui/widgets/post/feed_post_widget.dart | 94 +-- .../feed/ui/widgets/post/post_overlay.dart | 34 +- .../messages/ui/pages/messages_page.dart | 12 + .../ui/pages/new_chat_search_page.dart | 28 +- .../messages/ui/widgets/messages_list.dart | 32 +- .../ui/widgets/notification_item.dart | 265 +++---- .../models/content_warning_selection.dart | 28 + .../providers/video_upload_provider.dart | 29 +- .../posting/ui/pages/image_review_page.dart | 9 + .../posting/ui/pages/story_post_page.dart | 13 + .../posting/ui/pages/video_review_page.dart | 8 + .../ui/widgets/content_warning_selector.dart | 140 ++++ .../posting/utils/story_direct_post.dart | 9 + .../profile/providers/profile_provider.dart | 2 + .../profile/ui/pages/profile_page.dart | 365 +++++----- .../profile/ui/widgets/blocks_list_view.dart | 48 +- .../ui/widgets/profile_feed_post_widget.dart | 86 +-- .../ui/widgets/profile_grid_widget.dart | 65 +- .../profile/ui/widgets/user_list_view.dart | 77 +- .../repositories/post_search_repository.dart | 42 +- .../providers/post_search_provider.dart | 57 +- .../search/ui/pages/post_results.dart | 42 +- .../features/search/ui/pages/search_page.dart | 28 +- .../search/ui/pages/user_results.dart | 94 +-- .../ui/widgets/suggested_feeds_list.dart | 121 ++-- .../providers/preferences_provider.dart | 42 ++ .../settings/providers/settings_provider.dart | 343 ++++----- .../ui/pages/labeler_label_settings_page.dart | 202 +++--- .../ui/pages/labeler_management_page.dart | 20 +- .../settings/ui/pages/moderation_page.dart | 195 ++++++ .../settings/ui/pages/settings_page.dart | 7 +- .../ui/widgets/label_setting_tile.dart | 173 ++--- .../ui/widgets/settings_feed_card.dart | 14 +- .../stories/ui/pages/author_stories_page.dart | 1 + .../features/stories/ui/pages/story_page.dart | 196 ++++-- .../stories/ui/widgets/stories_list.dart | 35 +- .../components/app_choice_group_test.dart | 129 ++++ .../components/app_surface_test.dart | 35 + .../moderation/moderated_content_test.dart | 389 +++++++++++ .../moderation_definitions_test.dart | 383 ++++++++++ .../moderation/moderation_engine_test.dart | 659 ++++++++++++++++++ .../moderation_preferences_test.dart | 215 ++++++ .../moderation/moderation_provider_test.dart | 94 +++ .../actor_repository_impl_test.dart | 8 + .../feed_repository_requests_test.dart | 81 ++- .../graph_repository_impl_test.dart | 4 + .../labeler_repository_impl_test.dart | 62 ++ .../notification_repository_impl_test.dart | 4 + .../pref_repository_impl_test.dart | 20 + .../repositories/repository_test_support.dart | 21 + .../sound_repository_impl_test.dart | 4 + .../story_repository_impl_test.dart | 8 + .../appview_labeler_headers_test.dart | 43 ++ .../core/ui/widgets/report_dialog_test.dart | 171 +++++ test/src/core/utils/label_utils_test.dart | 247 ------- .../feed/providers/feed_provider_test.dart | 202 +++++- .../visible_pinned_feeds_provider_test.dart | 160 +++++ .../providers/video_upload_provider_test.dart | 16 + .../content_warning_selector_test.dart | 74 ++ .../post_search_repository_test.dart | 41 ++ .../providers/post_search_provider_test.dart | 64 +- .../providers/preferences_provider_test.dart | 91 +++ .../providers/settings_provider_test.dart | 274 ++++++-- .../labeler_label_settings_page_test.dart | 186 +++++ .../ui/pages/moderation_page_test.dart | 166 +++++ .../settings/ui/pages/settings_page_test.dart | 23 + .../ui/widgets/label_setting_tile_test.dart | 120 +++- .../stories/ui/pages/story_page_test.dart | 94 +++ 114 files changed, 8386 insertions(+), 2301 deletions(-) create mode 100644 lib/src/core/design_system/components/atoms/surfaces/app_surface.dart create mode 100644 lib/src/core/design_system/components/molecules/app_choice_group.dart create mode 100644 lib/src/core/moderation/moderated_content.dart create mode 100644 lib/src/core/moderation/moderation.dart create mode 100644 lib/src/core/moderation/moderation_definitions.dart create mode 100644 lib/src/core/moderation/moderation_engine.dart create mode 100644 lib/src/core/moderation/moderation_label_localizations.dart create mode 100644 lib/src/core/moderation/moderation_models.dart create mode 100644 lib/src/core/moderation/moderation_provider.dart create mode 100644 lib/src/core/network/atproto/data/services/appview_labeler_headers.dart delete mode 100644 lib/src/core/ui/widgets/content_warning_overlay.dart delete mode 100644 lib/src/core/utils/label_utils.dart create mode 100644 lib/src/features/feed/providers/visible_pinned_feeds_provider.dart create mode 100644 lib/src/features/posting/models/content_warning_selection.dart create mode 100644 lib/src/features/posting/ui/widgets/content_warning_selector.dart create mode 100644 lib/src/features/settings/ui/pages/moderation_page.dart create mode 100644 test/src/core/design_system/components/app_choice_group_test.dart create mode 100644 test/src/core/design_system/components/app_surface_test.dart create mode 100644 test/src/core/moderation/moderated_content_test.dart create mode 100644 test/src/core/moderation/moderation_definitions_test.dart create mode 100644 test/src/core/moderation/moderation_engine_test.dart create mode 100644 test/src/core/moderation/moderation_preferences_test.dart create mode 100644 test/src/core/moderation/moderation_provider_test.dart create mode 100644 test/src/core/network/atproto/data/services/appview_labeler_headers_test.dart create mode 100644 test/src/core/ui/widgets/report_dialog_test.dart delete mode 100644 test/src/core/utils/label_utils_test.dart create mode 100644 test/src/features/feed/providers/visible_pinned_feeds_provider_test.dart create mode 100644 test/src/features/posting/ui/widgets/content_warning_selector_test.dart create mode 100644 test/src/features/search/data/repositories/post_search_repository_test.dart create mode 100644 test/src/features/settings/ui/pages/labeler_label_settings_page_test.dart create mode 100644 test/src/features/settings/ui/pages/moderation_page_test.dart create mode 100644 test/src/features/settings/ui/pages/settings_page_test.dart create mode 100644 test/src/features/stories/ui/pages/story_page_test.dart diff --git a/lib/src/core/design_system/components/atoms/surfaces/app_surface.dart b/lib/src/core/design_system/components/atoms/surfaces/app_surface.dart new file mode 100644 index 00000000..d59924c2 --- /dev/null +++ b/lib/src/core/design_system/components/atoms/surfaces/app_surface.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/tokens/shadows.dart'; + +enum AppSurfaceVariant { outlined, raised } + +class AppSurface extends StatelessWidget { + const AppSurface({ + required this.child, + super.key, + this.variant = AppSurfaceVariant.outlined, + this.margin = EdgeInsets.zero, + this.padding = EdgeInsets.zero, + }); + + final Widget child; + final AppSurfaceVariant variant; + final EdgeInsetsGeometry margin; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final borderRadius = BorderRadius.circular(16); + + return Padding( + padding: margin, + child: DecoratedBox( + decoration: BoxDecoration( + color: isDark + ? colorScheme.surfaceContainerHighest + : colorScheme.surface, + borderRadius: borderRadius, + border: Border.all(color: colorScheme.outline), + boxShadow: variant == AppSurfaceVariant.raised + ? const [AppShadows.shadowXs] + : null, + ), + child: ClipRRect( + borderRadius: borderRadius, + child: Padding(padding: padding, child: child), + ), + ), + ); + } +} diff --git a/lib/src/core/design_system/components/molecules/app_choice_group.dart b/lib/src/core/design_system/components/molecules/app_choice_group.dart new file mode 100644 index 00000000..52bc2a95 --- /dev/null +++ b/lib/src/core/design_system/components/molecules/app_choice_group.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; +import 'package:spark/src/core/design_system/tokens/typography.dart'; + +@immutable +class AppChoiceOption { + const AppChoiceOption({ + required this.value, + required this.label, + this.enabled = true, + }); + + final T value; + final String label; + final bool enabled; +} + +class AppChoiceGroup extends StatelessWidget { + const AppChoiceGroup({ + required this.value, + required this.options, + required this.onChanged, + super.key, + this.enabled = true, + }) : assert(options.length > 1); + + final T value; + final List> options; + final ValueChanged onChanged; + final bool enabled; + + @override + Widget build(BuildContext context) { + return Row( + spacing: 6, + children: [ + for (final option in options) + Expanded( + child: _ChoiceButton( + option: option, + isSelected: option.value == value, + enabled: enabled && option.enabled, + onSelected: onChanged, + ), + ), + ], + ); + } +} + +class _ChoiceButton extends StatelessWidget { + const _ChoiceButton({ + required this.option, + required this.isSelected, + required this.enabled, + required this.onSelected, + }); + + final AppChoiceOption option; + final bool isSelected; + final bool enabled; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final onTap = enabled ? () => onSelected(option.value) : null; + final backgroundColor = enabled + ? isSelected + ? colorScheme.primary + : colorScheme.surface + : colorScheme.onSurface.withValues(alpha: 0.12); + final foregroundColor = enabled + ? isSelected + ? colorScheme.onPrimary + : colorScheme.onSurface + : colorScheme.onSurface.withValues(alpha: 0.38); + final borderRadius = BorderRadius.circular(12); + + return Semantics( + label: option.label, + button: true, + enabled: enabled, + selected: isSelected, + inMutuallyExclusiveGroup: true, + onTap: onTap, + child: ExcludeSemantics( + child: InteractivePressable( + onTap: onTap, + borderRadius: borderRadius, + child: Material( + color: backgroundColor, + elevation: isSelected && enabled ? 2 : 0, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + side: BorderSide(color: colorScheme.outline, width: 0.5), + ), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 40), + child: Align( + child: Text( + option.label, + overflow: TextOverflow.ellipsis, + style: AppTypography.textExtraSmallBold.copyWith( + color: foregroundColor, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/core/design_system/templates/chat_list_page_template.dart b/lib/src/core/design_system/templates/chat_list_page_template.dart index 8faf9a03..b5a03b0b 100644 --- a/lib/src/core/design_system/templates/chat_list_page_template.dart +++ b/lib/src/core/design_system/templates/chat_list_page_template.dart @@ -34,6 +34,7 @@ class ChatListPageTemplate extends StatelessWidget { this.loadingItemCount = 8, this.onAddTap, this.onRefresh, + this.itemWrapper, }); const ChatListPageTemplate.loading({ @@ -42,6 +43,7 @@ class ChatListPageTemplate extends StatelessWidget { this.loadingItemCount = 8, this.onAddTap, this.onRefresh, + this.itemWrapper, }) : items = const [], onItemTap = _noopItemTap, loading = true; @@ -53,6 +55,8 @@ class ChatListPageTemplate extends StatelessWidget { final int loadingItemCount; final VoidCallback? onAddTap; final Future Function()? onRefresh; + final Widget Function(BuildContext context, int index, Widget child)? + itemWrapper; @override Widget build(BuildContext context) { @@ -86,10 +90,13 @@ class ChatListPageTemplate extends StatelessWidget { padding: EdgeInsets.zero, itemCount: items.length, separatorBuilder: (_, _) => const SizedBox.shrink(), - itemBuilder: (context, index) => _ChatTile( - data: items[index], - onTap: () => onItemTap(index), - ), + itemBuilder: (context, index) { + final tile = _ChatTile( + data: items[index], + onTap: () => onItemTap(index), + ); + return itemWrapper?.call(context, index, tile) ?? tile; + }, ), ), ), diff --git a/lib/src/core/design_system/templates/image_review_page_template.dart b/lib/src/core/design_system/templates/image_review_page_template.dart index a5de199f..fd9a8bd1 100644 --- a/lib/src/core/design_system/templates/image_review_page_template.dart +++ b/lib/src/core/design_system/templates/image_review_page_template.dart @@ -46,6 +46,7 @@ class ImageReviewPageTemplate extends StatelessWidget { this.mentionController, this.onMentionsChanged, this.showCrossPostWarning = false, + this.contentWarningSection, this.backgroundColor, this.isOverLimit = false, }); @@ -70,6 +71,7 @@ class ImageReviewPageTemplate extends StatelessWidget { final bool crossPostValue; final ValueChanged onCrossPostChanged; final bool showCrossPostWarning; + final Widget? contentWarningSection; final String? selectedSoundTitle; final String? selectedSoundSubtitle; final VoidCallback? onAddSound; @@ -148,6 +150,10 @@ class ImageReviewPageTemplate extends StatelessWidget { onMentionsChanged: onMentionsChanged, maxChars: descriptionMaxChars, ), + if (contentWarningSection != null) ...[ + const SizedBox(height: 20), + contentWarningSection!, + ], const SizedBox(height: 20), _CrossPostSection( value: crossPostValue, diff --git a/lib/src/core/design_system/templates/video_review_page_template.dart b/lib/src/core/design_system/templates/video_review_page_template.dart index f66f3ddb..153e50b6 100644 --- a/lib/src/core/design_system/templates/video_review_page_template.dart +++ b/lib/src/core/design_system/templates/video_review_page_template.dart @@ -37,6 +37,7 @@ class VideoReviewPageTemplate extends StatelessWidget { this.uploadIndeterminate = false, this.hasUploadError = false, this.onUploadRetry, + this.contentWarningSection, super.key, }); @@ -63,6 +64,7 @@ class VideoReviewPageTemplate extends StatelessWidget { final bool uploadIndeterminate; final bool hasUploadError; final VoidCallback? onUploadRetry; + final Widget? contentWarningSection; @override Widget build(BuildContext context) { @@ -116,6 +118,10 @@ class VideoReviewPageTemplate extends StatelessWidget { onMentionsChanged: onMentionsChanged, maxChars: descriptionMaxChars, ), + if (contentWarningSection != null) ...[ + const SizedBox(height: 20), + contentWarningSection!, + ], if (showCrossPost) ...[ const SizedBox(height: 20), _CrossPostSection( diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index d29b8bf6..073cd88e 100644 --- a/lib/src/core/l10n/app_localizations.dart +++ b/lib/src/core/l10n/app_localizations.dart @@ -274,6 +274,12 @@ abstract class AppLocalizations { /// **'Settings'** String get pageTitleSettings; + /// Moderation settings page title + /// + /// In en, this message translates to: + /// **'Moderation'** + String get pageTitleModeration; + /// Story manager page title /// /// In en, this message translates to: @@ -1730,6 +1736,234 @@ abstract class AppLocalizations { int count, ); + /// Heading for author-applied media content warnings + /// + /// In en, this message translates to: + /// **'Content warnings'** + String get labelContentWarnings; + + /// Explanation shown above author self-label controls + /// + /// In en, this message translates to: + /// **'Add any warnings that apply to the media you are posting.'** + String get messageContentWarningsDescription; + + /// Heading for mutually exclusive adult-content warnings + /// + /// In en, this message translates to: + /// **'Adult Content'** + String get labelAdultContent; + + /// Heading over content concealed by moderation + /// + /// In en, this message translates to: + /// **'Content warning'** + String get moderationContentWarning; + + /// Generic fallback for a labeler notice with no localized label strings + /// + /// In en, this message translates to: + /// **'Content notice'** + String get moderationContentNotice; + + /// Button that reveals moderated content + /// + /// In en, this message translates to: + /// **'View content'** + String get buttonViewContent; + + /// Button that opens moderation label details + /// + /// In en, this message translates to: + /// **'Why am I seeing this?'** + String get buttonModerationDetails; + + /// Message for an imperative or age-gated moderation decision + /// + /// In en, this message translates to: + /// **'This restriction cannot be overridden.'** + String get moderationCannotOverride; + + /// Title of the moderation details sheet + /// + /// In en, this message translates to: + /// **'Moderation details'** + String get moderationDetailsTitle; + + /// Label before the DID of the labeler that applied a label + /// + /// In en, this message translates to: + /// **'Applied by'** + String get moderationAppliedBy; + + /// Label before a moderation label expiration timestamp + /// + /// In en, this message translates to: + /// **'Expires'** + String get moderationExpires; + + /// Button for appealing a moderation label applied to the current user + /// + /// In en, this message translates to: + /// **'Appeal label'** + String get buttonAppealLabel; + + /// Default text sent with a label appeal + /// + /// In en, this message translates to: + /// **'I am appealing this label.'** + String get moderationAppealReason; + + /// Confirmation after a moderation label appeal succeeds + /// + /// In en, this message translates to: + /// **'Appeal sent'** + String get moderationAppealSent; + + /// Error after a moderation label appeal fails + /// + /// In en, this message translates to: + /// **'Could not send the appeal'** + String get moderationAppealFailed; + + /// Settings toggle for allowing configurable adult-only labels + /// + /// In en, this message translates to: + /// **'Show adult content'** + String get settingAdultContent; + + /// Explanation for the adult content setting + /// + /// In en, this message translates to: + /// **'When off, adult-only labels are hidden and cannot be overridden.'** + String get settingAdultContentDescription; + + /// Moderation preference that shows labeled content + /// + /// In en, this message translates to: + /// **'Show'** + String get labelShow; + + /// Moderation preference that turns off treatment for a label + /// + /// In en, this message translates to: + /// **'Off'** + String get labelOff; + + /// Moderation preference that warns before showing labeled content + /// + /// In en, this message translates to: + /// **'Warn'** + String get labelWarn; + + /// Moderation preference that shows an informational badge on labeled content + /// + /// In en, this message translates to: + /// **'Badge'** + String get labelBadge; + + /// Moderation preference that hides labeled content + /// + /// In en, this message translates to: + /// **'Hide'** + String get labelHide; + + /// Explanation for a global label shown on a labeler settings page + /// + /// In en, this message translates to: + /// **'Configured in Moderation settings.'** + String get moderationConfiguredGlobally; + + /// Name of the built-in porn moderation label + /// + /// In en, this message translates to: + /// **'Adult Content'** + String get moderationLabelPornName; + + /// Description of the built-in porn moderation label + /// + /// In en, this message translates to: + /// **'Explicit sexual images.'** + String get moderationLabelPornDescription; + + /// Name of the built-in sexually suggestive moderation label + /// + /// In en, this message translates to: + /// **'Sexually Suggestive'** + String get moderationLabelSexualName; + + /// Description of the built-in sexually suggestive moderation label + /// + /// In en, this message translates to: + /// **'Does not include nudity.'** + String get moderationLabelSexualDescription; + + /// Name of the built-in non-sexual nudity moderation label + /// + /// In en, this message translates to: + /// **'Non-sexual Nudity'** + String get moderationLabelNudityName; + + /// Description of the built-in non-sexual nudity moderation label + /// + /// In en, this message translates to: + /// **'For example, artistic nudes.'** + String get moderationLabelNudityDescription; + + /// Name of the built-in graphic media moderation label + /// + /// In en, this message translates to: + /// **'Graphic Media'** + String get moderationLabelGraphicMediaName; + + /// Description of the built-in graphic media moderation label + /// + /// In en, this message translates to: + /// **'Explicit or potentially disturbing media.'** + String get moderationLabelGraphicMediaDescription; + + /// Name of the built-in gore moderation label + /// + /// In en, this message translates to: + /// **'Gore'** + String get moderationLabelGoreName; + + /// Description of the built-in gore moderation label + /// + /// In en, this message translates to: + /// **'Graphic depictions of severe injury, blood, or death.'** + String get moderationLabelGoreDescription; + + /// Error shown when a labeler cannot be resolved, validated, or saved + /// + /// In en, this message translates to: + /// **'Could not add that labeler. Check the DID or handle and try again.'** + String get errorAddingLabeler; + + /// Label for choosing the service that receives a moderation report + /// + /// In en, this message translates to: + /// **'Moderation service'** + String get moderationService; + + /// Fallback name for the app's default moderation service + /// + /// In en, this message translates to: + /// **'Default moderation service'** + String get moderationDefaultService; + + /// Message when no subscribed moderation service supports a report + /// + /// In en, this message translates to: + /// **'None of your moderation services accepts this type of report.'** + String get moderationNoCompatibleService; + + /// Error shown when compatible moderation services cannot be loaded + /// + /// In en, this message translates to: + /// **'Could not load moderation services.'** + String get moderationServiceLoadFailed; + /// Send button text /// /// In en, this message translates to: diff --git a/lib/src/core/l10n/app_localizations_en.dart b/lib/src/core/l10n/app_localizations_en.dart index e305c7b0..b1f969d7 100644 --- a/lib/src/core/l10n/app_localizations_en.dart +++ b/lib/src/core/l10n/app_localizations_en.dart @@ -98,6 +98,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pageTitleSettings => 'Settings'; + @override + String get pageTitleModeration => 'Moderation'; + @override String get pageTitleStoryManager => 'Story Manager'; @@ -964,6 +967,129 @@ class AppLocalizationsEn extends AppLocalizations { return 'Followed by $firstName, $secondName, and $_temp0'; } + @override + String get labelContentWarnings => 'Content warnings'; + + @override + String get messageContentWarningsDescription => + 'Add any warnings that apply to the media you are posting.'; + + @override + String get labelAdultContent => 'Adult Content'; + + @override + String get moderationContentWarning => 'Content warning'; + + @override + String get moderationContentNotice => 'Content notice'; + + @override + String get buttonViewContent => 'View content'; + + @override + String get buttonModerationDetails => 'Why am I seeing this?'; + + @override + String get moderationCannotOverride => + 'This restriction cannot be overridden.'; + + @override + String get moderationDetailsTitle => 'Moderation details'; + + @override + String get moderationAppliedBy => 'Applied by'; + + @override + String get moderationExpires => 'Expires'; + + @override + String get buttonAppealLabel => 'Appeal label'; + + @override + String get moderationAppealReason => 'I am appealing this label.'; + + @override + String get moderationAppealSent => 'Appeal sent'; + + @override + String get moderationAppealFailed => 'Could not send the appeal'; + + @override + String get settingAdultContent => 'Show adult content'; + + @override + String get settingAdultContentDescription => + 'When off, adult-only labels are hidden and cannot be overridden.'; + + @override + String get labelShow => 'Show'; + + @override + String get labelOff => 'Off'; + + @override + String get labelWarn => 'Warn'; + + @override + String get labelBadge => 'Badge'; + + @override + String get labelHide => 'Hide'; + + @override + String get moderationConfiguredGlobally => + 'Configured in Moderation settings.'; + + @override + String get moderationLabelPornName => 'Adult Content'; + + @override + String get moderationLabelPornDescription => 'Explicit sexual images.'; + + @override + String get moderationLabelSexualName => 'Sexually Suggestive'; + + @override + String get moderationLabelSexualDescription => 'Does not include nudity.'; + + @override + String get moderationLabelNudityName => 'Non-sexual Nudity'; + + @override + String get moderationLabelNudityDescription => 'For example, artistic nudes.'; + + @override + String get moderationLabelGraphicMediaName => 'Graphic Media'; + + @override + String get moderationLabelGraphicMediaDescription => + 'Explicit or potentially disturbing media.'; + + @override + String get moderationLabelGoreName => 'Gore'; + + @override + String get moderationLabelGoreDescription => + 'Graphic depictions of severe injury, blood, or death.'; + + @override + String get errorAddingLabeler => + 'Could not add that labeler. Check the DID or handle and try again.'; + + @override + String get moderationService => 'Moderation service'; + + @override + String get moderationDefaultService => 'Default moderation service'; + + @override + String get moderationNoCompatibleService => + 'None of your moderation services accepts this type of report.'; + + @override + String get moderationServiceLoadFailed => + 'Could not load moderation services.'; + @override String get buttonSend => 'Send'; } diff --git a/lib/src/core/l10n/intl_en.arb b/lib/src/core/l10n/intl_en.arb index 4e955acc..9aeff253 100644 --- a/lib/src/core/l10n/intl_en.arb +++ b/lib/src/core/l10n/intl_en.arb @@ -152,6 +152,11 @@ "description": "Settings page title" }, + "pageTitleModeration": "Moderation", + "@pageTitleModeration": { + "description": "Moderation settings page title" + }, + "pageTitleStoryManager": "Story Manager", "@pageTitleStoryManager": { "description": "Story manager page title" @@ -1503,6 +1508,159 @@ } }, + "labelContentWarnings": "Content warnings", + "@labelContentWarnings": { + "description": "Heading for author-applied media content warnings" + }, + "messageContentWarningsDescription": "Add any warnings that apply to the media you are posting.", + "@messageContentWarningsDescription": { + "description": "Explanation shown above author self-label controls" + }, + "labelAdultContent": "Adult Content", + "@labelAdultContent": { + "description": "Heading for mutually exclusive adult-content warnings" + }, + "moderationContentWarning": "Content warning", + "@moderationContentWarning": { + "description": "Heading over content concealed by moderation" + }, + "moderationContentNotice": "Content notice", + "@moderationContentNotice": { + "description": "Generic fallback for a labeler notice with no localized label strings" + }, + "buttonViewContent": "View content", + "@buttonViewContent": { + "description": "Button that reveals moderated content" + }, + "buttonModerationDetails": "Why am I seeing this?", + "@buttonModerationDetails": { + "description": "Button that opens moderation label details" + }, + "moderationCannotOverride": "This restriction cannot be overridden.", + "@moderationCannotOverride": { + "description": "Message for an imperative or age-gated moderation decision" + }, + "moderationDetailsTitle": "Moderation details", + "@moderationDetailsTitle": { + "description": "Title of the moderation details sheet" + }, + "moderationAppliedBy": "Applied by", + "@moderationAppliedBy": { + "description": "Label before the DID of the labeler that applied a label" + }, + "moderationExpires": "Expires", + "@moderationExpires": { + "description": "Label before a moderation label expiration timestamp" + }, + "buttonAppealLabel": "Appeal label", + "@buttonAppealLabel": { + "description": "Button for appealing a moderation label applied to the current user" + }, + "moderationAppealReason": "I am appealing this label.", + "@moderationAppealReason": { + "description": "Default text sent with a label appeal" + }, + "moderationAppealSent": "Appeal sent", + "@moderationAppealSent": { + "description": "Confirmation after a moderation label appeal succeeds" + }, + "moderationAppealFailed": "Could not send the appeal", + "@moderationAppealFailed": { + "description": "Error after a moderation label appeal fails" + }, + "settingAdultContent": "Show adult content", + "@settingAdultContent": { + "description": "Settings toggle for allowing configurable adult-only labels" + }, + "settingAdultContentDescription": "When off, adult-only labels are hidden and cannot be overridden.", + "@settingAdultContentDescription": { + "description": "Explanation for the adult content setting" + }, + "labelShow": "Show", + "@labelShow": { + "description": "Moderation preference that shows labeled content" + }, + "labelOff": "Off", + "@labelOff": { + "description": "Moderation preference that turns off treatment for a label" + }, + "labelWarn": "Warn", + "@labelWarn": { + "description": "Moderation preference that warns before showing labeled content" + }, + "labelBadge": "Badge", + "@labelBadge": { + "description": "Moderation preference that shows an informational badge on labeled content" + }, + "labelHide": "Hide", + "@labelHide": { + "description": "Moderation preference that hides labeled content" + }, + "moderationConfiguredGlobally": "Configured in Moderation settings.", + "@moderationConfiguredGlobally": { + "description": "Explanation for a global label shown on a labeler settings page" + }, + "moderationLabelPornName": "Adult Content", + "@moderationLabelPornName": { + "description": "Name of the built-in porn moderation label" + }, + "moderationLabelPornDescription": "Explicit sexual images.", + "@moderationLabelPornDescription": { + "description": "Description of the built-in porn moderation label" + }, + "moderationLabelSexualName": "Sexually Suggestive", + "@moderationLabelSexualName": { + "description": "Name of the built-in sexually suggestive moderation label" + }, + "moderationLabelSexualDescription": "Does not include nudity.", + "@moderationLabelSexualDescription": { + "description": "Description of the built-in sexually suggestive moderation label" + }, + "moderationLabelNudityName": "Non-sexual Nudity", + "@moderationLabelNudityName": { + "description": "Name of the built-in non-sexual nudity moderation label" + }, + "moderationLabelNudityDescription": "For example, artistic nudes.", + "@moderationLabelNudityDescription": { + "description": "Description of the built-in non-sexual nudity moderation label" + }, + "moderationLabelGraphicMediaName": "Graphic Media", + "@moderationLabelGraphicMediaName": { + "description": "Name of the built-in graphic media moderation label" + }, + "moderationLabelGraphicMediaDescription": "Explicit or potentially disturbing media.", + "@moderationLabelGraphicMediaDescription": { + "description": "Description of the built-in graphic media moderation label" + }, + "moderationLabelGoreName": "Gore", + "@moderationLabelGoreName": { + "description": "Name of the built-in gore moderation label" + }, + "moderationLabelGoreDescription": "Graphic depictions of severe injury, blood, or death.", + "@moderationLabelGoreDescription": { + "description": "Description of the built-in gore moderation label" + }, + "errorAddingLabeler": "Could not add that labeler. Check the DID or handle and try again.", + "@errorAddingLabeler": { + "description": "Error shown when a labeler cannot be resolved, validated, or saved" + }, + "moderationService": "Moderation service", + "@moderationService": { + "description": "Label for choosing the service that receives a moderation report" + }, + "moderationDefaultService": "Default moderation service", + "@moderationDefaultService": { + "description": "Fallback name for the app's default moderation service" + }, + "moderationNoCompatibleService": "None of your moderation services accepts this type of report.", + "@moderationNoCompatibleService": { + "description": "Message when no subscribed moderation service supports a report" + }, + "moderationServiceLoadFailed": "Could not load moderation services.", + "@moderationServiceLoadFailed": { + "description": "Error shown when compatible moderation services cannot be loaded" + }, + "buttonSend": "Send", "@buttonSend": { "description": "Send button text" diff --git a/lib/src/core/moderation/moderated_content.dart b/lib/src/core/moderation/moderated_content.dart new file mode 100644 index 00000000..7751dd5b --- /dev/null +++ b/lib/src/core/moderation/moderated_content.dart @@ -0,0 +1,528 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get_it/get_it.dart'; +import 'package:poptart/poptart.dart'; +import 'package:poptart_lex/com/atproto/admin/defs.dart'; +import 'package:poptart_lex/com/atproto/label/defs.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/l10n/app_localizations.dart'; +import 'package:spark/src/core/moderation/moderation.dart'; +import 'package:spark/src/core/moderation/moderation_provider.dart'; +import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; + +/// Applies the same moderation decision and reveal rules to every surface. +class ModeratedContent extends ConsumerStatefulWidget { + const ModeratedContent({ + required this.labels, + required this.target, + required this.context, + required this.child, + super.key, + this.subjectDid, + this.authorLabels = const [], + this.compact = false, + this.blurredChild, + this.onConcealedTap, + this.filterReplacement = const SizedBox.shrink(), + this.onConcealChanged, + }); + + final List