diff --git a/lib/comment/view/create_comment_page.dart b/lib/comment/view/create_comment_page.dart index 182d99eb7..b55c35845 100644 --- a/lib/comment/view/create_comment_page.dart +++ b/lib/comment/view/create_comment_page.dart @@ -15,7 +15,7 @@ import 'package:thunder/account/models/draft.dart'; // Project imports import 'package:thunder/comment/cubit/create_comment_cubit.dart'; -import 'package:thunder/community/utils/post_card_action_helpers.dart'; +import 'package:thunder/post/widgets/post_action_bottom_sheet.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/models/post_view_media.dart'; import 'package:thunder/drafts/draft_type.dart'; diff --git a/lib/community/pages/create_post_page.dart b/lib/community/pages/create_post_page.dart index 71726fafa..370ff22af 100644 --- a/lib/community/pages/create_post_page.dart +++ b/lib/community/pages/create_post_page.dart @@ -18,7 +18,7 @@ import 'package:markdown_editor/markdown_editor.dart'; import 'package:thunder/account/models/account.dart'; import 'package:thunder/account/models/draft.dart'; import 'package:thunder/community/bloc/image_bloc.dart'; -import 'package:thunder/community/utils/post_card_action_helpers.dart'; +import 'package:thunder/post/widgets/post_action_bottom_sheet.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/auth/helpers/fetch_account.dart'; import 'package:thunder/core/enums/view_mode.dart'; diff --git a/lib/community/utils/post_card_action_helpers.dart b/lib/community/utils/post_card_action_helpers.dart deleted file mode 100644 index 228c55e7b..000000000 --- a/lib/community/utils/post_card_action_helpers.dart +++ /dev/null @@ -1,884 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:back_button_interceptor/back_button_interceptor.dart'; -import 'package:flutter/material.dart'; - -import 'package:share_plus/share_plus.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:thunder/account/bloc/account_bloc.dart'; - -import 'package:thunder/community/bloc/community_bloc.dart'; -import 'package:thunder/community/enums/community_action.dart'; -import 'package:thunder/community/widgets/post_card_metadata.dart'; -import 'package:thunder/core/enums/full_name.dart'; -import 'package:thunder/core/enums/media_type.dart'; -import 'package:thunder/core/models/post_view_media.dart'; -import 'package:thunder/core/singletons/lemmy_client.dart'; -import 'package:thunder/feed/bloc/feed_bloc.dart'; -import 'package:thunder/feed/utils/utils.dart'; -import 'package:thunder/feed/view/feed_page.dart'; -import 'package:thunder/instance/bloc/instance_bloc.dart'; -import 'package:thunder/instance/enums/instance_action.dart'; -import 'package:thunder/post/enums/post_action.dart'; -import 'package:thunder/post/widgets/reason_bottom_sheet.dart'; -import 'package:thunder/shared/advanced_share_sheet.dart'; -import 'package:thunder/shared/picker_item.dart'; -import 'package:thunder/shared/snackbar.dart'; -import 'package:thunder/thunder/bloc/thunder_bloc.dart'; -import 'package:thunder/user/bloc/user_bloc.dart'; -import 'package:thunder/user/enums/user_action.dart'; -import 'package:thunder/utils/instance.dart'; -import 'package:thunder/instance/utils/navigate_instance.dart'; -import 'package:lemmy_api_client/v3.dart'; - -import 'package:thunder/core/auth/bloc/auth_bloc.dart'; -import 'package:thunder/shared/multi_picker_item.dart'; -import 'package:thunder/utils/global_context.dart'; - -enum PostCardAction { - userActions, - visitProfile, - blockUser, - communityActions, - visitCommunity, - subscribeToCommunity, - unsubscribeFromCommunity, - blockCommunity, - instanceActions, - visitCommunityInstance, - blockCommunityInstance, - visitUserInstance, - blockUserInstance, - sharePost, - sharePostLocal, - shareImage, - shareMedia, - shareLink, - shareAdvanced, - upvote, - downvote, - save, - toggleRead, - hide, - share, - delete, - moderatorActions, - moderatorLockPost, - moderatorPinCommunity, - moderatorRemovePost, -} - -class ExtendedPostCardActions { - const ExtendedPostCardActions({ - required this.postCardAction, - required this.icon, - this.trailingIcon, - required this.label, - this.getColor, - this.getForegroundColor, - this.getOverrideIcon, - this.getOverrideLabel, - this.getSubtitleLabel, - this.shouldShow, - this.shouldEnable, - }); - - final PostCardAction postCardAction; - final IconData icon; - final IconData? trailingIcon; - final String label; - final Color Function(BuildContext context)? getColor; - final Color? Function(BuildContext context, PostView postView)? getForegroundColor; - final IconData? Function(PostView postView)? getOverrideIcon; - final String? Function(BuildContext context, PostView postView)? getOverrideLabel; - final String? Function(BuildContext context, PostViewMedia postViewMedia)? getSubtitleLabel; - final bool Function(BuildContext context, PostView commentView)? shouldShow; - final bool Function(bool isUserLoggedIn)? shouldEnable; -} - -final l10n = AppLocalizations.of(GlobalContext.context)!; - -final List postCardActionItems = [ - ExtendedPostCardActions( - postCardAction: PostCardAction.userActions, - icon: Icons.person_rounded, - label: l10n.user, - getSubtitleLabel: (context, postViewMedia) => generateUserFullName( - context, - postViewMedia.postView.creator.name, - postViewMedia.postView.creator.displayName, - fetchInstanceNameFromUrl(postViewMedia.postView.creator.actorId), - ), - trailingIcon: Icons.chevron_right_rounded, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.visitProfile, - icon: Icons.person_search_rounded, - label: l10n.visitUserProfile, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.blockUser, - icon: Icons.block, - label: l10n.blockUser, - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.communityActions, - icon: Icons.people_rounded, - label: l10n.community, - getSubtitleLabel: (context, postViewMedia) => generateCommunityFullName( - context, - postViewMedia.postView.community.name, - postViewMedia.postView.community.title, - fetchInstanceNameFromUrl(postViewMedia.postView.community.actorId), - ), - trailingIcon: Icons.chevron_right_rounded, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.visitCommunity, - icon: Icons.home_work_rounded, - label: l10n.visitCommunity, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.subscribeToCommunity, - icon: Icons.add_circle_outline_rounded, - label: l10n.subscribeToCommunity, - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.unsubscribeFromCommunity, - icon: Icons.remove_circle_outline_rounded, - label: l10n.unsubscribeFromCommunity, - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.blockCommunity, - icon: Icons.block_rounded, - label: l10n.blockCommunity, - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.instanceActions, - icon: Icons.language_rounded, - label: l10n.instance(1), - getSubtitleLabel: (context, postViewMedia) { - return areCommunityAndUserOnSameInstance(postViewMedia.postView) - ? fetchInstanceNameFromUrl(postViewMedia.postView.community.actorId) - : '${fetchInstanceNameFromUrl(postViewMedia.postView.community.actorId)} • ${fetchInstanceNameFromUrl(postViewMedia.postView.creator.actorId)}'; - }, - trailingIcon: Icons.chevron_right_rounded, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.visitCommunityInstance, - icon: Icons.language, - label: '', - getOverrideLabel: (context, postView) { - return areCommunityAndUserOnSameInstance(postView) ? l10n.visitInstance : l10n.visitCommunityInstance; - }, - getSubtitleLabel: (context, postViewMedia) => fetchInstanceNameFromUrl(postViewMedia.postView.community.actorId), - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.blockCommunityInstance, - icon: Icons.block_rounded, - label: '', - getOverrideLabel: (context, postView) { - return areCommunityAndUserOnSameInstance(postView) ? l10n.blockInstance : l10n.blockCommunityInstance; - }, - getSubtitleLabel: (context, postViewMedia) => fetchInstanceNameFromUrl(postViewMedia.postView.community.actorId), - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.visitUserInstance, - icon: Icons.language, - label: l10n.visitUserInstance, - getSubtitleLabel: (context, postViewMedia) => fetchInstanceNameFromUrl(postViewMedia.postView.creator.actorId), - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.blockUserInstance, - icon: Icons.block_rounded, - label: l10n.blockUserInstance, - getSubtitleLabel: (context, postViewMedia) => fetchInstanceNameFromUrl(postViewMedia.postView.creator.actorId), - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.sharePost, - icon: Icons.share_rounded, - label: l10n.sharePost, - getSubtitleLabel: (context, postViewMedia) => postViewMedia.postView.post.apId, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.sharePostLocal, - icon: Icons.share_rounded, - label: l10n.sharePostLocal, - getSubtitleLabel: (context, postViewMedia) => LemmyClient.instance.generatePostUrl(postViewMedia.postView.post.id), - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.shareImage, - icon: Icons.image_rounded, - label: l10n.shareImage, - getSubtitleLabel: (context, postViewMedia) => postViewMedia.media.first.imageUrl, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.shareMedia, - icon: Icons.personal_video_rounded, - label: l10n.shareMediaLink, - getSubtitleLabel: (context, postViewMedia) => postViewMedia.media.first.mediaUrl, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.shareLink, - icon: Icons.link_rounded, - label: l10n.shareLink, - getSubtitleLabel: (context, postViewMedia) => postViewMedia.media.first.originalUrl, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.shareAdvanced, - icon: Icons.screen_share_rounded, - label: l10n.advanced, - getSubtitleLabel: (context, postViewMedia) => l10n.useAdvancedShareSheet, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.upvote, - label: l10n.upvote, - icon: Icons.arrow_upward_rounded, - getColor: (context) => context.read().state.upvoteColor.color, - getForegroundColor: (context, postView) => postView.myVote == 1 ? context.read().state.upvoteColor.color : null, - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.downvote, - label: l10n.downvote, - icon: Icons.arrow_downward_rounded, - getColor: (context) => context.read().state.downvoteColor.color, - getForegroundColor: (context, postView) => postView.myVote == -1 ? context.read().state.downvoteColor.color : null, - shouldShow: (context, commentView) => context.read().state.downvotesEnabled, - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.save, - label: l10n.save, - icon: Icons.star_border_rounded, - getColor: (context) => context.read().state.saveColor.color, - getForegroundColor: (context, postView) => postView.saved ? context.read().state.saveColor.color : null, - getOverrideIcon: (postView) => postView.saved ? Icons.star_rounded : null, - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.toggleRead, - label: l10n.toggelRead, - icon: Icons.mail_outline_outlined, - getColor: (context) => context.read().state.markReadColor.color, - getOverrideIcon: (postView) => postView.read ? Icons.mark_email_unread_rounded : Icons.mark_email_read_outlined, - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.hide, - label: l10n.hide, - getOverrideLabel: (context, postView) => postView.hidden == true ? l10n.unhide : l10n.hide, - icon: Icons.visibility_off_rounded, - getColor: (context) => context.read().state.hideColor.color, - getOverrideIcon: (postView) => postView.hidden == true ? Icons.visibility_rounded : Icons.visibility_off_rounded, - shouldEnable: (isUserLoggedIn) => isUserLoggedIn, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.share, - icon: Icons.share_rounded, - label: l10n.share, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.delete, - icon: Icons.delete_rounded, - label: l10n.delete, - getOverrideIcon: (postView) => postView.post.deleted ? Icons.restore_from_trash_rounded : Icons.delete_rounded, - getOverrideLabel: (context, postView) => postView.post.deleted ? l10n.restore : l10n.delete, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.moderatorActions, - icon: Icons.shield_rounded, - trailingIcon: Icons.chevron_right_rounded, - label: l10n.moderatorActions, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.moderatorLockPost, - icon: Icons.lock, - label: l10n.lockPost, - getOverrideIcon: (postView) => postView.post.locked ? Icons.lock_open_rounded : Icons.lock, - getOverrideLabel: (context, postView) => postView.post.locked ? l10n.unlockPost : l10n.lockPost, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.moderatorPinCommunity, - icon: Icons.push_pin_rounded, - label: l10n.pinToCommunity, - getOverrideIcon: (postView) => postView.post.featuredCommunity ? Icons.push_pin_rounded : Icons.push_pin_outlined, - getOverrideLabel: (context, postView) => postView.post.featuredCommunity ? l10n.unpinFromCommunity : l10n.pinToCommunity, - ), - ExtendedPostCardActions( - postCardAction: PostCardAction.moderatorRemovePost, - icon: Icons.delete_forever_rounded, - label: l10n.removePost, - getOverrideIcon: (postView) => postView.post.removed ? Icons.restore_from_trash_rounded : Icons.delete_forever_rounded, - getOverrideLabel: (context, postView) => postView.post.removed ? l10n.restorePost : l10n.removePost, - ) -]; - -enum PostActionBottomSheetPage { - general, - share, - moderator, - user, - community, - instance, -} - -void showPostActionBottomModalSheet( - BuildContext context, - PostViewMedia postViewMedia, { - PostActionBottomSheetPage page = PostActionBottomSheetPage.general, - void Function(int userId)? onBlockedUser, - void Function(int userId)? onBlockedCommunity, - void Function(int postId)? onPostHidden, -}) { - final bool isOwnPost = postViewMedia.postView.creator.id == context.read().state.account?.userId; - final bool isModerator = - context.read().state.moderates.any((CommunityModeratorView communityModeratorView) => communityModeratorView.community.id == postViewMedia.postView.community.id); - final int? currentUserId = context.read().state.account?.userId; - - // Generate the list of default actions for the general page - final List defaultPostCardActions = postCardActionItems - .where((extendedAction) => [ - PostCardAction.userActions, - PostCardAction.communityActions, - PostCardAction.instanceActions, - ].contains(extendedAction.postCardAction)) - .toList(); - - // Add the moderator actions submenu - if (isModerator) { - defaultPostCardActions.add(postCardActionItems.firstWhere((ExtendedPostCardActions extendedPostCardActions) => extendedPostCardActions.postCardAction == PostCardAction.moderatorActions)); - } - - // Generate the list of default multi actions - final List defaultMultiPostCardActions = postCardActionItems - .where((extendedAction) => [ - PostCardAction.upvote, - PostCardAction.downvote, - PostCardAction.save, - PostCardAction.toggleRead, - PostCardAction.hide, - PostCardAction.share, - if (isOwnPost) PostCardAction.delete, - ].contains(extendedAction.postCardAction)) - .toList(); - - // Remove hide if unsupported - if (defaultMultiPostCardActions.any((extendedAction) => extendedAction.postCardAction == PostCardAction.hide) && !LemmyClient.instance.supportsFeature(LemmyFeature.hidePosts)) { - defaultMultiPostCardActions.removeWhere((ExtendedPostCardActions postCardActionItem) => postCardActionItem.postCardAction == PostCardAction.hide); - } - - // Generate the list of moderator actions - final List moderatorPostCardActions = postCardActionItems - .where((extendedAction) => [ - PostCardAction.moderatorLockPost, - PostCardAction.moderatorPinCommunity, - PostCardAction.moderatorRemovePost, - ].contains(extendedAction.postCardAction)) - .toList(); - - // Generate the list of share actions - final List sharePostCardActions = postCardActionItems - .where((extendedAction) => [ - PostCardAction.sharePost, - PostCardAction.sharePostLocal, - PostCardAction.shareImage, - PostCardAction.shareMedia, - PostCardAction.shareLink, - PostCardAction.shareAdvanced, - ].contains(extendedAction.postCardAction)) - .toList(); - - // Remove the share link option if there is no link - // Or if the media link is the same as the external link - if (postViewMedia.media.isEmpty || - postViewMedia.media.first.mediaType == MediaType.text || - postViewMedia.media.first.originalUrl == postViewMedia.media.first.imageUrl || - postViewMedia.media.first.originalUrl == postViewMedia.media.first.mediaUrl) { - sharePostCardActions.removeWhere((extendedAction) => extendedAction.postCardAction == PostCardAction.shareLink); - } - - // Remove the share image option if there is no image - if (postViewMedia.media.isEmpty || postViewMedia.media.first.imageUrl?.isNotEmpty != true) { - sharePostCardActions.removeWhere((extendedAction) => extendedAction.postCardAction == PostCardAction.shareImage); - } - - // Remove the share media option if there is no media - if (postViewMedia.media.isEmpty || postViewMedia.media.first.mediaUrl?.isNotEmpty != true) { - sharePostCardActions.removeWhere((extendedAction) => extendedAction.postCardAction == PostCardAction.shareMedia); - } - - // Remove the share local option if it is the same as the original - if (postViewMedia.postView.post.apId == LemmyClient.instance.generatePostUrl(postViewMedia.postView.post.id)) { - sharePostCardActions.removeWhere((extendedAction) => extendedAction.postCardAction == PostCardAction.sharePostLocal); - } - - // Generate the list of user actions - final List userActions = postCardActionItems - .where((extendedAction) => [ - PostCardAction.visitProfile, - if (postViewMedia.postView.creator.id != currentUserId) PostCardAction.blockUser, - ].contains(extendedAction.postCardAction)) - .toList(); - - // Generate the list of community actions - final List communityActions = postCardActionItems - .where((extendedAction) => [ - PostCardAction.visitCommunity, - postViewMedia.postView.subscribed == SubscribedType.notSubscribed ? PostCardAction.subscribeToCommunity : PostCardAction.unsubscribeFromCommunity, - PostCardAction.blockCommunity, - ].contains(extendedAction.postCardAction)) - .toList(); - - // Hide the option to block a community if the user is subscribed to it - if (communityActions.any((extendedAction) => extendedAction.postCardAction == PostCardAction.blockCommunity) && postViewMedia.postView.subscribed != SubscribedType.notSubscribed) { - communityActions.removeWhere((ExtendedPostCardActions postCardActionItem) => postCardActionItem.postCardAction == PostCardAction.blockCommunity); - } - - // Generate the list of instance actions - final List instanceActions = postCardActionItems - .where((extendedAction) => [ - PostCardAction.visitCommunityInstance, - PostCardAction.blockCommunityInstance, - PostCardAction.visitUserInstance, - PostCardAction.blockUserInstance, - ].contains(extendedAction.postCardAction)) - .toList(); - - // Remove block if unsupported - if (instanceActions.any((extendedAction) => extendedAction.postCardAction == PostCardAction.blockCommunityInstance) && !LemmyClient.instance.supportsFeature(LemmyFeature.blockInstance)) { - instanceActions.removeWhere((ExtendedPostCardActions postCardActionItem) => postCardActionItem.postCardAction == PostCardAction.blockCommunityInstance); - } - if (instanceActions.any((extendedAction) => extendedAction.postCardAction == PostCardAction.blockUserInstance) && !LemmyClient.instance.supportsFeature(LemmyFeature.blockInstance)) { - instanceActions.removeWhere((ExtendedPostCardActions postCardActionItem) => postCardActionItem.postCardAction == PostCardAction.blockUserInstance); - } - - // Hide user block if user's instance is the same as the community' sinstance - bool areSameInstance = areCommunityAndUserOnSameInstance(postViewMedia.postView); - if (instanceActions.any((extendedAction) => extendedAction.postCardAction == PostCardAction.visitUserInstance) && areSameInstance) { - instanceActions.removeWhere((ExtendedPostCardActions postCardActionItem) => postCardActionItem.postCardAction == PostCardAction.visitUserInstance); - } - if (instanceActions.any((extendedAction) => extendedAction.postCardAction == PostCardAction.blockUserInstance) && areSameInstance) { - instanceActions.removeWhere((ExtendedPostCardActions postCardActionItem) => postCardActionItem.postCardAction == PostCardAction.blockUserInstance); - } - - showModalBottomSheet( - showDragHandle: true, - isScrollControlled: true, - context: context, - builder: (builderContext) => PostCardActionPicker( - postViewMedia: postViewMedia, - page: page, - postCardActions: { - PostActionBottomSheetPage.general: defaultPostCardActions, - PostActionBottomSheetPage.moderator: moderatorPostCardActions, - PostActionBottomSheetPage.share: sharePostCardActions, - PostActionBottomSheetPage.user: userActions, - PostActionBottomSheetPage.community: communityActions, - PostActionBottomSheetPage.instance: instanceActions, - }, - multiPostCardActions: {PostActionBottomSheetPage.general: defaultMultiPostCardActions}, - titles: { - PostActionBottomSheetPage.general: l10n.actions, - PostActionBottomSheetPage.moderator: l10n.moderatorActions, - PostActionBottomSheetPage.share: l10n.share, - PostActionBottomSheetPage.user: l10n.userActions, - PostActionBottomSheetPage.community: l10n.communityActions, - PostActionBottomSheetPage.instance: l10n.instanceActions, - }, - outerContext: context, - onBlockedUser: onBlockedUser, - onBlockedCommunity: onBlockedCommunity, - onPostHidden: onPostHidden, - ), - ); -} - -class PostCardActionPicker extends StatefulWidget { - /// The post - final PostViewMedia postViewMedia; - - /// This is the list of quick actions that are shown horizontally across the top of the sheet - final Map> multiPostCardActions; - - /// This is the set of full actions to display vertically in a list - final Map> postCardActions; - - /// This is the set of titles to show for each page - final Map titles; - - /// The current page - final PostActionBottomSheetPage page; - - /// The context from whoever invoked this sheet (useful for blocs that would otherwise be missing) - final BuildContext outerContext; - - /// Callback used to notify that we blocked a user - final void Function(int userId)? onBlockedUser; - - /// Callback used to notify that we blocked a community - final Function(int userId)? onBlockedCommunity; - - /// Callback used to notify that we hid a post - final Function(int postId)? onPostHidden; - - const PostCardActionPicker({ - super.key, - required this.postViewMedia, - required this.page, - required this.postCardActions, - required this.multiPostCardActions, - required this.titles, - required this.outerContext, - required this.onBlockedUser, - required this.onBlockedCommunity, - required this.onPostHidden, - }); - - @override - State createState() => _PostCardActionPickerState(); -} - -class _PostCardActionPickerState extends State { - PostActionBottomSheetPage? page; - - @override - void initState() { - super.initState(); - - BackButtonInterceptor.add(_handleBack); - } - - @override - void dispose() { - BackButtonInterceptor.remove(_handleBack); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - final bool isUserLoggedIn = context.read().state.isLoggedIn; - - return SingleChildScrollView( - child: AnimatedSize( - duration: const Duration(milliseconds: 100), - curve: Curves.easeInOut, - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - Semantics( - label: '${widget.titles[page ?? widget.page] ?? l10n.actions}, ${(page ?? widget.page) == PostActionBottomSheetPage.general ? '' : l10n.backButton}', - child: Padding( - padding: const EdgeInsets.only(left: 10, right: 10), - child: Material( - borderRadius: BorderRadius.circular(50), - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: (page ?? widget.page) == PostActionBottomSheetPage.general ? null : () => setState(() => page = PostActionBottomSheetPage.general), - child: Padding( - padding: const EdgeInsets.fromLTRB(12.0, 10, 16.0, 10.0), - child: Align( - alignment: Alignment.centerLeft, - child: Row( - children: [ - if ((page ?? widget.page) != PostActionBottomSheetPage.general) ...[ - const Icon(Icons.chevron_left, size: 30), - const SizedBox(width: 12), - ], - Semantics( - excludeSemantics: true, - child: Text( - widget.titles[page ?? widget.page] ?? l10n.actions, - style: theme.textTheme.titleLarge, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - // Post metadata chips - if ((page ?? PostActionBottomSheetPage.general) == PostActionBottomSheetPage.general) - Row( - children: [ - const SizedBox(width: 20), - LanguagePostCardMetaData(languageId: widget.postViewMedia.postView.post.languageId), - ], - ), - if (widget.multiPostCardActions[page ?? widget.page]?.isNotEmpty == true) - MultiPickerItem( - pickerItems: [ - ...widget.multiPostCardActions[page ?? widget.page]!.where((a) => a.shouldShow?.call(context, widget.postViewMedia.postView) ?? true).map( - (a) { - return PickerItemData( - label: a.getOverrideLabel?.call(context, widget.postViewMedia.postView) ?? a.label, - icon: a.getOverrideIcon?.call(widget.postViewMedia.postView) ?? a.icon, - backgroundColor: a.getColor?.call(context), - foregroundColor: a.getForegroundColor?.call(context, widget.postViewMedia.postView), - onSelected: (a.shouldEnable?.call(isUserLoggedIn) ?? true) ? () => onSelected(a.postCardAction) : null, - ); - }, - ), - ], - ), - if (widget.postCardActions[page ?? widget.page]?.isNotEmpty == true) - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: widget.postCardActions[page ?? widget.page]!.length, - itemBuilder: (BuildContext itemBuilderContext, int index) { - return PickerItem( - label: widget.postCardActions[page ?? widget.page]![index].getOverrideLabel?.call(context, widget.postViewMedia.postView) ?? - widget.postCardActions[page ?? widget.page]![index].label, - subtitle: widget.postCardActions[page ?? widget.page]![index].getSubtitleLabel?.call(context, widget.postViewMedia), - icon: widget.postCardActions[page ?? widget.page]![index].getOverrideIcon?.call(widget.postViewMedia.postView) ?? widget.postCardActions[page ?? widget.page]![index].icon, - trailingIcon: widget.postCardActions[page ?? widget.page]![index].trailingIcon, - onSelected: (widget.postCardActions[page ?? widget.page]![index].shouldEnable?.call(isUserLoggedIn) ?? true) - ? () => onSelected(widget.postCardActions[page ?? widget.page]![index].postCardAction) - : null, - ); - }, - ), - const SizedBox(height: 16.0), - ], - ), - ), - ), - ); - } - - void onSelected(PostCardAction postCardAction) async { - bool pop = true; - void Function() action; - - switch (postCardAction) { - case PostCardAction.visitCommunity: - action = () => onTapCommunityName(widget.outerContext, widget.postViewMedia.postView.community.id); - break; - case PostCardAction.userActions: - action = () => setState(() => page = PostActionBottomSheetPage.user); - pop = false; - break; - case PostCardAction.visitProfile: - action = () => navigateToFeedPage(widget.outerContext, feedType: FeedType.user, userId: widget.postViewMedia.postView.post.creatorId); - break; - case PostCardAction.visitCommunityInstance: - action = () => navigateToInstancePage(widget.outerContext, - instanceHost: fetchInstanceNameFromUrl(widget.postViewMedia.postView.community.actorId)!, instanceId: widget.postViewMedia.postView.community.instanceId); - break; - case PostCardAction.visitUserInstance: - action = () => navigateToInstancePage(widget.outerContext, - instanceHost: fetchInstanceNameFromUrl(widget.postViewMedia.postView.creator.actorId)!, instanceId: widget.postViewMedia.postView.creator.instanceId); - break; - case PostCardAction.sharePost: - action = () => Share.share(widget.postViewMedia.postView.post.apId); - break; - case PostCardAction.sharePostLocal: - action = () => Share.share(LemmyClient.instance.generatePostUrl(widget.postViewMedia.postView.post.id)); - break; - case PostCardAction.shareImage: - action = () async { - if (widget.postViewMedia.media.first.imageUrl != null) { - try { - // Try to get the cached image first - var media = await DefaultCacheManager().getFileFromCache(widget.postViewMedia.media.first.imageUrl!); - File? mediaFile = media?.file; - - if (media == null) { - // Tell user we're downloading the image - showSnackbar(AppLocalizations.of(widget.outerContext)!.downloadingMedia); - - // Download - mediaFile = await DefaultCacheManager().getSingleFile(widget.postViewMedia.media.first.imageUrl!); - } - - // Share - await Share.shareXFiles([XFile(mediaFile!.path)]); - } catch (e) { - // Tell the user that the download failed - showSnackbar(AppLocalizations.of(widget.outerContext)!.errorDownloadingMedia(e)); - } - } - }; - break; - case PostCardAction.shareMedia: - action = () => Share.share(widget.postViewMedia.media.first.mediaUrl!); - break; - case PostCardAction.shareLink: - action = () { - if (widget.postViewMedia.media.first.originalUrl != null) Share.share(widget.postViewMedia.media.first.originalUrl!); - }; - break; - case PostCardAction.shareAdvanced: - action = () => showAdvancedShareSheet(widget.outerContext, widget.postViewMedia); - break; - case PostCardAction.instanceActions: - action = () => setState(() => page = PostActionBottomSheetPage.instance); - pop = false; - break; - case PostCardAction.blockCommunityInstance: - action = () => widget.outerContext.read().add(InstanceActionEvent( - instanceAction: InstanceAction.block, - instanceId: widget.postViewMedia.postView.community.instanceId, - domain: fetchInstanceNameFromUrl(widget.postViewMedia.postView.community.actorId), - value: true, - )); - break; - case PostCardAction.blockUserInstance: - action = () => widget.outerContext.read().add(InstanceActionEvent( - instanceAction: InstanceAction.block, - instanceId: widget.postViewMedia.postView.creator.instanceId, - domain: fetchInstanceNameFromUrl(widget.postViewMedia.postView.creator.actorId), - value: true, - )); - break; - case PostCardAction.communityActions: - action = () => setState(() => page = PostActionBottomSheetPage.community); - pop = false; - break; - case PostCardAction.blockCommunity: - action = () { - widget.outerContext.read().add(CommunityActionEvent(communityAction: CommunityAction.block, communityId: widget.postViewMedia.postView.community.id, value: true)); - widget.onBlockedCommunity?.call(widget.postViewMedia.postView.community.id); - }; - break; - case PostCardAction.upvote: - action = () => widget.outerContext - .read() - .add(FeedItemActionedEvent(postAction: PostAction.vote, postId: widget.postViewMedia.postView.post.id, value: widget.postViewMedia.postView.myVote == 1 ? 0 : 1)); - break; - case PostCardAction.downvote: - action = () => widget.outerContext - .read() - .add(FeedItemActionedEvent(postAction: PostAction.vote, postId: widget.postViewMedia.postView.post.id, value: widget.postViewMedia.postView.myVote == -1 ? 0 : -1)); - break; - case PostCardAction.save: - action = () => - widget.outerContext.read().add(FeedItemActionedEvent(postAction: PostAction.save, postId: widget.postViewMedia.postView.post.id, value: !widget.postViewMedia.postView.saved)); - break; - case PostCardAction.toggleRead: - action = () => - widget.outerContext.read().add(FeedItemActionedEvent(postAction: PostAction.read, postId: widget.postViewMedia.postView.post.id, value: !widget.postViewMedia.postView.read)); - break; - case PostCardAction.hide: - action = () => widget.outerContext - .read() - .add(FeedItemActionedEvent(postAction: PostAction.hide, postId: widget.postViewMedia.postView.post.id, value: !(widget.postViewMedia.postView.hidden ?? false))); - widget.onPostHidden?.call(widget.postViewMedia.postView.post.id); - break; - case PostCardAction.share: - pop = false; - action = () => setState(() => page = PostActionBottomSheetPage.share); - break; - case PostCardAction.blockUser: - action = () { - widget.outerContext.read().add(UserActionEvent(userAction: UserAction.block, userId: widget.postViewMedia.postView.creator.id, value: true)); - widget.onBlockedCommunity?.call(widget.postViewMedia.postView.creator.id); - }; - break; - case PostCardAction.subscribeToCommunity: - action = () => widget.outerContext.read().add(CommunityActionEvent( - communityAction: CommunityAction.follow, - communityId: widget.postViewMedia.postView.community.id, - value: true, - )); - break; - case PostCardAction.unsubscribeFromCommunity: - action = () => widget.outerContext.read().add(CommunityActionEvent( - communityAction: CommunityAction.follow, - communityId: widget.postViewMedia.postView.community.id, - value: false, - )); - break; - case PostCardAction.delete: - action = () => widget.outerContext - .read() - .add(FeedItemActionedEvent(postAction: PostAction.delete, postId: widget.postViewMedia.postView.post.id, value: !widget.postViewMedia.postView.post.deleted)); - break; - case PostCardAction.moderatorActions: - action = () => setState(() => page = PostActionBottomSheetPage.moderator); - pop = false; - break; - case PostCardAction.moderatorLockPost: - action = () => widget.outerContext - .read() - .add(FeedItemActionedEvent(postAction: PostAction.lock, postId: widget.postViewMedia.postView.post.id, value: !widget.postViewMedia.postView.post.locked)); - break; - case PostCardAction.moderatorPinCommunity: - action = () => widget.outerContext - .read() - .add(FeedItemActionedEvent(postAction: PostAction.pinCommunity, postId: widget.postViewMedia.postView.post.id, value: !widget.postViewMedia.postView.post.featuredCommunity)); - break; - case PostCardAction.moderatorRemovePost: - action = () => showRemovePostReasonBottomSheet(widget.outerContext, widget.postViewMedia); - break; - } - - if (pop) { - Navigator.of(context).pop(); - } - - action(); - } - - FutureOr _handleBack(bool stopDefaultButtonEvent, RouteInfo routeInfo) { - if ((page ?? widget.page) != PostActionBottomSheetPage.general) { - setState(() => page = PostActionBottomSheetPage.general); - return true; - } - - return false; - } -} - -void onTapCommunityName(BuildContext context, int communityId) { - navigateToFeedPage(context, feedType: FeedType.community, communityId: communityId); -} - -void showRemovePostReasonBottomSheet(BuildContext context, PostViewMedia postViewMedia) { - showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: true, - builder: (_) => ReasonBottomSheet( - title: postViewMedia.postView.post.removed ? l10n.restorePost : l10n.removalReason, - submitLabel: postViewMedia.postView.post.removed ? l10n.restore : l10n.remove, - textHint: l10n.reason, - onSubmit: (String message) { - context.read().add( - FeedItemActionedEvent( - postAction: PostAction.remove, - postId: postViewMedia.postView.post.id, - value: { - 'remove': !postViewMedia.postView.post.removed, - 'reason': message, - }, - ), - ); - Navigator.of(context).pop(); - }, - ), - ); -} - -bool areCommunityAndUserOnSameInstance(PostView postView) { - String? communityInstance = fetchInstanceNameFromUrl(postView.community.actorId); - String? userInstance = fetchInstanceNameFromUrl(postView.creator.actorId); - return communityInstance == userInstance; -} diff --git a/lib/community/widgets/post_card.dart b/lib/community/widgets/post_card.dart index 0c5c0055d..b8b133eea 100644 --- a/lib/community/widgets/post_card.dart +++ b/lib/community/widgets/post_card.dart @@ -4,8 +4,9 @@ import 'package:flutter/services.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/community/enums/community_action.dart'; import 'package:thunder/community/utils/post_actions.dart'; -import 'package:thunder/community/utils/post_card_action_helpers.dart'; +import 'package:thunder/post/widgets/post_action_bottom_sheet.dart'; import 'package:thunder/community/widgets/post_card_view_comfortable.dart'; import 'package:thunder/community/widgets/post_card_view_compact.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; @@ -18,6 +19,7 @@ import 'package:thunder/feed/widgets/widgets.dart'; import 'package:thunder/post/enums/post_action.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; import 'package:thunder/post/utils/navigate_post.dart'; +import 'package:thunder/user/enums/user_action.dart'; class PostCard extends StatefulWidget { final PostViewMedia postViewMedia; @@ -263,9 +265,33 @@ class _PostCardState extends State { onLongPress: () => showPostActionBottomModalSheet( context, widget.postViewMedia, - onBlockedUser: (userId) => context.read().add(FeedDismissBlockedEvent(userId: userId)), - onBlockedCommunity: (communityId) => context.read().add(FeedDismissBlockedEvent(communityId: communityId)), - onPostHidden: (postId) => context.read().add(FeedDismissHiddenPostEvent(postId: postId)), + onAction: ({postAction, userAction, communityAction, required postViewMedia}) async { + if (postAction == null && userAction == null && communityAction == null) return; + + switch (postAction) { + case PostAction.hide: + context.read().add(FeedDismissHiddenPostEvent(postId: postViewMedia.postView.post.id)); + break; + default: + break; + } + + switch (userAction) { + case UserAction.block: + context.read().add(FeedDismissBlockedEvent(userId: postViewMedia.postView.creator.id)); + break; + default: + break; + } + + switch (communityAction) { + case CommunityAction.block: + context.read().add(FeedDismissBlockedEvent(communityId: postViewMedia.postView.community.id)); + break; + default: + break; + } + }, ), onTap: () async { PostView postView = widget.postViewMedia.postView; diff --git a/lib/community/widgets/post_card_view_comfortable.dart b/lib/community/widgets/post_card_view_comfortable.dart index 8875f97b2..656e1da8c 100644 --- a/lib/community/widgets/post_card_view_comfortable.dart +++ b/lib/community/widgets/post_card_view_comfortable.dart @@ -8,7 +8,9 @@ import 'package:lemmy_api_client/v3.dart'; import 'package:markdown/markdown.dart' hide Text; import 'package:thunder/account/bloc/account_bloc.dart'; -import 'package:thunder/community/utils/post_card_action_helpers.dart'; +import 'package:thunder/community/enums/community_action.dart'; +import 'package:thunder/post/enums/post_action.dart'; +import 'package:thunder/post/widgets/post_action_bottom_sheet.dart'; import 'package:thunder/community/widgets/post_card_actions.dart'; import 'package:thunder/community/widgets/post_card_metadata.dart'; import 'package:thunder/core/enums/font_scale.dart'; @@ -20,6 +22,7 @@ import 'package:thunder/feed/feed.dart'; import 'package:thunder/shared/media_view.dart'; import 'package:thunder/shared/text/scalable_text.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/user/enums/user_action.dart'; class PostCardViewComfortable extends StatelessWidget { final Function(int) onVoteAction; @@ -102,7 +105,7 @@ class PostCardViewComfortable extends StatelessWidget { final bool darkTheme = context.read().state.useDarkTheme; return Container( - color: indicateRead && postViewMedia.postView.read ? theme.colorScheme.onBackground.withOpacity(darkTheme ? 0.05 : 0.075) : null, + color: indicateRead && postViewMedia.postView.read ? theme.colorScheme.onSurface.withOpacity(darkTheme ? 0.05 : 0.075) : null, padding: const EdgeInsets.symmetric(vertical: 12.0), child: Column( mainAxisAlignment: MainAxisAlignment.start, @@ -348,10 +351,35 @@ class PostCardViewComfortable extends StatelessWidget { showPostActionBottomModalSheet( context, postViewMedia, - onBlockedUser: (userId) => context.read().add(FeedDismissBlockedEvent(userId: userId)), - onBlockedCommunity: (communityId) => context.read().add(FeedDismissBlockedEvent(communityId: communityId)), - onPostHidden: (postId) => context.read().add(FeedDismissHiddenPostEvent(postId: postId)), + onAction: ({postAction, userAction, communityAction, required postViewMedia}) async { + if (postAction == null && userAction == null && communityAction == null) return; + + switch (postAction) { + case PostAction.hide: + context.read().add(FeedDismissHiddenPostEvent(postId: postViewMedia.postView.post.id)); + break; + default: + break; + } + + switch (userAction) { + case UserAction.block: + context.read().add(FeedDismissBlockedEvent(userId: postViewMedia.postView.creator.id)); + break; + default: + break; + } + + switch (communityAction) { + case CommunityAction.block: + context.read().add(FeedDismissBlockedEvent(communityId: postViewMedia.postView.community.id)); + break; + default: + break; + } + }, ); + HapticFeedback.mediumImpact(); }), if (isUserLoggedIn) diff --git a/lib/feed/bloc/feed_bloc.dart b/lib/feed/bloc/feed_bloc.dart index f361da409..d17843a69 100644 --- a/lib/feed/bloc/feed_bloc.dart +++ b/lib/feed/bloc/feed_bloc.dart @@ -333,7 +333,15 @@ class FeedBloc extends Bloc { return emit(state.copyWith(status: FeedStatus.failure)); } case PostAction.report: - // TODO: Handle this case. + int existingPostViewMediaIndex = state.postViewMedias.indexWhere((PostViewMedia postViewMedia) => postViewMedia.postView.post.id == event.postId); + PostViewMedia postViewMedia = state.postViewMedias[existingPostViewMediaIndex]; + + try { + await reportPost(postViewMedia.postView.post.id, event.value); + return emit(state.copyWith(status: FeedStatus.success)); + } catch (e) { + return emit(state.copyWith(status: FeedStatus.failure)); + } case PostAction.lock: // Optimistically lock the post int existingPostViewMediaIndex = state.postViewMedias.indexWhere((PostViewMedia postViewMedia) => postViewMedia.postView.post.id == event.postId); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c5bbe0fa4..adb24a729 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -53,6 +53,10 @@ "@addAccountToSeeProfile": {}, "addAnonymousInstance": "Add Anonymous Instance", "@addAnonymousInstance": {}, + "addAsCommunityModerator": "Add as Community Moderator", + "@addAsCommunityModerator": { + "description": "Moderator action to add a user as community moderator" + }, "addDiscussionLanguage": "Add Language", "@addDiscussionLanguage": { "description": "Hint for text field to add language" @@ -147,6 +151,10 @@ "@backgroundCheckWarning": { "description": "Warning for enabling notifications" }, + "banFromCommunity": "Ban from Community", + "@banFromCommunity": { + "description": "Moderator action to ban a user from a community" + }, "bannedUser": "Banned User", "@bannedUser": { "description": "Short decription for moderator action to ban a user" @@ -669,6 +677,10 @@ "@deleteLocalPreferencesDescription": { "description": "Description for confirmation action to delete local preferences" }, + "deletePost": "Delete Post", + "@deletePost": { + "description": "Action for deleting a post" + }, "deleteUserLabelConfirmation": "Are you sure you want to delete the label?", "@deleteUserLabelConfirmation": { "description": "Confirmation message for deleting a label" @@ -1573,6 +1585,10 @@ "@permissionDeniedMessage": { "description": "Explanation for error when user denies OS permissions" }, + "pinPostToCommunity": "Pin Post to Community", + "@pinPostToCommunity": { + "description": "Action for pinning a post to a community (moderator action)" + }, "pinToCommunity": "Pin to Community", "@pinToCommunity": { "description": "Setting for pinning a post to a community (moderator action)" @@ -1581,6 +1597,14 @@ "@placeholderText": { "description": "Placeholder text for any previews. This comes from https://www.lipsum.com/" }, + "post": "Post", + "@post": { + "description": "Describes a single post" + }, + "postActions": "Post Actions", + "@postActions": { + "description": "Describes a given set of actions that can be performed on a post" + }, "postBehaviourSettings": "Posts", "@postBehaviourSettings": { "description": "Subcategory in Setting -> General" @@ -1717,6 +1741,10 @@ }, "reachedTheBottom": "No more items to load", "@reachedTheBottom": {}, + "read": "Read", + "@read": { + "description": "Indicates that a post has been read" + }, "readAll": "Read All", "@readAll": {}, "reason": "Reason", @@ -1745,6 +1773,10 @@ "@remove": {}, "removeAccount": "Remove Account", "@removeAccount": {}, + "removeAsCommunityModerator": "Remove as Community Moderator", + "@removeAsCommunityModerator": { + "description": "Moderator action to remove user as community moderator" + }, "removeFromFavorites": "Remove from favorites", "@removeFromFavorites": { "description": "Action to remove a community in drawer from favorites" @@ -1805,6 +1837,10 @@ "@report": {}, "reportComment": "Report Comment", "@reportComment": {}, + "reportPost": "Report Post", + "@reportPost": { + "description": "Action to report a post (moderator action)" + }, "reporter": "Reporter:", "@reporter": { "description": "Name of reporter that reported a post/comment" @@ -2251,6 +2287,10 @@ }, "subscriptions": "Subscriptions", "@subscriptions": {}, + "successfullyBannedUser": "Banned {username}", + "@successfullyBannedUser": { + "description": "Notification for successfully banning a user" + }, "successfullyBlocked": "Blocked.", "@successfullyBlocked": {}, "successfullyBlockedCommunity": "Blocked {communityName}", @@ -2259,6 +2299,10 @@ "@successfullyBlockedUser": { "description": "Notification for successfully blocking a user" }, + "successfullyUnbannedUser": "Unbanned {username}", + "@successfullyUnbannedUser": { + "description": "Notification for successfully unbanning a user" + }, "successfullyUnblocked": "Unblocked.", "@successfullyUnblocked": {}, "successfullyUnblockedCommunity": "Unblocked {communityName}", @@ -2461,6 +2505,10 @@ "@unableToRetrieveChangelog": { "description": "Error message for when we are unable to retrieve the changelog." }, + "unbanFromCommunity": "Unban from Community", + "@unbanFromCommunity": { + "description": "Moderator action to unban a user from a community" + }, "unbannedUser": "Unbanned User", "@unbannedUser": { "description": "Short decription for moderator action to unban a user" @@ -2473,10 +2521,22 @@ "@unblockCommunity": { "description": "Action to unblock a community" }, + "unblockCommunityInstance": "Unblock Community Instance", + "@unblockCommunityInstance": { + "description": "Action to unblock the instance of a given community" + }, "unblockInstance": "Unblock Instance", "@unblockInstance": { "description": "Tooltip for unblocking an instance" }, + "unblockUser": "Unblock User", + "@unblockUser": { + "description": "Action to unblock a user" + }, + "unblockUserInstance": "Unblock User Instance", + "@unblockUserInstance": { + "description": "Action to unblock the instance of a given user" + }, "understandEnable": "I Understand, Enable", "@understandEnable": { "description": "Action for acknowledging and enabling something" @@ -2523,6 +2583,10 @@ "@unpinFromCommunity": { "description": "Setting for unpinning a post from a community (moderator action)" }, + "unpinPostFromCommunity": "Unpin Post from Community", + "@unpinPostFromCommunity": { + "description": "Moderator action to unpin a post from a community" + }, "unreachable": "Unreachable", "@unreachable": { "description": "Describes an instance that is currently unreachable" diff --git a/lib/post/utils/post.dart b/lib/post/utils/post.dart index 4b80d24f1..d3a0815b6 100644 --- a/lib/post/utils/post.dart +++ b/lib/post/utils/post.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/auth/helpers/fetch_account.dart'; @@ -242,6 +243,23 @@ Future removePost(int postId, bool remove, String reason) async { return postResponse.postView.post.removed == remove; } +/// Logic to report a given post +Future reportPost(int postId, String reason) async { + final l10n = AppLocalizations.of(GlobalContext.context)!; + final account = await fetchActiveProfileAccount(); + final lemmy = LemmyClient.instance.lemmyApiV3; + + if (account?.jwt == null) throw Exception(l10n.userNotLoggedIn); + + PostReportResponse postReportResponse = await lemmy.run(CreatePostReport( + auth: account!.jwt!, + postId: postId, + reason: reason, + )); + + return postReportResponse; +} + /// Logic to vote on a post Future votePost(int postId, int score) async { Account? account = await fetchActiveProfileAccount(); diff --git a/lib/post/widgets/community_post_action_bottom_sheet.dart b/lib/post/widgets/community_post_action_bottom_sheet.dart new file mode 100644 index 000000000..b574233f6 --- /dev/null +++ b/lib/post/widgets/community_post_action_bottom_sheet.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lemmy_api_client/v3.dart'; + +import 'package:thunder/community/bloc/community_bloc.dart'; +import 'package:thunder/community/enums/community_action.dart'; +import 'package:thunder/core/auth/bloc/auth_bloc.dart'; +import 'package:thunder/core/models/post_view_media.dart'; +import 'package:thunder/feed/feed.dart'; +import 'package:thunder/post/enums/post_action.dart'; +import 'package:thunder/post/widgets/post_action_bottom_sheet.dart'; +import 'package:thunder/shared/bottom_sheet_action.dart'; +import 'package:thunder/shared/divider.dart'; +import 'package:thunder/thunder/thunder_icons.dart'; + +/// Defines the actions that can be taken on a community +enum CommunityPostAction { + viewCommunity(icon: Icons.home_work_rounded, permissionType: PermissionType.user, requiresAuthentication: false), + subscribeToCommunity(icon: Icons.add_circle_outline_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + unsubscribeFromCommunity(icon: Icons.remove_circle_outline_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + blockCommunity(icon: Icons.block_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + unblockCommunity(icon: Icons.block_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + ; + + String get name => switch (this) { + CommunityPostAction.viewCommunity => l10n.visitCommunity, + CommunityPostAction.subscribeToCommunity => l10n.subscribeToCommunity, + CommunityPostAction.unsubscribeFromCommunity => l10n.unsubscribeFromCommunity, + CommunityPostAction.blockCommunity => l10n.blockCommunity, + CommunityPostAction.unblockCommunity => l10n.unblockCommunity, + }; + + /// The icon to use for the action + final IconData icon; + + /// The permission type to use for the action + final PermissionType permissionType; + + /// Whether or not the action requires user authentication + final bool requiresAuthentication; + + const CommunityPostAction({required this.icon, required this.permissionType, required this.requiresAuthentication}); +} + +/// A bottom sheet that allows the user to perform actions on a community. +/// +/// Given a [postViewMedia] and a [onAction] callback, this widget will display a list of actions that can be taken on the community. +/// The [onAction] callback will be triggered when an action is performed. This is useful if the parent widget requires an updated [CommunityView]. +class CommunityPostActionBottomSheet extends StatefulWidget { + const CommunityPostActionBottomSheet({super.key, required this.postViewMedia, required this.onAction}); + + /// The post information + final PostViewMedia postViewMedia; + + /// Called when an action is selected + final Function(CommunityAction communityAction, CommunityView? communityView) onAction; + + @override + State createState() => _CommunityPostActionBottomSheetState(); +} + +class _CommunityPostActionBottomSheetState extends State { + CommunityAction? _communityAction; + + void performAction(CommunityPostAction action) { + switch (action) { + case CommunityPostAction.viewCommunity: + context.pop(); + navigateToFeedPage(context, feedType: FeedType.community, communityId: widget.postViewMedia.postView.community.id); + break; + case CommunityPostAction.subscribeToCommunity: + context.read().add(CommunityActionEvent(communityId: widget.postViewMedia.postView.community.id, communityAction: CommunityAction.follow, value: true)); + setState(() => _communityAction = CommunityAction.follow); + break; + case CommunityPostAction.unsubscribeFromCommunity: + context.read().add(CommunityActionEvent(communityId: widget.postViewMedia.postView.community.id, communityAction: CommunityAction.follow, value: false)); + break; + case CommunityPostAction.blockCommunity: + context.read().add(CommunityActionEvent(communityId: widget.postViewMedia.postView.community.id, communityAction: CommunityAction.block, value: true)); + setState(() => _communityAction = CommunityAction.block); + break; + case CommunityPostAction.unblockCommunity: + context.read().add(CommunityActionEvent(communityId: widget.postViewMedia.postView.community.id, communityAction: CommunityAction.block, value: false)); + break; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final authState = context.read().state; + + List userActions = CommunityPostAction.values.where((element) => element.permissionType == PermissionType.user).toList(); + List moderatorActions = CommunityPostAction.values.where((element) => element.permissionType == PermissionType.moderator).toList(); + // List adminActions = CommunityPostAction.values.where((element) => element.permissionType == PermissionType.admin).toList(); + + // final account = authState.getSiteResponse?.myUser?.localUserView.person; + final moderatedCommunities = authState.getSiteResponse?.myUser?.moderates ?? []; + final isModerator = moderatedCommunities.where((communityModeratorView) => communityModeratorView.community.actorId == widget.postViewMedia.postView.community.actorId).isNotEmpty; + // final isAdmin = authState.getSiteResponse?.admins.where((personView) => personView.person.actorId == account?.actorId).isNotEmpty ?? false; + + final isLoggedIn = authState.isLoggedIn; + final blockedCommunities = authState.getSiteResponse?.myUser?.communityBlocks ?? []; + + final isCommunityBlocked = blockedCommunities.where((cbv) => cbv.community.actorId == widget.postViewMedia.postView.community.actorId).isNotEmpty; + final isSubscribedToCommunity = widget.postViewMedia.postView.subscribed != SubscribedType.notSubscribed; + + if (!isLoggedIn) { + userActions = userActions.where((action) => action.requiresAuthentication == false).toList(); + } else { + if (isSubscribedToCommunity) { + userActions = userActions.where((action) => action != CommunityPostAction.subscribeToCommunity).toList(); + } else { + userActions = userActions.where((action) => action != CommunityPostAction.unsubscribeFromCommunity).toList(); + } + + if (isCommunityBlocked) { + userActions = userActions.where((action) => action != CommunityPostAction.blockCommunity).toList(); + } else { + userActions = userActions.where((action) => action != CommunityPostAction.unblockCommunity).toList(); + } + } + + return BlocListener( + listener: (context, state) { + if (state.status == CommunityStatus.success) { + context.pop(); + if (_communityAction != null) widget.onAction(_communityAction!, state.communityView); + setState(() => _communityAction = null); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...userActions + .map( + (communityPostAction) => BottomSheetAction( + leading: Icon(communityPostAction.icon), + title: communityPostAction.name, + onTap: () => performAction(communityPostAction), + ), + ) + .toList() as List, + if (isModerator && moderatorActions.isNotEmpty) ...[ + const ThunderDivider(sliver: false, padding: false), + ...moderatorActions + .map( + (communityPostAction) => BottomSheetAction( + leading: Icon(communityPostAction.icon), + trailing: Padding( + padding: const EdgeInsets.only(left: 1), + child: Icon( + Thunder.shield, + size: 20, + color: Color.alphaBlend(theme.colorScheme.primary.withOpacity(0.4), Colors.green), + ), + ), + title: communityPostAction.name, + onTap: () => performAction(communityPostAction), + ), + ) + .toList() as List, + ], + // if (isAdmin && adminActions.isNotEmpty) ...[ + // const ThunderDivider(sliver: false, padding: false), + // ...adminActions + // .map( + // (communityPostAction) => BottomSheetAction( + // leading: Icon(communityPostAction.icon), + // trailing: Padding( + // padding: const EdgeInsets.only(left: 1), + // child: Icon( + // Thunder.shield_crown, + // size: 20, + // color: Color.alphaBlend(theme.colorScheme.primary.withOpacity(0.4), Colors.red), + // ), + // ), + // title: communityPostAction.name, + // onTap: () => performAction(communityPostAction), + // ), + // ) + // .toList() as List, + // ], + ], + ), + ); + } +} diff --git a/lib/post/widgets/general_post_action_bottom_sheet.dart b/lib/post/widgets/general_post_action_bottom_sheet.dart new file mode 100644 index 000000000..108d83b72 --- /dev/null +++ b/lib/post/widgets/general_post_action_bottom_sheet.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:thunder/core/auth/bloc/auth_bloc.dart'; +import 'package:thunder/core/enums/full_name.dart'; +import 'package:thunder/core/models/post_view_media.dart'; +import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/feed/bloc/feed_bloc.dart'; +import 'package:thunder/post/enums/post_action.dart'; +import 'package:thunder/post/widgets/post_action_bottom_sheet.dart'; +import 'package:thunder/shared/bottom_sheet_action.dart'; +import 'package:thunder/shared/multi_picker_item.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/utils/instance.dart'; + +/// Defines the general actions that can be taken on a post +enum GeneralPostAction { + general(icon: Icons.more_horiz), + post(icon: Icons.splitscreen_rounded), + user(icon: Icons.person_rounded), + community(icon: Icons.people_rounded), + instance(icon: Icons.language_rounded), + share(icon: Icons.share); + + String get name => switch (this) { + GeneralPostAction.post => l10n.post, + GeneralPostAction.user => l10n.user, + GeneralPostAction.community => l10n.community, + GeneralPostAction.instance => l10n.instance(1), + GeneralPostAction.share => l10n.share, + GeneralPostAction.general => l10n.actions, + }; + + /// The title to use for the action. This is shown when the given page is active + String get title => switch (this) { + GeneralPostAction.post => l10n.postActions, + GeneralPostAction.user => l10n.userActions, + GeneralPostAction.community => l10n.communityActions, + GeneralPostAction.instance => l10n.instanceActions, + GeneralPostAction.share => l10n.share, + GeneralPostAction.general => l10n.actions, + }; + + /// The icon to use for the action + final IconData icon; + + const GeneralPostAction({required this.icon}); +} + +enum GeneralQuickPostAction { + upvote(enabledIcon: Icons.arrow_upward_rounded, disabledIcon: Icons.arrow_upward_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + downvote(enabledIcon: Icons.arrow_downward_rounded, disabledIcon: Icons.arrow_downward_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + save(enabledIcon: Icons.star_rounded, disabledIcon: Icons.star_outline_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + read(enabledIcon: Icons.mark_email_read_outlined, disabledIcon: Icons.mark_email_unread_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + hide(enabledIcon: Icons.visibility_off_rounded, disabledIcon: Icons.visibility_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + ; + + /// The icon to use for the action when it is enabled + final IconData enabledIcon; + + /// The icon to use for the action when it is disabled + final IconData disabledIcon; + + /// The permission type to use for the action + final PermissionType permissionType; + + /// Whether or not the action requires user authentication + final bool requiresAuthentication; + + const GeneralQuickPostAction({required this.enabledIcon, required this.disabledIcon, required this.permissionType, required this.requiresAuthentication}); +} + +/// Defines the general top-levelactions that can be taken on a post. +/// Given a [postViewMedia] and a [onSwitchActivePage] callback, this widget will display a list of actions that can be taken on the post. +class GeneralPostActionBottomSheetPage extends StatefulWidget { + const GeneralPostActionBottomSheetPage({super.key, required this.context, required this.postViewMedia, required this.onSwitchActivePage, required this.onAction}); + + /// The outer context + final BuildContext context; + + /// The post information + final PostViewMedia postViewMedia; + + /// Called when the active page is changed + final Function(GeneralPostAction page) onSwitchActivePage; + + /// Called when an action is selected + final Function(PostAction postAction, PostViewMedia? postViewMedia) onAction; + + @override + State createState() => _GeneralPostActionBottomSheetPageState(); +} + +class _GeneralPostActionBottomSheetPageState extends State { + String? generateSubtitle(GeneralPostAction page) { + PostViewMedia postViewMedia = widget.postViewMedia; + + String? communityInstance = fetchInstanceNameFromUrl(postViewMedia.postView.community.actorId); + String? userInstance = fetchInstanceNameFromUrl(postViewMedia.postView.creator.actorId); + + switch (page) { + case GeneralPostAction.user: + return generateUserFullName(context, postViewMedia.postView.creator.name, postViewMedia.postView.creator.displayName, fetchInstanceNameFromUrl(postViewMedia.postView.creator.actorId)); + case GeneralPostAction.community: + return generateCommunityFullName(context, postViewMedia.postView.community.name, postViewMedia.postView.community.title, fetchInstanceNameFromUrl(postViewMedia.postView.community.actorId)); + case GeneralPostAction.instance: + return (communityInstance == userInstance) ? '$communityInstance' : '$communityInstance • $userInstance'; + default: + return null; + } + } + + void performAction(GeneralQuickPostAction action) { + final postViewMedia = widget.postViewMedia; + + switch (action) { + case GeneralQuickPostAction.upvote: + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.vote, postId: postViewMedia.postView.post.id, value: postViewMedia.postView.myVote == 1 ? 0 : 1)); + break; + case GeneralQuickPostAction.downvote: + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.vote, postId: postViewMedia.postView.post.id, value: postViewMedia.postView.myVote == -1 ? 0 : -1)); + break; + case GeneralQuickPostAction.save: + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.save, postId: postViewMedia.postView.post.id, value: !postViewMedia.postView.saved)); + break; + case GeneralQuickPostAction.read: + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.read, postId: postViewMedia.postView.post.id, value: !postViewMedia.postView.read)); + break; + case GeneralQuickPostAction.hide: + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.hide, postId: postViewMedia.postView.post.id, value: postViewMedia.postView.hidden == true ? false : true)); + widget.onAction(PostAction.hide, postViewMedia); + break; + } + + context.pop(); + } + + IconData getIcon(GeneralQuickPostAction action) { + final postViewMedia = widget.postViewMedia; + + switch (action) { + case GeneralQuickPostAction.upvote: + return postViewMedia.postView.myVote == 1 ? GeneralQuickPostAction.upvote.enabledIcon : GeneralQuickPostAction.upvote.disabledIcon; + case GeneralQuickPostAction.downvote: + return postViewMedia.postView.myVote == -1 ? GeneralQuickPostAction.downvote.enabledIcon : GeneralQuickPostAction.downvote.disabledIcon; + case GeneralQuickPostAction.save: + return postViewMedia.postView.saved ? GeneralQuickPostAction.save.enabledIcon : GeneralQuickPostAction.save.disabledIcon; + case GeneralQuickPostAction.read: + return postViewMedia.postView.read ? GeneralQuickPostAction.read.enabledIcon : GeneralQuickPostAction.read.disabledIcon; + case GeneralQuickPostAction.hide: + return postViewMedia.postView.hidden == true ? GeneralQuickPostAction.hide.enabledIcon : GeneralQuickPostAction.hide.disabledIcon; + } + } + + String getLabel(GeneralQuickPostAction action) { + final postViewMedia = widget.postViewMedia; + + switch (action) { + case GeneralQuickPostAction.upvote: + return postViewMedia.postView.myVote == 1 ? l10n.upvoted : l10n.upvote; + case GeneralQuickPostAction.downvote: + return postViewMedia.postView.myVote == -1 ? l10n.downvoted : l10n.downvote; + case GeneralQuickPostAction.save: + return postViewMedia.postView.saved ? l10n.saved : l10n.save; + case GeneralQuickPostAction.read: + return postViewMedia.postView.read ? l10n.read : l10n.markAsRead; + case GeneralQuickPostAction.hide: + return postViewMedia.postView.hidden == true ? l10n.hidden : l10n.hide; + } + } + + Color? getForegroundColor(GeneralQuickPostAction action) { + final state = context.read().state; + final postViewMedia = widget.postViewMedia; + + switch (action) { + case GeneralQuickPostAction.upvote: + return postViewMedia.postView.myVote == 1 ? state.upvoteColor.color : null; + case GeneralQuickPostAction.downvote: + return postViewMedia.postView.myVote == -1 ? state.downvoteColor.color : null; + case GeneralQuickPostAction.save: + return postViewMedia.postView.saved ? state.saveColor.color : null; + case GeneralQuickPostAction.read: + return postViewMedia.postView.read ? state.markReadColor.color : null; + case GeneralQuickPostAction.hide: + return postViewMedia.postView.hidden == true ? state.hideColor.color : null; + } + } + + @override + Widget build(BuildContext context) { + final authState = context.read().state; + final isLoggedIn = authState.isLoggedIn; + + List quickActions = GeneralQuickPostAction.values.where((element) => element.permissionType == PermissionType.user).toList(); + + if (!isLoggedIn) { + quickActions = quickActions.where((action) => action.requiresAuthentication == false).toList(); + } else { + // Hide hidden if instance does not support it + if (!LemmyClient.instance.supportsFeature(LemmyFeature.hidePosts)) { + quickActions = quickActions.where((action) => action != GeneralQuickPostAction.hide).toList(); + } + + // Hide downvoted if instance does not support it + if (!authState.downvotesEnabled) { + quickActions = quickActions.where((action) => action != GeneralQuickPostAction.downvote).toList(); + } + } + + // Determine the available sub-menus to display + List submenus = GeneralPostAction.values.where((page) => page != GeneralPostAction.general).toList(); + + if (!isLoggedIn) { + submenus = submenus.where((action) => action != GeneralPostAction.post).toList(); + } + + return Column( + children: [ + if (quickActions.isNotEmpty) + MultiPickerItem( + pickerItems: GeneralQuickPostAction.values + .map((generalQuickPostAction) => PickerItemData( + icon: getIcon(generalQuickPostAction), + label: getLabel(generalQuickPostAction), + foregroundColor: getForegroundColor(generalQuickPostAction), + onSelected: isLoggedIn ? () => performAction(generalQuickPostAction) : null, + )) + .toList(), + ), + ...submenus + .map( + (page) => BottomSheetAction( + leading: Icon(page.icon), + trailing: const Icon(Icons.chevron_right_rounded), + title: page.name, + subtitle: generateSubtitle(page), + onTap: () => widget.onSwitchActivePage(page), + ), + ) + .toList() as List, + ], + ); + } +} diff --git a/lib/post/widgets/instance_post_action_bottom_sheet.dart b/lib/post/widgets/instance_post_action_bottom_sheet.dart new file mode 100644 index 000000000..9e379ba3c --- /dev/null +++ b/lib/post/widgets/instance_post_action_bottom_sheet.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:thunder/core/auth/bloc/auth_bloc.dart'; +import 'package:thunder/core/models/post_view_media.dart'; +import 'package:thunder/instance/bloc/instance_bloc.dart'; +import 'package:thunder/instance/enums/instance_action.dart'; +import 'package:thunder/instance/utils/navigate_instance.dart'; +import 'package:thunder/post/enums/post_action.dart'; +import 'package:thunder/post/utils/comment_action_helpers.dart'; +import 'package:thunder/shared/bottom_sheet_action.dart'; +// import 'package:thunder/shared/divider.dart'; +// import 'package:thunder/thunder/thunder_icons.dart'; +import 'package:thunder/utils/instance.dart'; + +/// Defines the actions that can be taken on a community +enum InstancePostAction { + visitCommunityInstance(icon: Icons.language_rounded, permissionType: PermissionType.user, requiresAuthentication: false), + blockCommunityInstance(icon: Icons.block_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + unblockCommunityInstance(icon: Icons.block_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + visitUserInstance(icon: Icons.language_rounded, permissionType: PermissionType.user, requiresAuthentication: false), + blockUserInstance(icon: Icons.block_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + unblockUserInstance(icon: Icons.block_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + ; + + String get name => switch (this) { + InstancePostAction.visitCommunityInstance => l10n.visitCommunityInstance, + InstancePostAction.blockCommunityInstance => l10n.blockCommunityInstance, + InstancePostAction.unblockCommunityInstance => l10n.unblockCommunityInstance, + InstancePostAction.visitUserInstance => l10n.visitUserInstance, + InstancePostAction.blockUserInstance => l10n.blockUserInstance, + InstancePostAction.unblockUserInstance => l10n.unblockUserInstance, + }; + + /// The icon to use for the action + final IconData icon; + + /// The permission type to use for the action + final PermissionType permissionType; + + /// Whether or not the action requires user authentication + final bool requiresAuthentication; + + const InstancePostAction({required this.icon, required this.permissionType, required this.requiresAuthentication}); +} + +/// A bottom sheet that allows the user to perform actions on a instance. +/// +/// Given a [postViewMedia] and a [onAction] callback, this widget will display a list of actions that can be taken on the instance. +class InstancePostActionBottomSheet extends StatefulWidget { + const InstancePostActionBottomSheet({super.key, required this.postViewMedia, required this.onAction}); + + /// The post information + final PostViewMedia postViewMedia; + + /// Called when an action is selected + final Function() onAction; + + @override + State createState() => _InstancePostActionBottomSheetState(); +} + +class _InstancePostActionBottomSheetState extends State { + void performAction(InstancePostAction action) { + switch (action) { + case InstancePostAction.visitCommunityInstance: + navigateToInstancePage(context, instanceHost: fetchInstanceNameFromUrl(widget.postViewMedia.postView.community.actorId)!, instanceId: widget.postViewMedia.postView.community.instanceId); + break; + case InstancePostAction.blockCommunityInstance: + context.read().add(InstanceActionEvent( + instanceAction: InstanceAction.block, + instanceId: widget.postViewMedia.postView.community.instanceId, + domain: fetchInstanceNameFromUrl(widget.postViewMedia.postView.community.actorId), + value: true, + )); + break; + case InstancePostAction.unblockCommunityInstance: + context.read().add(InstanceActionEvent( + instanceAction: InstanceAction.block, + instanceId: widget.postViewMedia.postView.community.instanceId, + domain: fetchInstanceNameFromUrl(widget.postViewMedia.postView.community.actorId), + value: false, + )); + break; + case InstancePostAction.visitUserInstance: + navigateToInstancePage(context, instanceHost: fetchInstanceNameFromUrl(widget.postViewMedia.postView.creator.actorId)!, instanceId: widget.postViewMedia.postView.creator.instanceId); + break; + case InstancePostAction.blockUserInstance: + context.read().add(InstanceActionEvent( + instanceAction: InstanceAction.block, + instanceId: widget.postViewMedia.postView.creator.instanceId, + domain: fetchInstanceNameFromUrl(widget.postViewMedia.postView.creator.actorId), + value: true, + )); + break; + case InstancePostAction.unblockUserInstance: + context.read().add(InstanceActionEvent( + instanceAction: InstanceAction.block, + instanceId: widget.postViewMedia.postView.creator.instanceId, + domain: fetchInstanceNameFromUrl(widget.postViewMedia.postView.creator.actorId), + value: false, + )); + break; + } + } + + @override + Widget build(BuildContext context) { + final authState = context.read().state; + + List userActions = InstancePostAction.values.where((element) => element.permissionType == PermissionType.user).toList(); + // List moderatorActions = InstancePostAction.values.where((element) => element.permissionType == PermissionType.moderator).toList(); + // List adminActions = InstancePostAction.values.where((element) => element.permissionType == PermissionType.admin).toList(); + + final account = authState.getSiteResponse?.myUser?.localUserView.person; + // final moderatedCommunities = authState.getSiteResponse?.myUser?.moderates ?? []; + // final isModerator = moderatedCommunities.where((communityModeratorView) => communityModeratorView.community.actorId == widget.postViewMedia.postView.community.actorId).isNotEmpty; + // final isAdmin = authState.getSiteResponse?.admins.where((personView) => personView.person.actorId == account?.actorId).isNotEmpty ?? false; + + final isLoggedIn = authState.isLoggedIn; + final blockedInstances = authState.getSiteResponse?.myUser?.instanceBlocks ?? []; + + final communityInstance = fetchInstanceNameFromUrl(widget.postViewMedia.postView.community.actorId); + final userInstance = fetchInstanceNameFromUrl(widget.postViewMedia.postView.creator.actorId); + final accountInstance = fetchInstanceNameFromUrl(account?.actorId); + + final isCommunityInstanceBlocked = blockedInstances.where((ibv) => ibv.instance.id == widget.postViewMedia.postView.community.instanceId).isNotEmpty; + final isUserInstanceBlocked = blockedInstances.where((ibv) => ibv.instance.id == widget.postViewMedia.postView.creator.instanceId).isNotEmpty; + + if (!isLoggedIn) { + userActions = userActions.where((action) => action.requiresAuthentication == false).toList(); + } else { + if (isCommunityInstanceBlocked) { + userActions = userActions.where((action) => action != InstancePostAction.blockCommunityInstance).toList(); + } else { + userActions = userActions.where((action) => action != InstancePostAction.unblockCommunityInstance).toList(); + } + + if (isUserInstanceBlocked) { + userActions = userActions.where((action) => action != InstancePostAction.blockUserInstance).toList(); + } else { + userActions = userActions.where((action) => action != InstancePostAction.unblockUserInstance).toList(); + } + } + + if (communityInstance == userInstance) { + userActions.removeWhere((action) => action == InstancePostAction.visitUserInstance || action == InstancePostAction.blockUserInstance); + } + + if (communityInstance == accountInstance) { + userActions.removeWhere((action) => action == InstancePostAction.blockCommunityInstance); + } + + if (userInstance == accountInstance) { + userActions.removeWhere((action) => action == InstancePostAction.blockUserInstance); + } + + return BlocListener( + listener: (context, state) { + if (state.status == InstanceStatus.success) { + context.pop(); + widget.onAction(); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...userActions + .map( + (instancePostAction) => BottomSheetAction( + leading: Icon(instancePostAction.icon), + subtitle: switch (instancePostAction) { + InstancePostAction.visitCommunityInstance => communityInstance, + InstancePostAction.blockCommunityInstance => communityInstance, + InstancePostAction.unblockCommunityInstance => communityInstance, + InstancePostAction.visitUserInstance => userInstance, + InstancePostAction.blockUserInstance => userInstance, + InstancePostAction.unblockUserInstance => userInstance, + }, + title: instancePostAction.name, + onTap: () => performAction(instancePostAction), + ), + ) + .toList() as List, + // if (isModerator && moderatorActions.isNotEmpty) ...[ + // const ThunderDivider(sliver: false, padding: false), + // ...moderatorActions + // .map( + // (instancePostAction) => BottomSheetAction( + // leading: Icon(instancePostAction.icon), + // trailing: Padding( + // padding: const EdgeInsets.only(left: 1), + // child: Icon( + // Thunder.shield, + // size: 20, + // color: Color.alphaBlend(theme.colorScheme.primary.withOpacity(0.4), Colors.green), + // ), + // ), + // title: instancePostAction.name, + // onTap: () => performAction(instancePostAction), + // ), + // ) + // .toList() as List, + // ], + // if (isAdmin && adminActions.isNotEmpty) ...[ + // const ThunderDivider(sliver: false, padding: false), + // ...adminActions + // .map( + // (instancePostAction) => BottomSheetAction( + // leading: Icon(instancePostAction.icon), + // trailing: Padding( + // padding: const EdgeInsets.only(left: 1), + // child: Icon( + // Thunder.shield_crown, + // size: 20, + // color: Color.alphaBlend(theme.colorScheme.primary.withOpacity(0.4), Colors.red), + // ), + // ), + // title: instancePostAction.name, + // onTap: () => performAction(instancePostAction), + // ), + // ) + // .toList() as List, + // ], + ], + ), + ); + } +} diff --git a/lib/post/widgets/post_action_bottom_sheet.dart b/lib/post/widgets/post_action_bottom_sheet.dart new file mode 100644 index 000000000..0c8cb449e --- /dev/null +++ b/lib/post/widgets/post_action_bottom_sheet.dart @@ -0,0 +1,186 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:back_button_interceptor/back_button_interceptor.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:lemmy_api_client/v3.dart'; + +import 'package:thunder/community/enums/community_action.dart'; +import 'package:thunder/community/widgets/post_card_metadata.dart'; +import 'package:thunder/core/enums/full_name.dart'; +import 'package:thunder/core/models/post_view_media.dart'; +import 'package:thunder/post/enums/post_action.dart'; +import 'package:thunder/post/widgets/community_post_action_bottom_sheet.dart'; +import 'package:thunder/post/widgets/general_post_action_bottom_sheet.dart'; +import 'package:thunder/post/widgets/instance_post_action_bottom_sheet.dart'; +import 'package:thunder/post/widgets/post_post_action_bottom_sheet.dart'; +import 'package:thunder/post/widgets/share_post_action_bottom_sheet.dart'; +import 'package:thunder/post/widgets/user_post_action_bottom_sheet.dart'; +import 'package:thunder/user/enums/user_action.dart'; +import 'package:thunder/utils/instance.dart'; +import 'package:thunder/utils/global_context.dart'; + +final l10n = AppLocalizations.of(GlobalContext.context)!; + +/// Programatically show the post action bottom sheet +void showPostActionBottomModalSheet( + BuildContext context, + PostViewMedia postViewMedia, { + GeneralPostAction page = GeneralPostAction.general, + void Function({PostAction? postAction, UserAction? userAction, CommunityAction? communityAction, required PostViewMedia postViewMedia})? onAction, +}) { + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => PostActionBottomSheet(context: context, postViewMedia: postViewMedia, onAction: onAction), + ); +} + +class PostActionBottomSheet extends StatefulWidget { + const PostActionBottomSheet({super.key, required this.context, required this.postViewMedia, this.initialPage = GeneralPostAction.general, required this.onAction}); + + /// The parent context + final BuildContext context; + + /// The post that is being acted on + final PostViewMedia postViewMedia; + + /// The initial page of the bottom sheet + final GeneralPostAction initialPage; + + /// The callback that is called when an action is performed + final void Function({PostAction? postAction, UserAction? userAction, CommunityAction? communityAction, required PostViewMedia postViewMedia})? onAction; + + @override + State createState() => _PostActionBottomSheetState(); +} + +class _PostActionBottomSheetState extends State { + GeneralPostAction currentPage = GeneralPostAction.general; + + FutureOr _handleBack(bool stopDefaultButtonEvent, RouteInfo routeInfo) { + if (currentPage != GeneralPostAction.general) { + setState(() => currentPage = GeneralPostAction.general); + return true; + } + + return false; + } + + @override + void initState() { + super.initState(); + currentPage = widget.initialPage; + BackButtonInterceptor.add(_handleBack); + } + + @override + void dispose() { + BackButtonInterceptor.remove(_handleBack); + super.dispose(); + } + + String? generateSubtitle(GeneralPostAction page) { + PostViewMedia postViewMedia = widget.postViewMedia; + + String? communityInstance = fetchInstanceNameFromUrl(postViewMedia.postView.community.actorId); + String? userInstance = fetchInstanceNameFromUrl(postViewMedia.postView.creator.actorId); + + switch (page) { + case GeneralPostAction.user: + return generateUserFullName(context, postViewMedia.postView.creator.name, postViewMedia.postView.creator.displayName, fetchInstanceNameFromUrl(postViewMedia.postView.creator.actorId)); + case GeneralPostAction.community: + return generateCommunityFullName(context, postViewMedia.postView.community.name, postViewMedia.postView.community.title, fetchInstanceNameFromUrl(postViewMedia.postView.community.actorId)); + case GeneralPostAction.instance: + return (communityInstance == userInstance) ? '$communityInstance' : '$communityInstance • $userInstance'; + default: + return null; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + Widget actions = switch (currentPage) { + GeneralPostAction.general => GeneralPostActionBottomSheetPage( + context: widget.context, + postViewMedia: widget.postViewMedia, + onSwitchActivePage: (page) => setState(() => currentPage = page), + onAction: (PostAction postAction, PostViewMedia? updatedPostViewMedia) { + widget.onAction?.call(postAction: postAction, postViewMedia: widget.postViewMedia); + }, + ), + GeneralPostAction.post => PostPostActionBottomSheet( + context: widget.context, + postViewMedia: widget.postViewMedia, + onAction: (PostAction postAction, PostViewMedia? updatedPostViewMedia) { + widget.onAction?.call(postAction: postAction, postViewMedia: widget.postViewMedia); + }, + ), + GeneralPostAction.user => UserPostActionBottomSheet( + context: widget.context, + postViewMedia: widget.postViewMedia, + onAction: (UserAction userAction, PersonView? updatedPersonView) { + widget.onAction?.call(userAction: userAction, postViewMedia: widget.postViewMedia); + }, + ), + GeneralPostAction.community => CommunityPostActionBottomSheet( + postViewMedia: widget.postViewMedia, + onAction: (CommunityAction communityAction, CommunityView? updatedCommunityView) { + widget.onAction?.call(communityAction: communityAction, postViewMedia: widget.postViewMedia); + }, + ), + GeneralPostAction.instance => InstancePostActionBottomSheet( + postViewMedia: widget.postViewMedia, + onAction: () {}, + ), + GeneralPostAction.share => SharePostActionBottomSheet( + context: widget.context, + postViewMedia: widget.postViewMedia, + onAction: () {}, + ), + }; + + return SafeArea( + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutCubicEmphasized, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + currentPage != GeneralPostAction.general + ? IconButton(onPressed: () => setState(() => currentPage = GeneralPostAction.general), icon: const Icon(Icons.chevron_left_rounded)) + : const SizedBox(width: 12.0), + Wrap( + direction: Axis.vertical, + children: [ + Text(currentPage.title, style: theme.textTheme.titleLarge), + if (currentPage != GeneralPostAction.general && currentPage != GeneralPostAction.share && currentPage != GeneralPostAction.post) Text(generateSubtitle(currentPage) ?? ''), + ], + ), + ], + ), + if (currentPage == GeneralPostAction.general) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: LanguagePostCardMetaData(languageId: widget.postViewMedia.postView.post.languageId), + ), + const SizedBox(height: 16.0), + actions, + ], + ), + ), + ), + ); + } +} diff --git a/lib/post/widgets/post_post_action_bottom_sheet.dart b/lib/post/widgets/post_post_action_bottom_sheet.dart new file mode 100644 index 000000000..c0da05752 --- /dev/null +++ b/lib/post/widgets/post_post_action_bottom_sheet.dart @@ -0,0 +1,372 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:swipeable_page_route/swipeable_page_route.dart'; +import 'package:thunder/account/bloc/account_bloc.dart'; +import 'package:thunder/account/models/account.dart'; +import 'package:thunder/community/pages/create_post_page.dart'; + +import 'package:thunder/core/auth/bloc/auth_bloc.dart'; +import 'package:thunder/core/auth/helpers/fetch_account.dart'; +import 'package:thunder/core/models/post_view_media.dart'; +import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/feed/bloc/feed_bloc.dart'; +import 'package:thunder/post/cubit/create_post_cubit.dart'; +import 'package:thunder/post/enums/post_action.dart'; +import 'package:thunder/post/utils/comment_action_helpers.dart'; +import 'package:thunder/post/utils/navigate_create_post.dart'; +import 'package:thunder/shared/bottom_sheet_action.dart'; +import 'package:thunder/shared/dialogs.dart'; +import 'package:thunder/shared/divider.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/thunder/thunder_icons.dart'; + +/// Defines the actions that can be taken on a user +/// TODO: Implement admin-level actions +enum PostPostAction { + reportPost(icon: Icons.flag_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + editPost(icon: Icons.edit_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + deletePost(icon: Icons.delete_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + restorePost(icon: Icons.restore_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + lockPost(icon: Icons.lock_rounded, permissionType: PermissionType.moderator, requiresAuthentication: true), + unlockPost(icon: Icons.lock_open_rounded, permissionType: PermissionType.moderator, requiresAuthentication: true), + removePost(icon: Icons.delete_rounded, permissionType: PermissionType.moderator, requiresAuthentication: true), + restorePostAsModerator(icon: Icons.restore_rounded, permissionType: PermissionType.moderator, requiresAuthentication: true), + pinPostToCommunity(icon: Icons.pin, permissionType: PermissionType.moderator, requiresAuthentication: true), + unpinPostFromCommunity(icon: Icons.pin, permissionType: PermissionType.moderator, requiresAuthentication: true), + // pinPostToInstance(icon: Icons.pin, permissionType: PermissionType.admin, requiresAuthentication: true), + // unpinPostFromInstance(icon: Icons.pin, permissionType: PermissionType.admin, requiresAuthentication: true), + ; + + String get name => switch (this) { + PostPostAction.reportPost => l10n.reportPost, + PostPostAction.editPost => l10n.editPost, + PostPostAction.deletePost => l10n.deletePost, + PostPostAction.restorePost => l10n.restorePost, + PostPostAction.lockPost => l10n.lockPost, + PostPostAction.unlockPost => l10n.unlockPost, + PostPostAction.removePost => l10n.removePost, + PostPostAction.restorePostAsModerator => l10n.restorePost, + PostPostAction.pinPostToCommunity => l10n.pinPostToCommunity, + PostPostAction.unpinPostFromCommunity => l10n.unpinPostFromCommunity, + // PostPostAction.pinPostToInstance => "Pin Post To Instance", + // PostPostAction.unpinPostFromInstance => "Unpin Post From Instance", + }; + + /// The icon to use for the action + final IconData icon; + + /// The permission type to use for the action + final PermissionType permissionType; + + /// Whether or not the action requires user authentication + final bool requiresAuthentication; + + const PostPostAction({required this.icon, required this.permissionType, required this.requiresAuthentication}); +} + +/// A bottom sheet that allows the user to perform actions on the post. +/// +/// Given a [postViewMedia] and a [onAction] callback, this widget will display a list of actions that can be taken on the post. +/// The [onAction] callback will be triggered when an action is performed. +class PostPostActionBottomSheet extends StatefulWidget { + const PostPostActionBottomSheet({super.key, required this.context, required this.postViewMedia, required this.onAction}); + + /// The outer context + final BuildContext context; + + /// The post information + final PostViewMedia postViewMedia; + + /// Called when an action is selected + final Function(PostAction postAction, PostViewMedia? postViewMedia) onAction; + + @override + State createState() => _PostPostActionBottomSheetState(); +} + +class _PostPostActionBottomSheetState extends State { + void performAction(PostPostAction action) async { + final postViewMedia = widget.postViewMedia; + + switch (action) { + case PostPostAction.reportPost: + showReportPostDialog(); + return; + case PostPostAction.editPost: + context.pop(); + + ThunderBloc thunderBloc = context.read(); + AccountBloc accountBloc = context.read(); + CreatePostCubit createPostCubit = CreatePostCubit(); + + final ThunderState thunderState = context.read().state; + final bool reduceAnimations = thunderState.reduceAnimations; + + Navigator.of(widget.context).push( + SwipeablePageRoute( + transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, + canOnlySwipeFromEdge: true, + backGestureDetectionWidth: 45, + builder: (context) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: thunderBloc), + BlocProvider.value(value: accountBloc), + BlocProvider.value(value: createPostCubit), + ], + child: CreatePostPage( + communityId: postViewMedia.postView.community.id, + // Create a stub for the community view. + communityView: CommunityView( + community: postViewMedia.postView.community, + subscribed: postViewMedia.postView.subscribed, + blocked: false, + counts: CommunityAggregates( + communityId: postViewMedia.postView.community.id, + subscribers: 0, + posts: 0, + comments: 0, + published: DateTime.now(), + usersActiveDay: 0, + usersActiveWeek: 0, + usersActiveMonth: 0, + usersActiveHalfYear: 0, + ), + ), + postView: postViewMedia.postView, + onPostSuccess: (PostViewMedia pvm, _) {}, + ), + ); + }, + ), + ); + + return; + case PostPostAction.deletePost: + context.pop(); + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.delete, postId: postViewMedia.postView.post.id, value: true)); + break; + case PostPostAction.restorePost: + context.pop(); + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.delete, postId: postViewMedia.postView.post.id, value: false)); + break; + case PostPostAction.lockPost: + context.pop(); + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.lock, postId: postViewMedia.postView.post.id, value: true)); + break; + case PostPostAction.unlockPost: + context.pop(); + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.lock, postId: postViewMedia.postView.post.id, value: false)); + break; + case PostPostAction.removePost: + showRemovePostReasonDialog(); + break; + case PostPostAction.restorePostAsModerator: + showRemovePostReasonDialog(); + break; + case PostPostAction.pinPostToCommunity: + context.pop(); + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.pinCommunity, postId: postViewMedia.postView.post.id, value: true)); + break; + case PostPostAction.unpinPostFromCommunity: + context.pop(); + widget.context.read().add(FeedItemActionedEvent(postAction: PostAction.pinCommunity, postId: postViewMedia.postView.post.id, value: false)); + break; + // case PostPostAction.pinPostToInstance: + // context.pop(); + // return; + // case PostPostAction.unpinPostFromInstance: + // context.pop(); + // return; + } + } + + void showReportPostDialog() { + context.pop(); + final TextEditingController messageController = TextEditingController(); + + showThunderDialog( + context: widget.context, + title: l10n.reportPost, + primaryButtonText: l10n.report(1), + onPrimaryButtonPressed: (dialogContext, setPrimaryButtonEnabled) { + widget.context.read().add( + FeedItemActionedEvent( + postAction: PostAction.report, + postId: widget.postViewMedia.postView.post.id, + value: messageController.text, + ), + ); + dialogContext.pop(); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (context) => context.pop(), + contentWidgetBuilder: (_) => TextFormField( + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + labelText: l10n.message(0), + ), + autofocus: true, + controller: messageController, + maxLines: 4, + ), + ); + } + + void showRemovePostReasonDialog() { + context.pop(); + final TextEditingController messageController = TextEditingController(); + + showThunderDialog( + context: widget.context, + title: widget.postViewMedia.postView.post.removed ? l10n.restorePost : l10n.removalReason, + primaryButtonText: widget.postViewMedia.postView.post.removed ? l10n.restore : l10n.remove, + onPrimaryButtonPressed: (dialogContext, setPrimaryButtonEnabled) { + widget.context.read().add( + FeedItemActionedEvent( + postAction: PostAction.remove, + postId: widget.postViewMedia.postView.post.id, + value: { + 'remove': !widget.postViewMedia.postView.post.removed, + 'reason': messageController.text, + }, + ), + ); + dialogContext.pop(); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (context) => context.pop(), + contentWidgetBuilder: (_) => TextFormField( + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + labelText: l10n.message(0), + ), + autofocus: true, + controller: messageController, + maxLines: 4, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final authState = context.read().state; + + List userActions = PostPostAction.values.where((element) => element.permissionType == PermissionType.user).toList(); + List moderatorActions = PostPostAction.values.where((element) => element.permissionType == PermissionType.moderator).toList(); + // List adminActions = PostPostAction.values.where((element) => element.permissionType == PermissionType.admin).toList(); + + final account = authState.getSiteResponse?.myUser?.localUserView.person; + final moderatedCommunities = authState.getSiteResponse?.myUser?.moderates ?? []; + final isModerator = moderatedCommunities.where((communityModeratorView) => communityModeratorView.community.actorId == widget.postViewMedia.postView.community.actorId).isNotEmpty; + // final isAdmin = authState.getSiteResponse?.admins.where((personView) => personView.person.actorId == account?.actorId).isNotEmpty ?? false; + + final isLoggedIn = authState.isLoggedIn; + final isPostLocked = widget.postViewMedia.postView.post.locked; + final isPostPinnedToCommunity = widget.postViewMedia.postView.post.featuredCommunity; // Pin to community + // final isPostPinnedToInstance = widget.postViewMedia.postView.post.featuredLocal; // Pin to instance + final isPostDeleted = widget.postViewMedia.postView.post.deleted; // Deleted by the user + final isPostRemoved = widget.postViewMedia.postView.post.removed; // Removed by a moderator + + if (!isLoggedIn) { + userActions = userActions.where((action) => action.requiresAuthentication == false).toList(); + } else { + if (account?.actorId == widget.postViewMedia.postView.creator.actorId) { + userActions = userActions.where((action) => action != PostPostAction.reportPost).toList(); + } else { + userActions = userActions.where((action) => action != PostPostAction.editPost && action != PostPostAction.deletePost && action != PostPostAction.restorePost).toList(); + } + + if (isPostDeleted) { + userActions = userActions.where((action) => action != PostPostAction.deletePost).toList(); + } else { + userActions = userActions.where((action) => action != PostPostAction.restorePost).toList(); + } + + if (isPostRemoved) { + moderatorActions = moderatorActions.where((action) => action != PostPostAction.removePost).toList(); + } else { + moderatorActions = moderatorActions.where((action) => action != PostPostAction.restorePostAsModerator).toList(); + } + + if (isPostLocked) { + moderatorActions = moderatorActions.where((action) => action != PostPostAction.lockPost).toList(); + } else { + moderatorActions = moderatorActions.where((action) => action != PostPostAction.unlockPost).toList(); + } + + if (isPostPinnedToCommunity) { + moderatorActions = moderatorActions.where((action) => action != PostPostAction.pinPostToCommunity).toList(); + } else { + moderatorActions = moderatorActions.where((action) => action != PostPostAction.unpinPostFromCommunity).toList(); + } + + // if (isPostPinnedToInstance) { + // adminActions = adminActions.where((action) => action != PostPostAction.pinPostToInstance).toList(); + // } else { + // adminActions = adminActions.where((action) => action != PostPostAction.unpinPostFromInstance).toList(); + // } + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...userActions + .map( + (postPostAction) => BottomSheetAction( + leading: Icon(postPostAction.icon), + title: postPostAction.name, + onTap: () => performAction(postPostAction), + ), + ) + .toList() as List, + if (isModerator && moderatorActions.isNotEmpty) ...[ + const ThunderDivider(sliver: false, padding: false), + ...moderatorActions + .map( + (postPostAction) => BottomSheetAction( + leading: Icon(postPostAction.icon), + trailing: Padding( + padding: const EdgeInsets.only(left: 1), + child: Icon( + Thunder.shield, + size: 20, + color: Color.alphaBlend(theme.colorScheme.primary.withOpacity(0.4), Colors.green), + ), + ), + title: postPostAction.name, + onTap: () => performAction(postPostAction), + ), + ) + .toList() as List, + ], + // if (isAdmin && adminActions.isNotEmpty) ...[ + // const ThunderDivider(sliver: false, padding: false), + // ...adminActions + // .map( + // (postPostAction) => BottomSheetAction( + // leading: Icon(postPostAction.icon), + // trailing: Padding( + // padding: const EdgeInsets.only(left: 1), + // child: Icon( + // Thunder.shield_crown, + // size: 20, + // color: Color.alphaBlend(theme.colorScheme.primary.withOpacity(0.4), Colors.red), + // ), + // ), + // title: postPostAction.name, + // onTap: () => performAction(postPostAction), + // ), + // ) + // .toList() as List, + // ], + ], + ); + } +} diff --git a/lib/post/widgets/post_view.dart b/lib/post/widgets/post_view.dart index e4ca8cb51..2031cfb59 100644 --- a/lib/post/widgets/post_view.dart +++ b/lib/post/widgets/post_view.dart @@ -17,8 +17,12 @@ import 'package:swipeable_page_route/swipeable_page_route.dart'; import 'package:thunder/account/bloc/account_bloc.dart'; import 'package:thunder/account/models/account.dart'; import 'package:thunder/comment/utils/navigate_comment.dart'; +import 'package:thunder/community/enums/community_action.dart'; import 'package:thunder/community/pages/create_post_page.dart'; -import 'package:thunder/community/utils/post_card_action_helpers.dart'; +import 'package:thunder/feed/bloc/feed_bloc.dart'; +import 'package:thunder/post/enums/post_action.dart'; +import 'package:thunder/post/widgets/general_post_action_bottom_sheet.dart'; +import 'package:thunder/post/widgets/post_action_bottom_sheet.dart'; import 'package:thunder/community/widgets/post_card_type_badge.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/auth/helpers/fetch_account.dart'; @@ -44,6 +48,7 @@ import 'package:thunder/shared/media_view.dart'; import 'package:thunder/shared/reply_to_preview_actions.dart'; import 'package:thunder/shared/text/scalable_text.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/user/enums/user_action.dart'; class PostSubview extends StatefulWidget { final PostViewMedia postViewMedia; @@ -297,8 +302,35 @@ class _PostSubviewState extends State with SingleTickerProviderStat onShare: () { showPostActionBottomModalSheet( context, - widget.postViewMedia, - page: PostActionBottomSheetPage.share, + postViewMedia, + page: GeneralPostAction.share, + onAction: ({postAction, userAction, communityAction, required postViewMedia}) async { + if (postAction == null && userAction == null && communityAction == null) return; + + switch (postAction) { + case PostAction.hide: + context.read().add(FeedDismissHiddenPostEvent(postId: postViewMedia.postView.post.id)); + break; + default: + break; + } + + switch (userAction) { + case UserAction.block: + context.read().add(FeedDismissBlockedEvent(userId: postViewMedia.postView.creator.id)); + break; + default: + break; + } + + switch (communityAction) { + case CommunityAction.block: + context.read().add(FeedDismissBlockedEvent(communityId: postViewMedia.postView.community.id)); + break; + default: + break; + } + }, ); }, onEdit: () async { diff --git a/lib/post/widgets/reason_bottom_sheet.dart b/lib/post/widgets/reason_bottom_sheet.dart deleted file mode 100644 index ec43384dd..000000000 --- a/lib/post/widgets/reason_bottom_sheet.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -class ReasonBottomSheet extends StatefulWidget { - const ReasonBottomSheet({super.key, this.title, this.textHint, this.submitLabel, this.errorMessage, required this.onSubmit}); - - /// A custom title of the bottom sheet. Defaults to "Reason" - final String? title; - - /// A custom text hint of the text field. Defaults to "Message" - final String? textHint; - - /// A custom label of the submit button. Defaults to "Submit" - final String? submitLabel; - - /// An error message to display - final String? errorMessage; - - /// Callback function which triggers when the submit button is pressed - final Function(String) onSubmit; - - @override - State createState() => _ReasonBottomSheetState(); -} - -class _ReasonBottomSheetState extends State { - late TextEditingController messageController; - - @override - void initState() { - messageController = TextEditingController(); - super.initState(); - } - - @override - void dispose() { - messageController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - - return Container( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 26.0, right: 16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - widget.title ?? l10n.reason, - style: theme.textTheme.titleLarge, - ), - ), - const SizedBox(height: 12), - TextFormField( - decoration: InputDecoration( - isDense: true, - border: const OutlineInputBorder(), - labelText: widget.textHint ?? l10n.message(0), - ), - autofocus: true, - controller: messageController, - maxLines: 4, - ), - const SizedBox(height: 12), - if (widget.errorMessage != null) - Text( - widget.errorMessage!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.cancel), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: widget.errorMessage != null ? null : () => widget.onSubmit(messageController.text), - child: Text(widget.submitLabel ?? l10n.submit), - ) - ], - ), - const SizedBox(height: 16.0), - ], - ), - ); - } -} diff --git a/lib/post/widgets/share_post_action_bottom_sheet.dart b/lib/post/widgets/share_post_action_bottom_sheet.dart new file mode 100644 index 000000000..cfb23e963 --- /dev/null +++ b/lib/post/widgets/share_post_action_bottom_sheet.dart @@ -0,0 +1,188 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:go_router/go_router.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'package:thunder/core/enums/media_type.dart'; +import 'package:thunder/core/models/post_view_media.dart'; +import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/instance/bloc/instance_bloc.dart'; +import 'package:thunder/post/enums/post_action.dart'; +import 'package:thunder/post/utils/comment_action_helpers.dart'; +import 'package:thunder/shared/advanced_share_sheet.dart'; +import 'package:thunder/shared/bottom_sheet_action.dart'; +import 'package:thunder/shared/snackbar.dart'; + +/// Defines the actions that can be taken on a post when sharing +enum SharePostAction { + sharePost(icon: Icons.share_rounded, permissionType: PermissionType.user, requiresAuthentication: false), + sharePostLocal(icon: Icons.share_rounded, permissionType: PermissionType.user, requiresAuthentication: false), + shareImage(icon: Icons.image_rounded, permissionType: PermissionType.user, requiresAuthentication: false), + shareMedia(icon: Icons.personal_video_rounded, permissionType: PermissionType.user, requiresAuthentication: false), + shareLink(icon: Icons.link_rounded, permissionType: PermissionType.user, requiresAuthentication: false), + shareAdvanced(icon: Icons.screen_share_rounded, permissionType: PermissionType.user, requiresAuthentication: false), + ; + + String get name => switch (this) { + SharePostAction.sharePost => l10n.sharePost, + SharePostAction.sharePostLocal => l10n.sharePostLocal, + SharePostAction.shareImage => l10n.shareImage, + SharePostAction.shareMedia => l10n.shareMediaLink, + SharePostAction.shareLink => l10n.shareLink, + SharePostAction.shareAdvanced => l10n.advanced, + }; + + /// The icon to use for the action + final IconData icon; + + /// The permission type to use for the action + final PermissionType permissionType; + + /// Whether or not the action requires user authentication + final bool requiresAuthentication; + + const SharePostAction({required this.icon, required this.permissionType, required this.requiresAuthentication}); +} + +/// A bottom sheet that allows the user to perform actions on a instance. +/// +/// Given a [postViewMedia] and a [onAction] callback, this widget will display a list of actions that can be taken on the instance. +class SharePostActionBottomSheet extends StatefulWidget { + const SharePostActionBottomSheet({super.key, required this.context, required this.postViewMedia, required this.onAction}); + + /// The parent context + final BuildContext context; + + /// The post information + final PostViewMedia postViewMedia; + + /// Called when an action is selected + final Function() onAction; + + @override + State createState() => _SharePostActionBottomSheetState(); +} + +class _SharePostActionBottomSheetState extends State { + void retrieveMedia(String? url) async { + if (url == null) return; + + try { + // Try to get the cached image first + var media = await DefaultCacheManager().getFileFromCache(url); + File? mediaFile = media?.file; + + if (media == null) { + showSnackbar(l10n.downloadingMedia); + mediaFile = await DefaultCacheManager().getSingleFile(url); + } + + await Share.shareXFiles([XFile(mediaFile!.path)]); + } catch (e) { + showSnackbar(l10n.errorDownloadingMedia(e)); + } + } + + void performAction(SharePostAction action) { + switch (action) { + case SharePostAction.sharePost: + Share.share(widget.postViewMedia.postView.post.apId); + break; + case SharePostAction.sharePostLocal: + Share.share(LemmyClient.instance.generatePostUrl(widget.postViewMedia.postView.post.id)); + break; + case SharePostAction.shareImage: + retrieveMedia(widget.postViewMedia.media.first.imageUrl!); + break; + case SharePostAction.shareMedia: + Share.share(widget.postViewMedia.media.first.mediaUrl!); + break; + case SharePostAction.shareLink: + if (widget.postViewMedia.media.first.originalUrl != null) Share.share(widget.postViewMedia.media.first.originalUrl!); + break; + case SharePostAction.shareAdvanced: + showAdvancedShareSheet(widget.context, widget.postViewMedia); + break; + default: + break; + } + } + + String? generateSubtitle(SharePostAction action) { + PostViewMedia postViewMedia = widget.postViewMedia; + + switch (action) { + case SharePostAction.sharePost: + return postViewMedia.postView.post.apId; + case SharePostAction.sharePostLocal: + return LemmyClient.instance.generatePostUrl(postViewMedia.postView.post.id); + case SharePostAction.shareImage: + return postViewMedia.media.first.imageUrl; + case SharePostAction.shareMedia: + return postViewMedia.media.first.mediaUrl; + case SharePostAction.shareLink: + return postViewMedia.media.first.originalUrl; + case SharePostAction.shareAdvanced: + return l10n.useAdvancedShareSheet; + default: + return null; + } + } + + @override + Widget build(BuildContext context) { + List userActions = SharePostAction.values.where((element) => element.permissionType == PermissionType.user).toList(); + + // Remove the share link option if there is no link or if the media link is the same as the external link + if (widget.postViewMedia.media.isEmpty || + widget.postViewMedia.media.first.mediaType == MediaType.text || + widget.postViewMedia.media.first.originalUrl == widget.postViewMedia.media.first.imageUrl || + widget.postViewMedia.media.first.originalUrl == widget.postViewMedia.media.first.mediaUrl) { + userActions.removeWhere((action) => action == SharePostAction.shareLink); + } + + // Remove the share image option if there is no image + if (widget.postViewMedia.media.isEmpty || widget.postViewMedia.media.first.imageUrl?.isNotEmpty != true) { + userActions.removeWhere((action) => action == SharePostAction.shareImage); + } + + // Remove the share media option if there is no media + if (widget.postViewMedia.media.isEmpty || widget.postViewMedia.media.first.mediaUrl?.isNotEmpty != true) { + userActions.removeWhere((action) => action == SharePostAction.shareMedia); + } + + // Remove the share local option if it is the same as the original + if (widget.postViewMedia.postView.post.apId == LemmyClient.instance.generatePostUrl(widget.postViewMedia.postView.post.id)) { + userActions.removeWhere((action) => action == SharePostAction.sharePostLocal); + } + + return BlocListener( + listener: (context, state) { + if (state.status == InstanceStatus.success) { + context.pop(); + widget.onAction(); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...userActions + .map( + (sharePostAction) => BottomSheetAction( + leading: Icon(sharePostAction.icon), + trailing: sharePostAction == SharePostAction.shareAdvanced ? const Icon(Icons.chevron_right_rounded) : null, + subtitle: generateSubtitle(sharePostAction), + title: sharePostAction.name, + onTap: () => performAction(sharePostAction), + ), + ) + .toList() as List, + ], + ), + ); + } +} diff --git a/lib/post/widgets/user_post_action_bottom_sheet.dart b/lib/post/widgets/user_post_action_bottom_sheet.dart new file mode 100644 index 000000000..1625e85e8 --- /dev/null +++ b/lib/post/widgets/user_post_action_bottom_sheet.dart @@ -0,0 +1,340 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lemmy_api_client/v3.dart'; + +import 'package:thunder/core/auth/bloc/auth_bloc.dart'; +import 'package:thunder/core/enums/user_type.dart'; +import 'package:thunder/core/models/post_view_media.dart'; +import 'package:thunder/feed/utils/utils.dart'; +import 'package:thunder/feed/view/feed_page.dart'; +import 'package:thunder/post/enums/post_action.dart'; +import 'package:thunder/post/utils/comment_action_helpers.dart'; +import 'package:thunder/shared/avatars/user_avatar.dart'; +import 'package:thunder/shared/bottom_sheet_action.dart'; +import 'package:thunder/shared/chips/user_chip.dart'; +import 'package:thunder/shared/dialogs.dart'; +import 'package:thunder/shared/divider.dart'; +import 'package:thunder/thunder/thunder_icons.dart'; +import 'package:thunder/user/bloc/user_bloc.dart'; +import 'package:thunder/user/enums/user_action.dart'; + +/// Defines the actions that can be taken on a user +/// TODO: Implement admin-level actions +enum UserPostAction { + viewProfile(icon: Icons.person_search_rounded, permissionType: PermissionType.user, requiresAuthentication: false), + blockUser(icon: Icons.block_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + unblockUser(icon: Icons.block_rounded, permissionType: PermissionType.user, requiresAuthentication: true), + banUserFromCommunity(icon: Icons.block, permissionType: PermissionType.moderator, requiresAuthentication: true), + unbanUserFromCommunity(icon: Icons.block, permissionType: PermissionType.moderator, requiresAuthentication: true), + addUserAsCommunityModerator(icon: Icons.person_add_rounded, permissionType: PermissionType.moderator, requiresAuthentication: true), + removeUserAsCommunityModerator(icon: Icons.person_remove_rounded, permissionType: PermissionType.moderator, requiresAuthentication: true), + // banUser(icon: Icons.block, permissionType: PermissionType.admin, requiresAuthentication: true), + // unbanUser(icon: Icons.block, permissionType: PermissionType.admin, requiresAuthentication: true), + // purgeUser(icon: Icons.delete_rounded, permissionType: PermissionType.admin, requiresAuthentication: true), + // addUserAsAdmin(icon: Icons.person_add_rounded, permissionType: PermissionType.admin, requiresAuthentication: true), + // removeUserAsAdmin(icon: Icons.person_remove_rounded, permissionType: PermissionType.admin, requiresAuthentication: true), + ; + + String get name => switch (this) { + UserPostAction.viewProfile => l10n.visitUserProfile, + UserPostAction.blockUser => l10n.blockUser, + UserPostAction.unblockUser => l10n.unblockUser, + UserPostAction.banUserFromCommunity => l10n.banFromCommunity, + UserPostAction.unbanUserFromCommunity => l10n.unbanFromCommunity, + UserPostAction.addUserAsCommunityModerator => l10n.addAsCommunityModerator, + UserPostAction.removeUserAsCommunityModerator => l10n.removeAsCommunityModerator, + // UserPostAction.banUser => "Ban From Instance", + // UserPostAction.unbanUser => "Unban User From Instance", + // UserPostAction.purgeUser => "Purge User", + // UserPostAction.addUserAsAdmin => "Add As Admin", + // UserPostAction.removeUserAsAdmin => "Remove As Admin", + }; + + /// The icon to use for the action + final IconData icon; + + /// The permission type to use for the action + final PermissionType permissionType; + + /// Whether or not the action requires user authentication + final bool requiresAuthentication; + + const UserPostAction({required this.icon, required this.permissionType, required this.requiresAuthentication}); +} + +/// A bottom sheet that allows the user to perform actions on a user. +/// +/// Given a [postViewMedia] and a [onAction] callback, this widget will display a list of actions that can be taken on the user. +/// The [onAction] callback will be triggered when an action is performed. This is useful if the parent widget requires an updated [PersonView]. +class UserPostActionBottomSheet extends StatefulWidget { + const UserPostActionBottomSheet({super.key, required this.context, required this.postViewMedia, required this.onAction}); + + /// The outer context + final BuildContext context; + + /// The post information + final PostViewMedia postViewMedia; + + /// Called when an action is selected + final Function(UserAction userAction, PersonView? personView) onAction; + + @override + State createState() => _UserPostActionBottomSheetState(); +} + +class _UserPostActionBottomSheetState extends State { + UserAction? _userAction; + + void performAction(UserPostAction action) { + switch (action) { + case UserPostAction.viewProfile: + context.pop(); + navigateToFeedPage(context, feedType: FeedType.user, userId: widget.postViewMedia.postView.creator.id); + break; + case UserPostAction.blockUser: + context.read().add(UserActionEvent(userId: widget.postViewMedia.postView.creator.id, userAction: UserAction.block, value: true)); + setState(() => _userAction = UserAction.block); + break; + case UserPostAction.unblockUser: + context.read().add(UserActionEvent(userId: widget.postViewMedia.postView.creator.id, userAction: UserAction.block, value: false)); + setState(() => _userAction = UserAction.block); + break; + case UserPostAction.banUserFromCommunity: + showBanUserDialog(); + break; + case UserPostAction.unbanUserFromCommunity: + context.read().add( + UserActionEvent( + userId: widget.postViewMedia.postView.creator.id, + userAction: UserAction.banFromCommunity, + value: false, + metadata: { + "communityId": widget.postViewMedia.postView.community.id, + }, + ), + ); + setState(() => _userAction = UserAction.banFromCommunity); + break; + case UserPostAction.addUserAsCommunityModerator: + context.read().add(UserActionEvent( + userId: widget.postViewMedia.postView.creator.id, + userAction: UserAction.addModerator, + value: true, + metadata: {"communityId": widget.postViewMedia.postView.community.id}, + )); + setState(() => _userAction = UserAction.addModerator); + break; + case UserPostAction.removeUserAsCommunityModerator: + context.read().add(UserActionEvent( + userId: widget.postViewMedia.postView.creator.id, + userAction: UserAction.addModerator, + value: false, + metadata: {"communityId": widget.postViewMedia.postView.community.id}, + )); + setState(() => _userAction = UserAction.addModerator); + break; + } + } + + void showBanUserDialog() { + /// The controller for the message + TextEditingController messageController = TextEditingController(); + + /// Whether or not the user data (posts and comments) should be removed from the community + bool removeData = false; + + showThunderDialog( + context: widget.context, + title: l10n.banFromCommunity, + primaryButtonText: "Ban", + onPrimaryButtonPressed: (dialogContext, setPrimaryButtonEnabled) { + widget.context.read().add( + UserActionEvent( + userId: widget.postViewMedia.postView.creator.id, + userAction: UserAction.banFromCommunity, + value: true, + metadata: { + "communityId": widget.postViewMedia.postView.community.id, + "reason": messageController.text, + "removeData": removeData, + }, + ), + ); + setState(() => _userAction = UserAction.banFromCommunity); + dialogContext.pop(); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (context) => context.pop(), + contentWidgetBuilder: (_) => StatefulBuilder( + builder: (context, setState) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UserChip( + person: widget.postViewMedia.postView.creator, + personAvatar: UserAvatar(person: widget.postViewMedia.postView.creator), + userGroups: const [UserType.op], + includeInstance: true, + ), + const SizedBox(height: 16.0), + TextFormField( + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + labelText: l10n.message(0), + ), + autofocus: true, + controller: messageController, + maxLines: 2, + ), + const SizedBox(height: 16.0), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Remove user data'), + Switch( + value: removeData, + onChanged: (value) { + setState(() => removeData = value); + }, + ), + ], + ) + ], + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final authState = context.read().state; + + List userActions = UserPostAction.values.where((element) => element.permissionType == PermissionType.user).toList(); + List moderatorActions = UserPostAction.values.where((element) => element.permissionType == PermissionType.moderator).toList(); + // List adminActions = UserPostAction.values.where((element) => element.permissionType == PermissionType.admin).toList(); + + final account = authState.getSiteResponse?.myUser?.localUserView.person; + final moderatedCommunities = authState.getSiteResponse?.myUser?.moderates ?? []; + final isModerator = moderatedCommunities.where((communityModeratorView) => communityModeratorView.community.actorId == widget.postViewMedia.postView.community.actorId).isNotEmpty; + // final isAdmin = authState.getSiteResponse?.admins.where((personView) => personView.person.actorId == account?.actorId).isNotEmpty ?? false; + + final isLoggedIn = authState.isLoggedIn; + final blockedUsers = authState.getSiteResponse?.myUser?.personBlocks ?? []; + + final isUserBlocked = blockedUsers.where((personBlockView) => personBlockView.person.actorId == widget.postViewMedia.postView.creator.actorId).isNotEmpty; + final isUserCommunityModerator = widget.postViewMedia.postView.creatorIsModerator ?? false; + final isUserBannedFromCommunity = widget.postViewMedia.postView.creatorBannedFromCommunity; + // final isUserBannedFromInstance = widget.postViewMedia.postView.creator.banned; + // final isUserAdmin = widget.postViewMedia.postView.creatorIsAdmin ?? false; + + if (!isLoggedIn) { + userActions = userActions.where((action) => action.requiresAuthentication == false).toList(); + } else { + if (account?.actorId == widget.postViewMedia.postView.creator.actorId) { + userActions = userActions.where((action) => action != UserPostAction.blockUser && action != UserPostAction.unblockUser).toList(); + } + + if (isUserBlocked) { + userActions = userActions.where((action) => action != UserPostAction.blockUser).toList(); + } else { + userActions = userActions.where((action) => action != UserPostAction.unblockUser).toList(); + } + + if (isUserCommunityModerator) { + moderatorActions = moderatorActions.where((action) => action != UserPostAction.addUserAsCommunityModerator).toList(); + moderatorActions = moderatorActions.where((action) => action != UserPostAction.banUserFromCommunity && action != UserPostAction.unbanUserFromCommunity).toList(); + } else { + moderatorActions = moderatorActions.where((action) => action != UserPostAction.removeUserAsCommunityModerator).toList(); + } + + if (isUserBannedFromCommunity) { + moderatorActions = moderatorActions.where((action) => action != UserPostAction.banUserFromCommunity).toList(); + } else { + moderatorActions = moderatorActions.where((action) => action != UserPostAction.unbanUserFromCommunity).toList(); + } + + // if (isUserBannedFromInstance) { + // adminActions = adminActions.where((action) => action != UserPostAction.banUser).toList(); + // } else { + // adminActions = adminActions.where((action) => action != UserPostAction.unbanUser).toList(); + // } + + // if (isUserAdmin) { + // adminActions = adminActions.where((action) => action != UserPostAction.addUserAsAdmin).toList(); + // } else { + // adminActions = adminActions.where((action) => action != UserPostAction.removeUserAsAdmin).toList(); + // } + } + + return BlocListener( + listener: (context, state) { + if (state.status == UserStatus.success) { + context.pop(); + if (_userAction != null) widget.onAction(_userAction!, state.personView); + setState(() => _userAction = null); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...userActions + .map( + (userPostAction) => BottomSheetAction( + leading: Icon(userPostAction.icon), + title: userPostAction.name, + onTap: () => performAction(userPostAction), + ), + ) + .toList() as List, + if (isModerator && moderatorActions.isNotEmpty) ...[ + const ThunderDivider(sliver: false, padding: false), + ...moderatorActions + .map( + (userPostAction) => BottomSheetAction( + leading: Icon(userPostAction.icon), + trailing: Padding( + padding: const EdgeInsets.only(left: 1), + child: Icon( + Thunder.shield, + size: 20, + color: Color.alphaBlend(theme.colorScheme.primary.withOpacity(0.4), Colors.green), + ), + ), + title: userPostAction.name, + onTap: () => performAction(userPostAction), + ), + ) + .toList() as List, + ], + // if (isAdmin && adminActions.isNotEmpty) ...[ + // const ThunderDivider(sliver: false, padding: false), + // ...adminActions + // .map( + // (userPostAction) => BottomSheetAction( + // leading: Icon(userPostAction.icon), + // trailing: Padding( + // padding: const EdgeInsets.only(left: 1), + // child: Icon( + // Thunder.shield_crown, + // size: 20, + // color: Color.alphaBlend(theme.colorScheme.primary.withOpacity(0.4), Colors.red), + // ), + // ), + // title: userPostAction.name, + // onTap: () => performAction(userPostAction), + // ), + // ) + // .toList() as List, + // ], + ], + ), + ); + } +} diff --git a/lib/settings/pages/accessibility_settings_page.dart b/lib/settings/pages/accessibility_settings_page.dart index 4e6c1f074..a8bfeaacd 100644 --- a/lib/settings/pages/accessibility_settings_page.dart +++ b/lib/settings/pages/accessibility_settings_page.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:thunder/community/utils/post_card_action_helpers.dart'; +import 'package:thunder/post/widgets/post_action_bottom_sheet.dart'; import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/core/singletons/preferences.dart'; @@ -136,7 +136,7 @@ class _AccessibilitySettingsPageState extends State w child: Text( AppLocalizations.of(context)!.accessibilityProfilesDescription, style: TextStyle( - color: theme.colorScheme.onBackground.withOpacity(0.75), + color: theme.colorScheme.onSurface.withOpacity(0.75), ), ), ), diff --git a/lib/shared/bottom_sheet_action.dart b/lib/shared/bottom_sheet_action.dart new file mode 100644 index 000000000..b36d864a1 --- /dev/null +++ b/lib/shared/bottom_sheet_action.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +/// Defines a widget that can be used in a [BottomSheet]. Can provide optional [leading] and [trailing] widgets. +/// +/// When tapped, will call the [onTap] callback. +class BottomSheetAction extends StatelessWidget { + const BottomSheetAction({super.key, required this.leading, this.trailing, required this.title, this.subtitle, required this.onTap}); + + /// The leading widget + final Widget leading; + + /// The trailing widget + final Widget? trailing; + + /// The title of the category + final String title; + + /// The subtitle of the category + final String? subtitle; + + /// Callback function to be called when the category is tapped + final Function() onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + onTap: onTap, + customBorder: const StadiumBorder(), + child: ListTile( + leading: leading, + trailing: trailing, + title: Text( + title, + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), + ), + subtitle: subtitle != null + ? Text( + subtitle ?? '', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.textTheme.bodyMedium?.color?.withOpacity(0.8)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + ), + ); + } +} diff --git a/lib/user/bloc/user_bloc.dart b/lib/user/bloc/user_bloc.dart index ca67172a3..8775b2f95 100644 --- a/lib/user/bloc/user_bloc.dart +++ b/lib/user/bloc/user_bloc.dart @@ -1,5 +1,6 @@ import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -63,6 +64,52 @@ class UserBloc extends Bloc { return emit(state.copyWith(status: UserStatus.failure)); } break; + case UserAction.banFromCommunity: + try { + assert(event.metadata != null); + assert(event.metadata!.containsKey('communityId')); + + int communityId = event.metadata!['communityId'] as int; + String? reason = event.metadata?['reason']; + int? expires = event.metadata?['expires']; + bool removeData = event.metadata?['removeData'] ?? false; + + if (expires != null) { + // Convert from milliseconds to seconds + expires = expires ~/ 1000; + } + + BanFromCommunityResponse banFromCommunityResponse = await banUserFromCommunity(event.userId, event.value, communityId: communityId, reason: reason, expires: expires, removeData: removeData); + + emit(state.copyWith( + status: UserStatus.success, + personView: banFromCommunityResponse.personView, + message: banFromCommunityResponse.banned + ? l10n.successfullyBannedUser(banFromCommunityResponse.personView.person.name) + : l10n.successfullyUnbannedUser(banFromCommunityResponse.personView.person.name), + )); + } catch (e) { + return emit(state.copyWith(status: UserStatus.failure, message: e.toString())); + } + break; + case UserAction.addModerator: + try { + assert(event.metadata != null); + assert(event.metadata!.containsKey('communityId')); + + int communityId = event.metadata!['communityId'] as int; + + AddModToCommunityResponse addModToCommunityResponse = await addModerator(event.userId, event.value, communityId: communityId); + CommunityModeratorView? communityModeratorView = addModToCommunityResponse.moderators.firstWhereOrNull((communityModeratorView) => communityModeratorView.moderator.id == event.userId); + + emit(state.copyWith( + status: UserStatus.success, + message: communityModeratorView != null ? 'Successfully added moderator' : 'Successfully removed moderator', + )); + } catch (e) { + return emit(state.copyWith(status: UserStatus.failure)); + } + break; } } } diff --git a/lib/user/bloc/user_event.dart b/lib/user/bloc/user_event.dart index 27d202ff7..3ca080b98 100644 --- a/lib/user/bloc/user_event.dart +++ b/lib/user/bloc/user_event.dart @@ -18,7 +18,10 @@ final class UserActionEvent extends UserEvent { /// TODO: Change the dynamic type to the correct type(s) if possible final dynamic value; - const UserActionEvent({required this.userId, required this.userAction, this.value}); + /// Additional metadata to attach to the action. This is used for actions such as banning a user + final Map? metadata; + + const UserActionEvent({required this.userId, required this.userAction, this.value, this.metadata}); } final class UserClearMessageEvent extends UserEvent {} diff --git a/lib/user/enums/user_action.dart b/lib/user/enums/user_action.dart index 92969ba15..84e06fa95 100644 --- a/lib/user/enums/user_action.dart +++ b/lib/user/enums/user_action.dart @@ -2,10 +2,11 @@ import 'package:thunder/post/enums/post_action.dart'; enum UserAction { /// User level user actions - block(permissionType: PermissionType.user); + block(permissionType: PermissionType.user), /// Moderator level user actions - // ban(permissionType: PermissionType.moderator), + addModerator(permissionType: PermissionType.moderator), + banFromCommunity(permissionType: PermissionType.moderator); /// Admin level user actions // purge(permissionType: PermissionType.admin); diff --git a/lib/user/utils/user.dart b/lib/user/utils/user.dart index 926bfe13b..124c2a2bd 100644 --- a/lib/user/utils/user.dart +++ b/lib/user/utils/user.dart @@ -1,8 +1,10 @@ import 'package:lemmy_api_client/v3.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/auth/helpers/fetch_account.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/utils/global_context.dart'; /// Logic to block a user Future blockUser(int userId, bool block) async { @@ -19,3 +21,45 @@ Future blockUser(int userId, bool block) async { return blockPersonResponse; } + +/// Logic to ban a user from a community +/// +/// Can optionally provide a reason and expiration date (in seconds) +/// If [removeData] is true, posts and comments from the user will also be deleted +Future banUserFromCommunity(int userId, bool ban, {required int communityId, String? reason, int? expires, bool removeData = false}) async { + final l10n = AppLocalizations.of(GlobalContext.context)!; + final account = await fetchActiveProfileAccount(); + final lemmy = LemmyClient.instance.lemmyApiV3; + + if (account?.jwt == null) throw Exception(l10n.userNotLoggedIn); + + BanFromCommunityResponse banFromCommunityResponse = await lemmy.run(BanFromCommunity( + auth: account!.jwt!, + communityId: communityId, + personId: userId, + ban: ban, + removeData: removeData, + reason: reason, + expires: expires, + )); + + return banFromCommunityResponse; +} + +/// Logic to add or remove moderator for a given community (moderator action) +Future addModerator(int userId, bool added, {required int communityId}) async { + final l10n = AppLocalizations.of(GlobalContext.context)!; + final account = await fetchActiveProfileAccount(); + final lemmy = LemmyClient.instance.lemmyApiV3; + + if (account?.jwt == null) throw Exception(l10n.userNotLoggedIn); + + AddModToCommunityResponse addModToCommunityResponse = await lemmy.run(AddModToCommunity( + auth: account!.jwt!, + communityId: communityId, + personId: userId, + added: added, + )); + + return addModToCommunityResponse; +}