From 1e7b2194fe13281a27e3eb7c2b9f3ae9dc7aa6ca Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg <118622417+knotbin@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:28:40 -0400 Subject: [PATCH] refactor: moderation overhaul (#129) * feat: implement source-aware label moderation * refactor: remove self-label authoring * fix: complete label moderation parity * fix: show moderator handle in label details * refactor: strengthen moderation parity architecture * fix moderation label event reconciliation * fix suggested feed moderation parity * fix: follow live labeler policy defaults * fix: keep Spark profile labels profile-scoped * refactor: tighten moderation ownership * fix: update Poptart moderation integration * fix moderation label scope and preference races * fix: close moderation UI gating gaps * refactor moderation reports and labeler settings * fix flaky story image cache test --- .../atoms/surfaces/app_surface.dart | 47 ++ .../molecules/app_choice_group.dart | 116 +++ .../components/molecules/profile_avatar.dart | 34 +- .../components/molecules/profile_card.dart | 20 +- .../components/molecules/story_circle.dart | 48 +- .../templates/chat_list_page_template.dart | 28 +- .../templates/info_bar_template.dart | 3 + .../templates/profile_page_template.dart | 28 +- lib/src/core/l10n/app_localizations.dart | 216 +++++ lib/src/core/l10n/app_localizations_en.dart | 116 +++ lib/src/core/l10n/intl_en.arb | 146 ++++ .../moderation/feed_generator_moderation.dart | 10 + .../core/moderation/moderated_content.dart | 141 ++++ lib/src/core/moderation/moderation.dart | 8 + .../moderation/moderation_appeal_service.dart | 69 ++ .../moderation/moderation_definitions.dart | 365 +++++++++ .../moderation/moderation_details_sheet.dart | 107 +++ .../core/moderation/moderation_engine.dart | 269 ++++++ .../moderation/moderation_label_events.dart | 46 ++ .../moderation_label_localizations.dart | 45 + .../core/moderation/moderation_models.dart | 273 +++++++ .../moderation/moderation_presentation.dart | 294 +++++++ .../core/moderation/moderation_provider.dart | 55 ++ .../moderation/moderation_report_catalog.dart | 291 +++++++ .../moderation/moderation_report_service.dart | 71 ++ .../core/moderation/moderation_subject.dart | 90 ++ .../data/adapters/bsky/actor_adapter.dart | 12 +- .../data/adapters/bsky/feed_adapter.dart | 12 +- .../atproto/data/models/feed_models.dart | 7 +- .../atproto/data/models/labeler_models.dart | 82 -- .../data/models/moderated_story_view.dart | 29 + .../atproto/data/models/pref_models.dart | 17 +- .../atproto/data/models/record_models.dart | 2 + .../repositories/actor_repository_impl.dart | 11 +- .../data/repositories/feed_repository.dart | 15 +- .../repositories/feed_repository_impl.dart | 150 +--- .../repositories/graph_repository_impl.dart | 8 +- .../data/repositories/labeler_repository.dart | 84 ++ .../repositories/labeler_repository_impl.dart | 192 ++++- .../notification_repository_impl.dart | 16 +- .../data/repositories/repo_repository.dart | 7 +- .../repositories/repo_repository_impl.dart | 76 +- .../repositories/sound_repository_impl.dart | 6 +- .../data/repositories/sprk_repository.dart | 12 + .../repositories/sprk_repository_impl.dart | 19 + .../data/repositories/story_repository.dart | 9 +- .../repositories/story_repository_impl.dart | 81 +- .../services/appview_labeler_headers.dart | 67 ++ .../repository/messages_repository_xrpc.dart | 12 +- .../core/providers/preferences_provider.dart | 153 ++++ lib/src/core/routing/app_router.dart | 2 + 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 | 570 ++++--------- lib/src/core/ui/widgets/widgets.dart | 1 - lib/src/core/utils/label_utils.dart | 173 ---- .../comments/ui/widgets/comment_item.dart | 73 +- .../feed/providers/feed_provider.dart | 156 ++-- .../visible_pinned_feeds_provider.dart | 57 ++ lib/src/features/feed/ui/pages/feed_page.dart | 8 +- .../features/feed/ui/pages/feeds_page.dart | 31 +- .../feed/ui/pages/standalone_post_page.dart | 107 +-- .../action_buttons/side_action_bar.dart | 7 +- .../feed/ui/widgets/feed/feeds_bar.dart | 40 +- .../ui/widgets/post/feed_post_widget.dart | 95 +-- .../feed/ui/widgets/post/info_bar.dart | 3 + .../feed/ui/widgets/post/post_overlay.dart | 40 +- .../feed/ui/widgets/post_likes_sheet.dart | 63 +- .../messages/ui/pages/messages_page.dart | 18 + .../ui/pages/new_chat_search_page.dart | 34 +- .../messages/ui/widgets/messages_list.dart | 33 +- .../ui/widgets/notification_item.dart | 268 +++--- .../providers/video_upload_provider.dart | 13 - .../profile/providers/profile_provider.dart | 6 +- .../profile/ui/pages/profile_page.dart | 382 +++++---- .../profile/ui/widgets/blocks_list_view.dart | 55 +- .../ui/widgets/profile_feed_post_widget.dart | 87 +- .../ui/widgets/profile_grid_widget.dart | 68 +- .../profile/ui/widgets/user_list_view.dart | 84 +- .../repositories/post_search_repository.dart | 44 +- .../providers/post_search_provider.dart | 90 +- .../providers/suggested_feeds_provider.dart | 31 + .../search/ui/pages/post_results.dart | 43 +- .../features/search/ui/pages/search_page.dart | 33 +- .../search/ui/pages/user_results.dart | 101 ++- .../ui/widgets/suggested_feeds_list.dart | 120 +-- .../providers/labeler_policy_preferences.dart | 22 + .../labeler_settings_controller.dart | 297 +++++++ .../providers/preferences_provider.dart | 92 --- .../settings/providers/settings_provider.dart | 640 +-------------- .../ui/pages/labeler_label_settings_page.dart | 272 ++---- .../ui/pages/labeler_management_page.dart | 33 +- .../settings/ui/pages/moderation_page.dart | 205 +++++ .../settings/ui/pages/settings_page.dart | 7 +- .../ui/widgets/label_setting_tile.dart | 173 ++-- .../ui/widgets/settings_feed_card.dart | 11 +- .../features/sound/ui/pages/sound_page.dart | 60 +- .../ui/widgets/audio_track_list_section.dart | 32 +- .../stories/providers/stories_by_author.dart | 7 +- .../providers/story_manager_provider.dart | 10 +- .../stories/ui/pages/all_stories_page.dart | 7 +- .../stories/ui/pages/author_stories_page.dart | 51 +- .../stories/ui/pages/story_manager_page.dart | 1 - .../features/stories/ui/pages/story_page.dart | 208 +++-- .../stories/ui/widgets/stories_list.dart | 41 +- .../stories/utils/story_navigation.dart | 1 + pubspec.lock | 12 +- pubspec.yaml | 6 +- .../components/app_choice_group_test.dart | 129 +++ .../components/app_surface_test.dart | 35 + .../components/profile_card_test.dart | 62 ++ .../moderation/moderated_content_test.dart | 581 +++++++++++++ .../moderation_definitions_test.dart | 383 +++++++++ .../moderation/moderation_engine_test.dart | 773 ++++++++++++++++++ .../moderation_label_events_test.dart | 96 +++ .../moderation_preferences_test.dart | 238 ++++++ .../moderation/moderation_provider_test.dart | 196 +++++ .../actor_repository_impl_test.dart | 8 + .../feed_repository_requests_test.dart | 58 +- .../graph_repository_impl_test.dart | 4 + .../labeler_repository_impl_test.dart | 217 +++++ .../notification_repository_impl_test.dart | 4 + .../pref_repository_impl_test.dart | 38 + .../repositories/repository_test_support.dart | 59 +- .../sound_repository_impl_test.dart | 4 + .../story_repository_impl_test.dart | 166 ++++ .../appview_labeler_headers_test.dart | 43 + .../providers/preferences_provider_test.dart | 215 ++++- .../core/ui/widgets/report_dialog_test.dart | 286 +++++++ test/src/core/utils/label_utils_test.dart | 247 ------ .../feed/providers/feed_provider_test.dart | 240 +++++- .../visible_pinned_feeds_provider_test.dart | 165 ++++ .../feed/ui/pages/feeds_page_test.dart | 262 ++++++ .../ui/pages/standalone_post_page_test.dart | 203 +++++ .../ui/widgets/post_likes_sheet_test.dart | 129 +++ .../profile/ui/pages/profile_page_test.dart | 158 ++++ .../post_search_repository_test.dart | 41 + .../providers/post_search_provider_test.dart | 176 +++- .../suggested_feeds_provider_test.dart | 87 +- .../labeler_settings_controller_test.dart | 223 +++++ .../providers/settings_provider_test.dart | 329 ++++++-- .../labeler_label_settings_page_test.dart | 293 +++++++ .../ui/pages/moderation_page_test.dart | 255 ++++++ .../settings/ui/pages/settings_page_test.dart | 23 + .../ui/widgets/label_setting_tile_test.dart | 120 ++- .../audio_selection_bottom_sheet_test.dart | 67 ++ .../providers/stories_by_author_test.dart | 24 +- .../story_manager_provider_test.dart | 23 +- .../stories/ui/pages/story_page_test.dart | 219 +++++ 150 files changed, 12441 insertions(+), 3720 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/feed_generator_moderation.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_appeal_service.dart create mode 100644 lib/src/core/moderation/moderation_definitions.dart create mode 100644 lib/src/core/moderation/moderation_details_sheet.dart create mode 100644 lib/src/core/moderation/moderation_engine.dart create mode 100644 lib/src/core/moderation/moderation_label_events.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_presentation.dart create mode 100644 lib/src/core/moderation/moderation_provider.dart create mode 100644 lib/src/core/moderation/moderation_report_catalog.dart create mode 100644 lib/src/core/moderation/moderation_report_service.dart create mode 100644 lib/src/core/moderation/moderation_subject.dart create mode 100644 lib/src/core/network/atproto/data/models/moderated_story_view.dart create mode 100644 lib/src/core/network/atproto/data/services/appview_labeler_headers.dart create mode 100644 lib/src/core/providers/preferences_provider.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/settings/providers/labeler_policy_preferences.dart create mode 100644 lib/src/features/settings/providers/labeler_settings_controller.dart delete mode 100644 lib/src/features/settings/providers/preferences_provider.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_label_events_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 rename test/src/{features/settings => core}/providers/preferences_provider_test.dart (54%) 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/feed/ui/pages/feeds_page_test.dart create mode 100644 test/src/features/feed/ui/pages/standalone_post_page_test.dart create mode 100644 test/src/features/feed/ui/widgets/post_likes_sheet_test.dart create mode 100644 test/src/features/profile/ui/pages/profile_page_test.dart create mode 100644 test/src/features/search/data/repositories/post_search_repository_test.dart create mode 100644 test/src/features/settings/providers/labeler_settings_controller_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/components/molecules/profile_avatar.dart b/lib/src/core/design_system/components/molecules/profile_avatar.dart index 615df354..0a86472c 100644 --- a/lib/src/core/design_system/components/molecules/profile_avatar.dart +++ b/lib/src/core/design_system/components/molecules/profile_avatar.dart @@ -14,6 +14,7 @@ class ProfileAvatar extends StatelessWidget { this.onTap, this.showAddButton = false, this.onAddTap, + this.avatarBuilder, }); final String? avatarUrl; @@ -23,6 +24,7 @@ class ProfileAvatar extends StatelessWidget { final VoidCallback? onTap; final bool showAddButton; final VoidCallback? onAddTap; + final Widget Function(Widget avatar)? avatarBuilder; @override Widget build(BuildContext context) { @@ -111,24 +113,22 @@ class ProfileAvatar extends StatelessWidget { required bool isDarkMode, required double avatarSize, }) { - if (avatarUrl != null && avatarUrl!.isNotEmpty) { - return ClipOval( - child: CachedNetworkImage( - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - imageUrl: avatarUrl!, - width: avatarSize, - height: avatarSize, - fit: BoxFit.cover, - placeholder: (context, url) => - _buildPlaceholder(context, isDarkMode, avatarSize), - errorWidget: (context, url, error) => - _buildPlaceholder(context, isDarkMode, avatarSize), - ), - ); - } + final avatar = avatarUrl != null && avatarUrl!.isNotEmpty + ? CachedNetworkImage( + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + imageUrl: avatarUrl!, + width: avatarSize, + height: avatarSize, + fit: BoxFit.cover, + placeholder: (context, url) => + _buildPlaceholder(context, isDarkMode, avatarSize), + errorWidget: (context, url, error) => + _buildPlaceholder(context, isDarkMode, avatarSize), + ) + : _buildPlaceholder(context, isDarkMode, avatarSize); - return _buildPlaceholder(context, isDarkMode, avatarSize); + return ClipOval(child: avatarBuilder?.call(avatar) ?? avatar); } Widget _buildPlaceholder( diff --git a/lib/src/core/design_system/components/molecules/profile_card.dart b/lib/src/core/design_system/components/molecules/profile_card.dart index f3c2fc82..9cd4eaa3 100644 --- a/lib/src/core/design_system/components/molecules/profile_card.dart +++ b/lib/src/core/design_system/components/molecules/profile_card.dart @@ -20,6 +20,7 @@ class ProfileCard extends StatelessWidget { this.onTap, this.hasStories = false, this.onAvatarTap, + this.avatarBuilder, super.key, }); @@ -37,6 +38,7 @@ class ProfileCard extends StatelessWidget { VoidCallback? onTap, bool hasStories = false, VoidCallback? onAvatarTap, + Widget Function(Widget avatar)? avatarBuilder, Key? key, }) : this( imageUrl: imageUrl, @@ -51,6 +53,7 @@ class ProfileCard extends StatelessWidget { onTap: onTap, hasStories: hasStories, onAvatarTap: onAvatarTap, + avatarBuilder: avatarBuilder, key: key, ); @@ -66,6 +69,7 @@ class ProfileCard extends StatelessWidget { final VoidCallback? onTap; final bool hasStories; final VoidCallback? onAvatarTap; + final Widget Function(Widget avatar)? avatarBuilder; @override Widget build(BuildContext context) { @@ -73,6 +77,14 @@ class ProfileCard extends StatelessWidget { final isDark = Theme.of(context).brightness == Brightness.dark; final radius = BorderRadius.circular(AppShapes.squircleRadius); final borderColor = isDark ? AppColors.grey800 : AppColors.grey200; + final avatar = ProfileAvatar( + avatarUrl: imageUrl.isNotEmpty ? imageUrl : null, + displayName: userName, + size: 36, + hasStories: hasStories, + onTap: onAvatarTap ?? onTap, + avatarBuilder: avatarBuilder, + ); final Widget content = ConstrainedBox( constraints: const BoxConstraints(minHeight: 60), @@ -97,13 +109,7 @@ class ProfileCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ProfileAvatar( - avatarUrl: imageUrl.isNotEmpty ? imageUrl : null, - displayName: userName, - size: 36, - hasStories: hasStories, - onTap: onAvatarTap ?? onTap, - ), + avatar, const SizedBox(width: 10), Expanded( child: Column( diff --git a/lib/src/core/design_system/components/molecules/story_circle.dart b/lib/src/core/design_system/components/molecules/story_circle.dart index 0344fec0..00105048 100644 --- a/lib/src/core/design_system/components/molecules/story_circle.dart +++ b/lib/src/core/design_system/components/molecules/story_circle.dart @@ -12,24 +12,28 @@ class StoryCircle extends StatelessWidget { final String userName; final String imageUrl; final String live; + final Widget Function(Widget avatar)? avatarBuilder; const StoryCircle._({ required this.type, required this.userName, required this.imageUrl, required this.live, + this.avatarBuilder, }); /// Constructor variant for an unread story with an accent border. factory StoryCircle.story({ required String userName, required String imageUrl, + Widget Function(Widget avatar)? avatarBuilder, }) { return StoryCircle._( type: StoryType.story, userName: userName, imageUrl: imageUrl, live: '', + avatarBuilder: avatarBuilder, ); } @@ -38,22 +42,29 @@ class StoryCircle extends StatelessWidget { required String userName, required String imageUrl, required String live, + Widget Function(Widget avatar)? avatarBuilder, }) { return StoryCircle._( type: StoryType.live, userName: userName, imageUrl: imageUrl, live: live, + avatarBuilder: avatarBuilder, ); } /// Constructor variant for a "Close Friends" story with a green border. - factory StoryCircle.cf({required String userName, required String imageUrl}) { + factory StoryCircle.cf({ + required String userName, + required String imageUrl, + Widget Function(Widget avatar)? avatarBuilder, + }) { return StoryCircle._( type: StoryType.cf, userName: userName, imageUrl: imageUrl, live: '', + avatarBuilder: avatarBuilder, ); } @@ -62,12 +73,14 @@ class StoryCircle extends StatelessWidget { factory StoryCircle.create({ required String userName, required String imageUrl, + Widget Function(Widget avatar)? avatarBuilder, }) { return StoryCircle._( type: StoryType.create, userName: userName, imageUrl: imageUrl, live: '', + avatarBuilder: avatarBuilder, ); } @@ -111,20 +124,9 @@ class StoryCircle extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(_ringGap), child: ClipOval( - child: imageUrl.isNotEmpty - ? CachedNetworkImage( - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - imageUrl: imageUrl, - width: _imageSize, - height: _imageSize, - fit: BoxFit.cover, - errorWidget: (context, url, error) => - const DefaultProfileAvatar( - size: _imageSize, - ), - ) - : const DefaultProfileAvatar(size: _imageSize), + child: + avatarBuilder?.call(_buildAvatar()) ?? + _buildAvatar(), ), ), ), @@ -160,6 +162,22 @@ class StoryCircle extends StatelessWidget { return null; } } + + Widget _buildAvatar() { + if (imageUrl.isEmpty) { + return const DefaultProfileAvatar(size: _imageSize); + } + return CachedNetworkImage( + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + imageUrl: imageUrl, + width: _imageSize, + height: _imageSize, + fit: BoxFit.cover, + errorWidget: (context, url, error) => + const DefaultProfileAvatar(size: _imageSize), + ); + } } class _LiveBadge extends StatelessWidget { 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..0ab11854 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 @@ -13,6 +13,7 @@ class ChatListItemData { this.avatarUrl, this.verified = false, this.unread = false, + this.avatarBuilder, }); final String? avatarUrl; @@ -22,6 +23,7 @@ class ChatListItemData { final String preview; final bool verified; final bool unread; + final Widget Function(Widget avatar)? avatarBuilder; } class ChatListPageTemplate extends StatelessWidget { @@ -34,6 +36,7 @@ class ChatListPageTemplate extends StatelessWidget { this.loadingItemCount = 8, this.onAddTap, this.onRefresh, + this.itemWrapper, }); const ChatListPageTemplate.loading({ @@ -42,6 +45,7 @@ class ChatListPageTemplate extends StatelessWidget { this.loadingItemCount = 8, this.onAddTap, this.onRefresh, + this.itemWrapper, }) : items = const [], onItemTap = _noopItemTap, loading = true; @@ -53,6 +57,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 +92,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; + }, ), ), ), @@ -110,16 +119,17 @@ class _ChatTile extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final onSurface = theme.colorScheme.onSurface; + final avatar = UserAvatar( + imageUrl: data.avatarUrl ?? '', + username: data.handle, + size: 50.45, + ); return ListTile( onTap: onTap, horizontalTitleGap: 12, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - leading: UserAvatar( - imageUrl: data.avatarUrl ?? '', - username: data.handle, - size: 50.45, - ), + leading: data.avatarBuilder?.call(avatar) ?? avatar, title: Row( children: [ Expanded( diff --git a/lib/src/core/design_system/templates/info_bar_template.dart b/lib/src/core/design_system/templates/info_bar_template.dart index 02683340..f5848df5 100644 --- a/lib/src/core/design_system/templates/info_bar_template.dart +++ b/lib/src/core/design_system/templates/info_bar_template.dart @@ -23,6 +23,7 @@ class InfoBarTemplate extends StatefulWidget { this.altAvailable = false, this.onAltTap, this.avatarUrl, + this.avatarBuilder, }); /// Display name @@ -57,6 +58,7 @@ class InfoBarTemplate extends StatefulWidget { /// Avatar shown on left of the name/handle. final String? avatarUrl; + final Widget Function(Widget avatar)? avatarBuilder; @override State createState() => _InfoBarTemplateState(); @@ -95,6 +97,7 @@ class _InfoBarTemplateState extends State displayName: widget.displayName, size: 32, onTap: widget.onAvatarTap ?? widget.onTitleTap, + avatarBuilder: widget.avatarBuilder, ), ), diff --git a/lib/src/core/design_system/templates/profile_page_template.dart b/lib/src/core/design_system/templates/profile_page_template.dart index e25ef529..98930153 100644 --- a/lib/src/core/design_system/templates/profile_page_template.dart +++ b/lib/src/core/design_system/templates/profile_page_template.dart @@ -24,6 +24,7 @@ class ProfilePageTemplate extends StatelessWidget { required this.tabsWidget, super.key, this.avatarUrl, + this.avatarBuilder, this.description, this.links, this.knownFollowers, @@ -58,6 +59,7 @@ class ProfilePageTemplate extends StatelessWidget { final String followersCount; final String followingCount; final String? avatarUrl; + final Widget Function(Widget avatar)? avatarBuilder; final String? description; final List? links; final KnownFollowers? knownFollowers; @@ -119,6 +121,7 @@ class ProfilePageTemplate extends StatelessWidget { followersCount: followersCount, followingCount: followingCount, avatarUrl: avatarUrl, + avatarBuilder: avatarBuilder, description: description, links: links, knownFollowers: knownFollowers, @@ -169,6 +172,7 @@ class _ProfileHeaderSection extends StatelessWidget { required this.isBlocking, required this.isEarlySupporter, this.avatarUrl, + this.avatarBuilder, this.description, this.links, this.knownFollowers, @@ -190,6 +194,7 @@ class _ProfileHeaderSection extends StatelessWidget { final String followersCount; final String followingCount; final String? avatarUrl; + final Widget Function(Widget avatar)? avatarBuilder; final String? description; final List? links; final KnownFollowers? knownFollowers; @@ -211,6 +216,17 @@ class _ProfileHeaderSection extends StatelessWidget { @override Widget build(BuildContext context) { + final avatar = ProfileAvatar( + avatarUrl: avatarUrl, + displayName: displayName, + hasStories: hasStories, + size: 80, + onTap: onAvatarTap, + showAddButton: isCurrentUser, + onAddTap: onAddStoryTap, + avatarBuilder: avatarBuilder, + ); + return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -219,17 +235,7 @@ class _ProfileHeaderSection extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Skeleton.keep( - child: ProfileAvatar( - avatarUrl: avatarUrl, - displayName: displayName, - hasStories: hasStories, - size: 80, - onTap: onAvatarTap, - showAddButton: isCurrentUser, - onAddTap: onAddStoryTap, - ), - ), + Skeleton.keep(child: avatar), const SizedBox(width: 16), Expanded( child: Column( diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index d29b8bf6..cd68a7cc 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,216 @@ abstract class AppLocalizations { int count, ); + /// 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..2971881e 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,119 @@ class AppLocalizationsEn extends AppLocalizations { return 'Followed by $firstName, $secondName, and $_temp0'; } + @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..b33fbf65 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,147 @@ } }, + "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/feed_generator_moderation.dart b/lib/src/core/moderation/feed_generator_moderation.dart new file mode 100644 index 00000000..5755a7d2 --- /dev/null +++ b/lib/src/core/moderation/feed_generator_moderation.dart @@ -0,0 +1,10 @@ +import 'package:spark/src/core/moderation/moderation_subject.dart'; +import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; + +ModerationSubject feedGeneratorModerationSubject(GeneratorView generator) { + return ModerationSubject.content( + labels: generator.labels ?? const [], + authorLabels: generator.creator.labels ?? const [], + subjectDid: generator.creator.did, + ); +} diff --git a/lib/src/core/moderation/moderated_content.dart b/lib/src/core/moderation/moderated_content.dart new file mode 100644 index 00000000..e02c7f8a --- /dev/null +++ b/lib/src/core/moderation/moderated_content.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:poptart_lex/com/atproto/label/defs.dart'; +import 'package:spark/src/core/moderation/moderation_details_sheet.dart'; +import 'package:spark/src/core/moderation/moderation_engine.dart'; +import 'package:spark/src/core/moderation/moderation_models.dart'; +import 'package:spark/src/core/moderation/moderation_presentation.dart'; +import 'package:spark/src/core/moderation/moderation_provider.dart'; +import 'package:spark/src/core/moderation/moderation_subject.dart'; + +/// Applies the same moderation decision and reveal rules to every surface. +class ModeratedContent extends ConsumerStatefulWidget { + const ModeratedContent({ + required this.subject, + required this.context, + required this.child, + super.key, + this.presentation = const ModerationPresentation.standard(), + this.filterReplacement = const SizedBox.shrink(), + this.onConcealChanged, + }); + + final ModerationSubject subject; + final ModerationContext context; + final Widget child; + final ModerationPresentation presentation; + final Widget filterReplacement; + final ValueChanged? onConcealChanged; + + @override + ConsumerState createState() => _ModeratedContentState(); +} + +class ModeratedProfileAvatar extends StatelessWidget { + const ModeratedProfileAvatar({ + required this.labels, + required this.subjectDid, + required this.child, + super.key, + }); + + final List