From cd59e7a00a8a4566e22b30fb94a6f5f0a6eb41b6 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Fri, 3 Nov 2023 11:28:02 -0400 Subject: [PATCH] Add comments as supported search type (#861) --- lib/l10n/app_en.arb | 2 + lib/search/bloc/search_bloc.dart | 79 ++++++++++++++++- lib/search/bloc/search_event.dart | 14 +++ lib/search/bloc/search_state.dart | 6 +- lib/search/pages/search_page.dart | 132 ++++++++++++++++++++++++++++- lib/search/utils/search_utils.dart | 20 +++++ 6 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 lib/search/utils/search_utils.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3e5b8e304..3bccef8ce 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -141,8 +141,10 @@ "@removeInstance": {}, "searchCommunitiesFederatedWith": "Search for communities federated with {instance}", "searchUsersFederatedWith": "Search for users federated with {instance}", + "searchCommentsFederatedWith": "Search for comments federated with {instance}", "noCommunitiesFound": "No communities found", "noUsersFound": "No users found", + "noCommentsFound": "No comments found", "allPosts": "All Posts", "clearSearch": "Clear Search", "selectSearchType": "Select Search Type", diff --git a/lib/search/bloc/search_bloc.dart b/lib/search/bloc/search_bloc.dart index c3e4fc106..d6d59664b 100644 --- a/lib/search/bloc/search_bloc.dart +++ b/lib/search/bloc/search_bloc.dart @@ -4,17 +4,23 @@ import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:stream_transform/stream_transform.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:collection/collection.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/search/utils/search_utils.dart'; +import 'package:thunder/utils/comment.dart'; +import 'package:thunder/utils/global_context.dart'; import 'package:thunder/utils/instance.dart'; part 'search_event.dart'; part 'search_state.dart'; const throttleDuration = Duration(milliseconds: 300); +const timeout = Duration(seconds: 10); EventTransformer throttleDroppable(Duration duration) { return (events, mapper) => droppable().call(events.throttle(duration), mapper); @@ -46,6 +52,14 @@ class SearchBloc extends Bloc { _getTrendingCommunitiesEvent, transformer: throttleDroppable(throttleDuration), ); + on( + _voteCommentEvent, + transformer: throttleDroppable(Duration.zero), // Don't give a throttle on vote + ); + on( + _saveCommentEvent, + transformer: throttleDroppable(Duration.zero), // Don't give a throttle on save + ); } Future _resetSearch(ResetSearch event, Emitter emit) async { @@ -57,6 +71,10 @@ class SearchBloc extends Bloc { try { emit(state.copyWith(status: SearchStatus.loading)); + if (event.query.isEmpty) { + return emit(state.copyWith(status: SearchStatus.initial)); + } + Account? account = await fetchActiveProfileAccount(); LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; @@ -111,7 +129,7 @@ class SearchBloc extends Bloc { } } - return emit(state.copyWith(status: SearchStatus.success, communities: searchResponse.communities, users: searchResponse.users, page: 2)); + return emit(state.copyWith(status: SearchStatus.success, communities: searchResponse.communities, users: searchResponse.users, comments: searchResponse.comments, page: 2)); } catch (e) { return emit(state.copyWith(status: SearchStatus.failure, errorMessage: e.toString())); } @@ -125,7 +143,7 @@ class SearchBloc extends Bloc { while (attemptCount < 2) { try { - emit(state.copyWith(status: SearchStatus.refreshing, communities: state.communities, users: state.users)); + emit(state.copyWith(status: SearchStatus.refreshing, communities: state.communities, users: state.users, comments: state.comments)); Account? account = await fetchActiveProfileAccount(); LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; @@ -138,15 +156,16 @@ class SearchBloc extends Bloc { sort: event.sortType, )); - if ((event.searchType == SearchType.communities && searchResponse.communities.isEmpty) || event.searchType == SearchType.users && searchResponse.users.isEmpty) { + if (searchIsEmpty(event.searchType, searchResponse: searchResponse)) { return emit(state.copyWith(status: SearchStatus.done)); } // Append the search results state.communities = [...state.communities ?? [], ...searchResponse.communities]; state.users = [...state.users ?? [], ...searchResponse.users]; + state.comments = [...state.comments ?? [], ...searchResponse.comments]; - return emit(state.copyWith(status: SearchStatus.success, communities: state.communities, users: state.users, page: state.page + 1)); + return emit(state.copyWith(status: SearchStatus.success, communities: state.communities, users: state.users, comments: state.comments, page: state.page + 1)); } catch (e) { exception = e; attemptCount++; @@ -266,4 +285,56 @@ class SearchBloc extends Bloc { // Not the end of the world if we can't load trending } } + + Future _voteCommentEvent(VoteCommentEvent event, Emitter emit) async { + final AppLocalizations l10n = AppLocalizations.of(GlobalContext.context)!; + + emit(state.copyWith(status: SearchStatus.performingCommentAction)); + + try { + CommentView updatedCommentView = await voteComment(event.commentId, event.score).timeout(timeout, onTimeout: () { + throw Exception(l10n.timeoutUpvoteComment); + }); + + // If it worked, update and emit + CommentView? commentView = state.comments?.firstWhereOrNull((commentView) => commentView.comment.id == event.commentId); + if (commentView != null) { + int index = (state.comments?.indexOf(commentView))!; + + List comments = List.from(state.comments ?? []); + comments.insert(index, updatedCommentView); + comments.remove(commentView); + + emit(state.copyWith(status: SearchStatus.success, comments: comments)); + } + } catch (e) { + // It just fails + } + } + + Future _saveCommentEvent(SaveCommentEvent event, Emitter emit) async { + final AppLocalizations l10n = AppLocalizations.of(GlobalContext.context)!; + + emit(state.copyWith(status: SearchStatus.performingCommentAction)); + + try { + CommentView updatedCommentView = await saveComment(event.commentId, event.save).timeout(timeout, onTimeout: () { + throw Exception(l10n.timeoutUpvoteComment); + }); + + // If it worked, update and emit + CommentView? commentView = state.comments?.firstWhereOrNull((commentView) => commentView.comment.id == event.commentId); + if (commentView != null) { + int index = (state.comments?.indexOf(commentView))!; + + List comments = List.from(state.comments ?? []); + comments.insert(index, updatedCommentView); + comments.remove(commentView); + + emit(state.copyWith(status: SearchStatus.success, comments: comments)); + } + } catch (e) { + // It just fails + } + } } diff --git a/lib/search/bloc/search_event.dart b/lib/search/bloc/search_event.dart index c32beaa34..603e107fd 100644 --- a/lib/search/bloc/search_event.dart +++ b/lib/search/bloc/search_event.dart @@ -37,3 +37,17 @@ class ContinueSearchEvent extends SearchEvent { class FocusSearchEvent extends SearchEvent {} class GetTrendingCommunitiesEvent extends SearchEvent {} + +class VoteCommentEvent extends SearchEvent { + final int commentId; + final int score; + + const VoteCommentEvent({required this.commentId, required this.score}); +} + +class SaveCommentEvent extends SearchEvent { + final int commentId; + final bool save; + + const SaveCommentEvent({required this.commentId, required this.save}); +} diff --git a/lib/search/bloc/search_state.dart b/lib/search/bloc/search_state.dart index 3ac780933..2ce9fb538 100644 --- a/lib/search/bloc/search_state.dart +++ b/lib/search/bloc/search_state.dart @@ -1,6 +1,6 @@ part of 'search_bloc.dart'; -enum SearchStatus { initial, trending, loading, refreshing, success, empty, failure, done } +enum SearchStatus { initial, trending, loading, refreshing, success, empty, failure, done, performingCommentAction } class SearchState extends Equatable { SearchState({ @@ -8,6 +8,7 @@ class SearchState extends Equatable { this.communities, this.trendingCommunities, this.users, + this.comments, this.errorMessage, this.page = 1, this.sortType, @@ -18,6 +19,7 @@ class SearchState extends Equatable { List? communities; List? trendingCommunities; List? users; + List? comments; final String? errorMessage; @@ -31,6 +33,7 @@ class SearchState extends Equatable { List? communities, List? trendingCommunities, List? users, + List? comments, String? errorMessage, int? page, SortType? sortType, @@ -41,6 +44,7 @@ class SearchState extends Equatable { communities: communities ?? this.communities, trendingCommunities: trendingCommunities ?? this.trendingCommunities, users: users ?? this.users, + comments: comments ?? this.comments, errorMessage: errorMessage, page: page ?? this.page, sortType: sortType ?? this.sortType, diff --git a/lib/search/pages/search_page.dart b/lib/search/pages/search_page.dart index 6923c79ae..6a2fe1dd0 100644 --- a/lib/search/pages/search_page.dart +++ b/lib/search/pages/search_page.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:fading_edge_scrollview/fading_edge_scrollview.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter/material.dart'; @@ -6,17 +9,22 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:shared_preferences/shared_preferences.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/bloc/anonymous_subscriptions_bloc.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/auth/helpers/fetch_account.dart'; -import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/feed/utils/utils.dart'; import 'package:thunder/feed/view/feed_page.dart'; +import 'package:thunder/post/bloc/post_bloc.dart' as post_bloc; +import 'package:thunder/post/pages/create_comment_page.dart'; import 'package:thunder/search/bloc/search_bloc.dart'; +import 'package:thunder/search/utils/search_utils.dart'; +import 'package:thunder/shared/comment_reference.dart'; import 'package:thunder/shared/error_message.dart'; import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/shared/sort_picker.dart'; @@ -243,7 +251,8 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi title: l10n.selectSearchType, items: [ ListPickerItem(label: l10n.communities, payload: SearchType.communities, icon: Icons.people_rounded), - ListPickerItem(label: l10n.users, payload: SearchType.users, icon: Icons.person_rounded) + ListPickerItem(label: l10n.users, payload: SearchType.users, icon: Icons.person_rounded), + ListPickerItem(label: l10n.comments, payload: SearchType.comments, icon: Icons.chat_rounded), ], onSelect: (value) { setState(() => _currentSearchType = value.payload); @@ -361,6 +370,7 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi switch (_currentSearchType) { SearchType.communities => l10n.searchCommunitiesFederatedWith((isUserLoggedIn ? accountInstance : currentAnonymousInstance) ?? ''), SearchType.users => l10n.searchUsersFederatedWith((isUserLoggedIn ? accountInstance : currentAnonymousInstance) ?? ''), + SearchType.comments => l10n.searchCommentsFederatedWith((isUserLoggedIn ? accountInstance : currentAnonymousInstance) ?? ''), _ => '', }, textAlign: TextAlign.center, @@ -400,12 +410,14 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi case SearchStatus.refreshing: case SearchStatus.success: case SearchStatus.done: - if ((_currentSearchType == SearchType.communities && state.communities?.isNotEmpty != true) || (_currentSearchType == SearchType.users && state.users?.isNotEmpty != true)) { + case SearchStatus.performingCommentAction: + if (searchIsEmpty(_currentSearchType, searchState: state)) { return Center( child: Text( switch (_currentSearchType) { SearchType.communities => l10n.noCommunitiesFound, SearchType.users => l10n.noUsersFound, + SearchType.comments => l10n.noCommentsFound, _ => '', }, textAlign: TextAlign.center, @@ -460,6 +472,42 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi }, ), ); + } else if (_currentSearchType == SearchType.comments) { + return FadingEdgeScrollView.fromScrollView( + gradientFractionOnEnd: 0, + child: ListView.builder( + controller: _scrollController, + itemCount: state.comments!.length + 1, + itemBuilder: (BuildContext context, int index) { + if (index == state.comments!.length) { + return state.status == SearchStatus.refreshing + ? const Center( + child: Padding( + padding: EdgeInsets.only(bottom: 10), + child: CircularProgressIndicator(), + ), + ) + : Container(); + } else { + CommentView commentView = state.comments![index]; + return Column( + children: [ + Divider( + height: 1.0, + thickness: 1.0, + color: ElevationOverlay.applySurfaceTint( + Theme.of(context).colorScheme.surface, + Theme.of(context).colorScheme.surfaceTint, + 10, + ), + ), + _buildCommentEntry(context, commentView), + ], + ); + } + }, + ), + ); } else { return Container(); } @@ -555,6 +603,84 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi ); } + Widget _buildCommentEntry(BuildContext context, CommentView commentView) { + final bool isOwnComment = commentView.creator.id == context.read().state.account?.userId; + + return BlocProvider( + create: (BuildContext context) => post_bloc.PostBloc(), + child: CommentReference( + comment: commentView, + now: DateTime.now().toUtc(), + onVoteAction: (int commentId, int voteType) => context.read().add(VoteCommentEvent(commentId: commentId, score: voteType)), + onSaveAction: (int commentId, bool save) => context.read().add(SaveCommentEvent(commentId: commentId, save: save)), + // Only swipe actions are supported here, and delete is not one of those, so no implementation + onDeleteAction: (int commentId, bool deleted) {}, + // Only swipe actions are supported here, and report is not one of those, so no implementation + onReportAction: (int commentId) {}, + onReplyEditAction: (CommentView commentView, bool isEdit) async { + ThunderBloc thunderBloc = context.read(); + AccountBloc accountBloc = context.read(); + + final ThunderState state = context.read().state; + final bool reduceAnimations = state.reduceAnimations; + + SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + DraftComment? newDraftComment; + DraftComment? previousDraftComment; + String draftId = '${LocalSettings.draftsCache.name}-${commentView.comment.id}'; + String? draftCommentJson = prefs.getString(draftId); + if (draftCommentJson != null) { + previousDraftComment = DraftComment.fromJson(jsonDecode(draftCommentJson)); + } + Timer timer = Timer.periodic(const Duration(seconds: 10), (Timer t) { + if (newDraftComment?.isNotEmpty == true) { + prefs.setString(draftId, jsonEncode(newDraftComment!.toJson())); + } + }); + + if (context.mounted) { + Navigator.of(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), + ], + child: CreateCommentPage( + commentView: commentView, + isEdit: isEdit, + parentCommentAuthor: commentView.creator.name, + previousDraftComment: previousDraftComment, + onUpdateDraft: (c) => newDraftComment = c, + )); + }, + ), + ) + .whenComplete( + () async { + timer.cancel(); + + if (newDraftComment?.saveAsDraft == true && newDraftComment?.isNotEmpty == true && (!isEdit || commentView.comment.content != newDraftComment?.text)) { + await Future.delayed(const Duration(milliseconds: 300)); + if (context.mounted) showSnackbar(context, AppLocalizations.of(context)!.commentSavedAsDraft); + prefs.setString(draftId, jsonEncode(newDraftComment!.toJson())); + } else { + prefs.remove(draftId); + } + }, + ); + } + }, + isOwnComment: isOwnComment, + ), + ); + } + void showSortBottomSheet(BuildContext context) { final AppLocalizations l10n = AppLocalizations.of(context)!; diff --git a/lib/search/utils/search_utils.dart b/lib/search/utils/search_utils.dart new file mode 100644 index 000000000..ac9672c17 --- /dev/null +++ b/lib/search/utils/search_utils.dart @@ -0,0 +1,20 @@ +import 'package:lemmy_api_client/v3.dart'; +import 'package:thunder/search/bloc/search_bloc.dart'; + +/// Checks whether there are any results for the current given [searchType] in the [searchState] or the given [searchResponse]. +bool searchIsEmpty(SearchType searchType, {SearchState? searchState, SearchResponse? searchResponse}) { + assert(searchState != null || searchResponse != null); + + final List? communities = searchState?.communities ?? searchResponse?.communities; + final List? users = searchState?.users ?? searchResponse?.users; + final List? comments = searchState?.comments ?? searchResponse?.comments; + + return switch (searchType) { + SearchType.communities => communities?.isNotEmpty != true, + SearchType.users => users?.isNotEmpty != true, + SearchType.comments => comments?.isNotEmpty != true, + //SearchType.posts => TODO + //SearchType.url => TODO + _ => false, + }; +}