diff --git a/lib/main.dart b/lib/main.dart index 88331df..77efcae 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'constants/app_colors.dart'; import 'models/community.dart'; import 'models/post.dart'; import 'providers/auth_provider.dart'; +import 'providers/block_provider.dart'; import 'providers/community_subscription_provider.dart'; import 'providers/multi_feed_provider.dart'; import 'providers/user_profile_provider.dart'; @@ -24,6 +25,7 @@ import 'screens/home/profile_screen.dart'; import 'screens/landing_screen.dart'; import 'services/comment_service.dart'; import 'services/comments_provider_cache.dart'; +import 'services/coves_api_service.dart'; import 'services/streamable_service.dart'; import 'services/vote_service.dart'; import 'widgets/loading_error_states.dart'; @@ -102,6 +104,16 @@ Future main() async { authProvider: authProvider, ), ), + ChangeNotifierProvider( + create: (_) => BlockProvider( + apiService: CovesApiService( + tokenGetter: () async => authProvider.session?.token, + tokenRefresher: authProvider.refreshToken, + signOutHandler: authProvider.signOut, + ), + authProvider: authProvider, + ), + ), ChangeNotifierProxyProvider3< AuthProvider, VoteProvider, diff --git a/lib/providers/block_provider.dart b/lib/providers/block_provider.dart new file mode 100644 index 0000000..893fc4b --- /dev/null +++ b/lib/providers/block_provider.dart @@ -0,0 +1,167 @@ +import 'package:flutter/foundation.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import '../services/api_exceptions.dart'; +import '../services/coves_api_service.dart'; +import 'auth_provider.dart'; + +/// Block Provider +/// +/// Manages block state for users and communities with optimistic UI updates. +/// Tracks local block state keyed by DID for instant feedback. +/// Automatically clears state when user signs out. +class BlockProvider with ChangeNotifier { + BlockProvider({ + required CovesApiService apiService, + required AuthProvider authProvider, + }) : _apiService = apiService, + _authProvider = authProvider { + _authProvider.addListener(_onAuthChanged); + } + + @override + void dispose() { + _authProvider.removeListener(_onAuthChanged); + super.dispose(); + } + + void _onAuthChanged() { + if (!_authProvider.isAuthenticated) { + if (_userBlocks.isNotEmpty || _communityBlocks.isNotEmpty) { + clear(); + if (kDebugMode) { + debugPrint('๐Ÿงน Cleared block state on sign-out'); + } + } + } + } + + final AuthProvider _authProvider; + final CovesApiService _apiService; + + // Map of DID -> blocked state + final Map _userBlocks = {}; + final Map _communityBlocks = {}; + + // Map of DID -> in-flight request flag + final Map _pendingUserBlocks = {}; + final Map _pendingCommunityBlocks = {}; + + /// Check if a user is blocked + bool isUserBlocked(String userDid) => _userBlocks[userDid] ?? false; + + /// Check if a community is blocked + bool isCommunityBlocked(String communityDid) => + _communityBlocks[communityDid] ?? false; + + /// Check if a user block request is pending + bool isUserBlockPending(String userDid) => + _pendingUserBlocks[userDid] ?? false; + + /// Check if a community block request is pending + bool isCommunityBlockPending(String communityDid) => + _pendingCommunityBlocks[communityDid] ?? false; + + /// Toggle user block (block/unblock) + /// + /// Returns true if now blocked, false if now unblocked. + /// Throws ApiException if the request fails. + Future toggleUserBlock({required String userDid}) => _toggleBlock( + did: userDid, + blocks: _userBlocks, + pending: _pendingUserBlocks, + blockFn: () => _apiService.blockUser(actor: userDid), + unblockFn: () => _apiService.unblockUser(actor: userDid), + ); + + /// Toggle community block (block/unblock) + /// + /// Returns true if now blocked, false if now unblocked. + /// Throws ApiException if the request fails. + Future toggleCommunityBlock({required String communityDid}) => + _toggleBlock( + did: communityDid, + blocks: _communityBlocks, + pending: _pendingCommunityBlocks, + blockFn: () => _apiService.blockCommunity(community: communityDid), + unblockFn: () => + _apiService.unblockCommunity(community: communityDid), + ); + + /// Generic toggle block with optimistic updates and rollback. + Future _toggleBlock({ + required String did, + required Map blocks, + required Map pending, + required Future Function() blockFn, + required Future Function() unblockFn, + }) async { + if (did.isEmpty || !did.startsWith('did:')) { + throw ApiException('Invalid DID'); + } + + if (pending[did] ?? false) { + if (kDebugMode) { + debugPrint('โš ๏ธ Block request already in progress for $did'); + } + return blocks[did] ?? false; + } + + final wasBlocked = blocks[did] ?? false; + final willBlock = !wasBlocked; + + // Optimistic update + mark as pending before notify + blocks[did] = willBlock; + pending[did] = true; + notifyListeners(); + + try { + if (willBlock) { + await blockFn(); + } else { + await unblockFn(); + } + return willBlock; + } on ApiException { + blocks[did] = wasBlocked; + notifyListeners(); + rethrow; + } catch (e, stackTrace) { + blocks[did] = wasBlocked; + notifyListeners(); + await Sentry.captureException(e, stackTrace: stackTrace); + throw ApiException( + 'Unexpected error: ${e.toString()}', + statusCode: 500, + ); + } finally { + pending.remove(did); + notifyListeners(); + } + } + + /// Initialize user block state from profile data + void setInitialUserBlockState({ + required String userDid, + required bool isBlocked, + }) { + _userBlocks[userDid] = isBlocked; + } + + /// Initialize community block state from community data + void setInitialCommunityBlockState({ + required String communityDid, + required bool isBlocked, + }) { + _communityBlocks[communityDid] = isBlocked; + } + + /// Clear all block state (e.g., on sign out) + void clear() { + _userBlocks.clear(); + _communityBlocks.clear(); + _pendingUserBlocks.clear(); + _pendingCommunityBlocks.clear(); + notifyListeners(); + } +} diff --git a/lib/services/coves_api_service.dart b/lib/services/coves_api_service.dart index b999ed2..87bc3e5 100644 --- a/lib/services/coves_api_service.dart +++ b/lib/services/coves_api_service.dart @@ -966,6 +966,115 @@ class CovesApiService { } } + /// Block a user by DID. Returns the block record URI. + Future blockUser({required String actor}) => _performBlock( + did: actor, + didLabel: 'user', + endpoint: '/xrpc/social.coves.actor.blockUser', + dataKey: 'subject', + ); + + /// Unblock a user by DID. + Future unblockUser({required String actor}) => _performUnblock( + did: actor, + didLabel: 'user', + endpoint: '/xrpc/social.coves.actor.unblockUser', + dataKey: 'subject', + ); + + /// Block a community by DID. Returns the block record URI. + Future blockCommunity({required String community}) => _performBlock( + did: community, + didLabel: 'community', + endpoint: '/xrpc/social.coves.community.blockCommunity', + dataKey: 'community', + ); + + /// Unblock a community by DID. + Future unblockCommunity({required String community}) => + _performUnblock( + did: community, + didLabel: 'community', + endpoint: '/xrpc/social.coves.community.unblockCommunity', + dataKey: 'community', + ); + + /// Shared helper for block operations that return a record URI. + Future _performBlock({ + required String did, + required String didLabel, + required String endpoint, + required String dataKey, + }) async { + if (did.isEmpty || !did.startsWith('did:')) { + throw ApiException('Invalid $didLabel DID'); + } + try { + if (kDebugMode) { + debugPrint('๐Ÿ“ก Blocking $didLabel: $did'); + } + + final response = await _dio.post( + endpoint, + data: {dataKey: did}, + ); + + if (kDebugMode) { + debugPrint('โœ… Blocked $didLabel: $did'); + } + + final data = response.data as Map; + final recordUri = + (data['block'] as Map)['recordUri'] as String?; + if (recordUri == null || recordUri.isEmpty) { + throw ApiException('Server returned invalid block response'); + } + return recordUri; + } on DioException catch (e) { + _handleDioException(e, 'block $didLabel'); + } catch (e) { + if (e is ApiException) rethrow; + if (kDebugMode) { + debugPrint('โŒ Error blocking $didLabel: $e'); + } + throw ApiException('Failed to block $didLabel', originalError: e); + } + } + + /// Shared helper for unblock operations. + Future _performUnblock({ + required String did, + required String didLabel, + required String endpoint, + required String dataKey, + }) async { + if (did.isEmpty || !did.startsWith('did:')) { + throw ApiException('Invalid $didLabel DID'); + } + try { + if (kDebugMode) { + debugPrint('๐Ÿ“ก Unblocking $didLabel: $did'); + } + + await _dio.post( + endpoint, + data: {dataKey: did}, + ); + + if (kDebugMode) { + debugPrint('โœ… Unblocked $didLabel: $did'); + } + } on DioException catch (e) { + _handleDioException(e, 'unblock $didLabel'); + } catch (e) { + if (e is ApiException) rethrow; + if (kDebugMode) { + debugPrint('โŒ Error unblocking $didLabel: $e'); + } + throw ApiException('Failed to unblock $didLabel', originalError: e); + } + } + /// Update a community's profile (e.g., avatar) /// /// Updates a community's profile with a new avatar image. diff --git a/lib/utils/error_messages.dart b/lib/utils/error_messages.dart index 8488ce9..eb891f1 100644 --- a/lib/utils/error_messages.dart +++ b/lib/utils/error_messages.dart @@ -113,6 +113,12 @@ abstract final class ErrorMessage { fallback: 'Could not update subscription. Please try again.', ); + /// Error message for block operations + static String block(Object error) => getErrorMessage( + error, + fallback: 'Could not update block. Please try again.', + ); + /// Error message for report operations static String report(Object error) => getErrorMessage( error, diff --git a/lib/widgets/block_action_helpers.dart b/lib/widgets/block_action_helpers.dart new file mode 100644 index 0000000..5ab0b9f --- /dev/null +++ b/lib/widgets/block_action_helpers.dart @@ -0,0 +1,242 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../providers/auth_provider.dart'; +import '../providers/block_provider.dart'; +import '../utils/error_messages.dart'; +import 'sign_in_dialog.dart'; + +/// Builds a block/unblock menu item for users or communities. +/// +/// Shows a spinner when the block request is pending, and toggles +/// between block/unblock icon and label based on current state. +MenuItemButton buildBlockMenuItem({ + required bool isBlocked, + required bool isPending, + required String label, + required VoidCallback onPressed, +}) { + return MenuItemButton( + onPressed: isPending ? null : onPressed, + leadingIcon: isPending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + isBlocked ? Icons.check_circle_outline : Icons.block, + size: 20, + ), + child: Text( + isPending + ? (isBlocked ? 'Unblocking...' : 'Blocking...') + : label, + ), + ); +} + +/// Handles the full block/unblock user flow: +/// 1. Auth check with sign-in dialog +/// 2. Confirmation dialog (only when blocking) +/// 3. Haptic feedback +/// 4. API call via BlockProvider +/// 5. Success/error snackbar +/// +/// Parameters: +/// - [context]: BuildContext +/// - [authorDid]: DID of the user to block +/// - [authorHandle]: Handle for display in dialogs/snackbars +Future handleBlockUser({ + required BuildContext context, + required String authorDid, + required String authorHandle, +}) async { + // Check authentication + final authProvider = context.read(); + if (!authProvider.isAuthenticated) { + if (!context.mounted) return; + final shouldSignIn = await SignInDialog.show( + context, + message: 'You need to sign in to block users.', + ); + if (shouldSignIn != true && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sign in required to block users'), + behavior: SnackBarBehavior.floating, + ), + ); + } + return; + } + + final blockProvider = context.read(); + final isUserBlocked = blockProvider.isUserBlocked(authorDid); + + // Show confirmation dialog only when blocking + if (!isUserBlocked) { + if (!context.mounted) return; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Block User'), + content: Text( + 'Block @$authorHandle? You won\'t see their posts or comments.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Block'), + ), + ], + ), + ); + if (confirmed != true) return; + } + + try { + await HapticFeedback.lightImpact(); + } on PlatformException { + // Haptics not supported + } + + if (!context.mounted) return; + final messenger = ScaffoldMessenger.of(context); + + try { + final nowBlocked = await blockProvider.toggleUserBlock( + userDid: authorDid, + ); + + if (context.mounted) { + messenger.showSnackBar( + SnackBar( + content: Text( + nowBlocked + ? 'Blocked @$authorHandle' + : 'Unblocked @$authorHandle', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('Failed to toggle user block: $e'); + } + if (context.mounted) { + messenger.showSnackBar( + SnackBar( + content: Text(ErrorMessage.block(e)), + behavior: SnackBarBehavior.floating, + ), + ); + } + } +} + +/// Handles the full block/unblock community flow. +/// +/// Same pattern as [handleBlockUser] but for communities. +Future handleBlockCommunity({ + required BuildContext context, + required String communityDid, + required String communityName, +}) async { + // Check authentication + final authProvider = context.read(); + if (!authProvider.isAuthenticated) { + if (!context.mounted) return; + final shouldSignIn = await SignInDialog.show( + context, + message: 'You need to sign in to block communities.', + ); + if (shouldSignIn != true && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sign in required to block communities'), + behavior: SnackBarBehavior.floating, + ), + ); + } + return; + } + + final blockProvider = context.read(); + final isCommunityBlocked = + blockProvider.isCommunityBlocked(communityDid); + + // Show confirmation dialog only when blocking + if (!isCommunityBlocked) { + if (!context.mounted) return; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Block Community'), + content: Text( + 'Block !$communityName? You won\'t see posts from this community.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Block'), + ), + ], + ), + ); + if (confirmed != true) return; + } + + try { + await HapticFeedback.lightImpact(); + } on PlatformException { + // Haptics not supported + } + + if (!context.mounted) return; + final messenger = ScaffoldMessenger.of(context); + + try { + final nowBlocked = await blockProvider.toggleCommunityBlock( + communityDid: communityDid, + ); + + if (context.mounted) { + messenger.showSnackBar( + SnackBar( + content: Text( + nowBlocked + ? 'Blocked !$communityName' + : 'Unblocked !$communityName', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('Failed to toggle community block: $e'); + } + if (context.mounted) { + messenger.showSnackBar( + SnackBar( + content: Text(ErrorMessage.block(e)), + behavior: SnackBarBehavior.floating, + ), + ); + } + } +} diff --git a/lib/widgets/comment_card.dart b/lib/widgets/comment_card.dart index fd33acc..ea139c3 100644 --- a/lib/widgets/comment_card.dart +++ b/lib/widgets/comment_card.dart @@ -9,10 +9,12 @@ import '../constants/threading_colors.dart'; import '../models/comment.dart'; import '../models/post.dart'; import '../providers/auth_provider.dart'; +import '../providers/block_provider.dart'; import '../providers/vote_provider.dart'; import '../services/api_exceptions.dart'; import '../utils/error_messages.dart'; import '../utils/date_time_utils.dart'; +import 'block_action_helpers.dart'; import 'icons/animated_heart_icon.dart'; import 'report_dialog.dart'; import 'rich_text_renderer.dart'; @@ -123,7 +125,18 @@ class _CommentCardState extends State { } : null, child: InkWell( - onTap: onTap, + onTap: onTap != null + ? () async { + try { + await HapticFeedback.mediumImpact(); + } on PlatformException catch (e) { + if (kDebugMode) { + debugPrint('Haptics not supported: $e'); + } + } + onTap!(); + } + : null, child: Container( decoration: const BoxDecoration(color: AppColors.background), child: Stack( @@ -328,7 +341,13 @@ class _CommentCardState extends State { /// /// Menu is only visible to authenticated users, so no auth check needed here. Future _handleMenuAction(BuildContext context, String action) async { - if (action == 'report') { + if (action == 'blockUser') { + await handleBlockUser( + context: context, + authorDid: comment.author.did, + authorHandle: comment.author.handle, + ); + } else if (action == 'report') { if (!context.mounted) return; final messenger = ScaffoldMessenger.of(context); @@ -477,14 +496,18 @@ class _CommentCardState extends State { /// Shows either a report option (for non-authors) or a delete option /// (for the comment author). Only visible when authenticated. Widget _buildCommentMenu(BuildContext context) { - return Consumer( - builder: (context, authProvider, child) { + return Consumer2( + builder: (context, authProvider, blockProvider, child) { // Only show menu for authenticated users if (!authProvider.isAuthenticated) { return const SizedBox.shrink(); } final isCommentAuthor = authProvider.did == comment.author.did; + final authorDid = comment.author.did; + final authorHandle = comment.author.handle; + final isUserBlocked = blockProvider.isUserBlocked(authorDid); + final isUserBlockPending = blockProvider.isUserBlockPending(authorDid); return MenuAnchor( style: MenuStyle( @@ -498,6 +521,16 @@ class _CommentCardState extends State { ), ), menuChildren: [ + // Block user option (for non-authors) + if (!isCommentAuthor) + buildBlockMenuItem( + isBlocked: isUserBlocked, + isPending: isUserBlockPending, + label: isUserBlocked + ? 'Unblock @$authorHandle' + : 'Block @$authorHandle', + onPressed: () => _handleMenuAction(context, 'blockUser'), + ), // Report option (for non-authors) if (!isCommentAuthor) MenuItemButton( @@ -533,6 +566,9 @@ class _CommentCardState extends State { tooltip: 'Comment options', padding: EdgeInsets.zero, constraints: const BoxConstraints(), + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), onPressed: () { if (controller.isOpen) { controller.close(); @@ -558,9 +594,9 @@ class _CommentCardState extends State { ); return Row( - mainAxisAlignment: MainAxisAlignment.end, children: [ _buildCommentMenu(context), + const Spacer(), Semantics( button: true, label: diff --git a/lib/widgets/post_card_actions.dart b/lib/widgets/post_card_actions.dart index 42c4568..1a0e829 100644 --- a/lib/widgets/post_card_actions.dart +++ b/lib/widgets/post_card_actions.dart @@ -7,12 +7,14 @@ import 'package:provider/provider.dart'; import '../constants/app_colors.dart'; import '../models/post.dart'; import '../providers/auth_provider.dart'; +import '../providers/block_provider.dart'; import '../providers/community_subscription_provider.dart'; import '../providers/vote_provider.dart'; import '../services/api_exceptions.dart'; import '../services/coves_api_service.dart'; import '../utils/error_messages.dart'; import '../utils/date_time_utils.dart'; +import 'block_action_helpers.dart'; import 'icons/animated_heart_icon.dart'; import 'report_dialog.dart'; import 'share_button.dart'; @@ -111,6 +113,18 @@ class _PostCardActionsState extends State { ); } } + } else if (action == 'blockCommunity') { + await handleBlockCommunity( + context: context, + communityDid: communityDid, + communityName: communityName, + ); + } else if (action == 'blockUser') { + await handleBlockUser( + context: context, + authorDid: post.post.author.did, + authorHandle: post.post.author.handle, + ); } else if (action == 'report') { // Check authentication - report requires sign-in final authProvider = context.read(); @@ -292,8 +306,8 @@ class _PostCardActionsState extends State { mainAxisSize: MainAxisSize.min, children: [ // Three dots menu button - Consumer2( - builder: (context, subscriptionProvider, authProvider, child) { + Consumer3( + builder: (context, subscriptionProvider, authProvider, blockProvider, child) { final communityDid = post.post.community.did; final communityName = post.post.community.name; final isSubscribed = @@ -301,6 +315,16 @@ class _PostCardActionsState extends State { final isPending = subscriptionProvider.isPending(communityDid); final isPostAuthor = authProvider.did == post.post.author.did; + final authorDid = post.post.author.did; + final authorHandle = post.post.author.handle; + final isUserBlocked = blockProvider.isUserBlocked(authorDid); + final isUserBlockPending = blockProvider.isUserBlockPending(authorDid); + final isCommunityBlocked = blockProvider.isCommunityBlocked(communityDid); + final isCommunityBlockPending = blockProvider.isCommunityBlockPending(communityDid); + // TODO: Set to true when the user is the community owner. + // CommunityRef currently lacks an owner/creator DID field, + // so we cannot determine ownership from post data alone. + const isCommunityOwner = false; return MenuAnchor( style: MenuStyle( @@ -349,6 +373,28 @@ class _PostCardActionsState extends State { : 'Subscribe to !$communityName'), ), ), + // Block community option (hidden for community owners) + if (!isCommunityOwner) + buildBlockMenuItem( + isBlocked: isCommunityBlocked, + isPending: isCommunityBlockPending, + label: isCommunityBlocked + ? 'Unblock !$communityName' + : 'Block !$communityName', + onPressed: () => + _handleMenuAction(context, 'blockCommunity'), + ), + // Block user option (except own posts) + if (!isPostAuthor) + buildBlockMenuItem( + isBlocked: isUserBlocked, + isPending: isUserBlockPending, + label: isUserBlocked + ? 'Unblock @$authorHandle' + : 'Block @$authorHandle', + onPressed: () => + _handleMenuAction(context, 'blockUser'), + ), // Report option (for all authenticated users, except own posts) if (!isPostAuthor) MenuItemButton(