From 7293229758fb7bdb3dd3f2fb4703ff7908a76b1c Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Wed, 1 May 2024 11:59:07 -0400 Subject: [PATCH] Optimistically mark replies as read (#1314) * Optimistically mark replies as read * Fix localizations --- lib/inbox/bloc/inbox_bloc.dart | 46 +++++--- lib/inbox/pages/inbox_page.dart | 22 ++-- lib/inbox/widgets/inbox_replies_view.dart | 130 +++++++++------------- lib/l10n/app_en.arb | 16 +++ 4 files changed, 110 insertions(+), 104 deletions(-) diff --git a/lib/inbox/bloc/inbox_bloc.dart b/lib/inbox/bloc/inbox_bloc.dart index 918880d71..5ecd3fb29 100644 --- a/lib/inbox/bloc/inbox_bloc.dart +++ b/lib/inbox/bloc/inbox_bloc.dart @@ -7,6 +7,8 @@ import 'package:stream_transform/stream_transform.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'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; part 'inbox_event.dart'; part 'inbox_state.dart'; @@ -32,7 +34,7 @@ class InboxBloc extends Bloc { void _init() { on( _getInboxEvent, - transformer: throttleDroppable(throttleDuration), + transformer: restartable(), ); on( _markReplyAsReadEvent, @@ -72,7 +74,7 @@ class InboxBloc extends Bloc { LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; if (event.reset) { - emit(state.copyWith(status: InboxStatus.loading)); + emit(state.copyWith(status: InboxStatus.loading, errorMessage: '')); // Fetch all the things PrivateMessagesResponse privateMessagesResponse = await lemmy.run( GetPrivateMessages( @@ -116,7 +118,7 @@ class InboxBloc extends Bloc { status: InboxStatus.success, privateMessages: cleanDeletedMessages(privateMessagesResponse.privateMessages), mentions: cleanDeletedMentions(getPersonMentionsResponse.mentions), - replies: getRepliesResponse.replies, + replies: getRepliesResponse.replies.toList(), // Copy this list so that it is modifyable showUnreadOnly: !event.showAll, inboxMentionPage: 2, inboxReplyPage: 2, @@ -134,7 +136,7 @@ class InboxBloc extends Bloc { // Prevent duplicate requests if we're done fetching if (state.hasReachedInboxReplyEnd && state.hasReachedInboxMentionEnd && state.hasReachedInboxPrivateMessageEnd) return; - emit(state.copyWith(status: InboxStatus.refreshing)); + emit(state.copyWith(status: InboxStatus.refreshing, errorMessage: '')); // Fetch all the things PrivateMessagesResponse privateMessagesResponse = await lemmy.run( @@ -213,7 +215,19 @@ class InboxBloc extends Bloc { Future _markReplyAsReadEvent(MarkReplyAsReadEvent event, emit) async { try { - emit(state.copyWith(status: InboxStatus.refreshing)); + emit(state.copyWith(status: InboxStatus.refreshing, errorMessage: '')); + + bool matchMarkedComment(CommentReplyView commentView) => commentView.commentReply.id == event.commentReplyId; + + // Optimistically remove the reply from the list + // or change the status (depending on whether we're showing all) + final CommentReplyView commentReplyView = state.replies.firstWhere(matchMarkedComment); + int index = state.replies.indexOf(commentReplyView); + if (event.showAll) { + state.replies[index] = commentReplyView.copyWith(commentReply: commentReplyView.commentReply.copyWith(read: event.read)); + } else if (event.read) { + state.replies.remove(commentReplyView); + } Account? account = await fetchActiveProfileAccount(); LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; @@ -228,15 +242,13 @@ class InboxBloc extends Bloc { read: event.read, )); - // Remove the post from the current reply list, or just mark it as read - List replies = List.from(state.replies); - bool matchMarkedComment(CommentReplyView commentView) => commentView.commentReply.id == response.commentReplyView.commentReply.id; - if (event.showAll) { - final CommentReplyView markedComment = replies.firstWhere(matchMarkedComment); - final int index = replies.indexOf(markedComment); - replies[index] = markedComment.copyWith(comment: response.commentReplyView.comment); - } else { - replies.removeWhere(matchMarkedComment); + if (response.commentReplyView.commentReply.read != event.read) { + return emit( + state.copyWith( + status: InboxStatus.failure, + errorMessage: event.read ? AppLocalizations.of(GlobalContext.context)!.errorMarkingReplyRead : AppLocalizations.of(GlobalContext.context)!.errorMarkingReplyUnread, + ), + ); } GetUnreadCountResponse getUnreadCountResponse = await lemmy.run( @@ -249,7 +261,7 @@ class InboxBloc extends Bloc { return emit(state.copyWith( status: InboxStatus.success, - replies: replies, + replies: state.replies, totalUnreadCount: totalUnreadCount, repliesUnreadCount: getUnreadCountResponse.replies, mentionsUnreadCount: getUnreadCountResponse.mentions, @@ -268,6 +280,7 @@ class InboxBloc extends Bloc { privateMessages: state.privateMessages, mentions: state.mentions, replies: state.replies, + errorMessage: '', )); Account? account = await fetchActiveProfileAccount(); @@ -291,7 +304,7 @@ class InboxBloc extends Bloc { Future _createCommentEvent(CreateInboxCommentReplyEvent event, Emitter emit) async { try { - emit(state.copyWith(status: InboxStatus.refreshing)); + emit(state.copyWith(status: InboxStatus.refreshing, errorMessage: '')); Account? account = await fetchActiveProfileAccount(); LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; @@ -318,6 +331,7 @@ class InboxBloc extends Bloc { try { emit(state.copyWith( status: InboxStatus.refreshing, + errorMessage: '', )); Account? account = await fetchActiveProfileAccount(); LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3; diff --git a/lib/inbox/pages/inbox_page.dart b/lib/inbox/pages/inbox_page.dart index 995a21182..a43ea5a85 100644 --- a/lib/inbox/pages/inbox_page.dart +++ b/lib/inbox/pages/inbox_page.dart @@ -11,8 +11,8 @@ import 'package:thunder/inbox/widgets/inbox_private_messages_view.dart'; import 'package:thunder/inbox/widgets/inbox_replies_view.dart'; import 'package:thunder/post/bloc/post_bloc.dart'; import 'package:thunder/shared/dialogs.dart'; -import 'package:thunder/shared/error_message.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:thunder/shared/snackbar.dart'; enum InboxType { replies, mentions, messages } @@ -162,22 +162,20 @@ class _InboxPageState extends State { ); case InboxStatus.refreshing: case InboxStatus.success: + case InboxStatus.failure: + if (state.errorMessage?.isNotEmpty == true) { + showSnackbar( + state.errorMessage!, + trailingIcon: Icons.refresh_rounded, + trailingAction: () => context.read().add(GetInboxEvent(reset: true, showAll: showAll)), + ); + } + if (inboxType == InboxType.mentions) return InboxMentionsView(mentions: state.mentions); if (inboxType == InboxType.messages) return InboxPrivateMessagesView(privateMessages: state.privateMessages); if (inboxType == InboxType.replies) return InboxRepliesView(replies: state.replies, showAll: showAll); case InboxStatus.empty: return Center(child: Text(l10n.emptyInbox)); - case InboxStatus.failure: - return ErrorMessage( - message: state.errorMessage, - actions: [ - ( - text: l10n.refreshContent, - action: () => context.read().add(const GetInboxEvent()), - loading: false, - ), - ], - ); } return Container(); diff --git a/lib/inbox/widgets/inbox_replies_view.dart b/lib/inbox/widgets/inbox_replies_view.dart index 6330e4972..89a947536 100644 --- a/lib/inbox/widgets/inbox_replies_view.dart +++ b/lib/inbox/widgets/inbox_replies_view.dart @@ -42,9 +42,6 @@ class InboxRepliesView extends StatefulWidget { } class _InboxRepliesViewState extends State { - List inboxRepliesBeingMarkedAsRead = []; - List inboxRepliesMarkedAsRead = []; - @override void initState() { super.initState(); @@ -54,82 +51,63 @@ class _InboxRepliesViewState extends State { Widget build(BuildContext context) { final DateTime now = DateTime.now().toUtc(); - if (widget.replies.isEmpty || widget.replies.map((reply) => reply.commentReply.id).every((id) => inboxRepliesMarkedAsRead.contains(id))) { - return Align(alignment: Alignment.topCenter, heightFactor: (MediaQuery.of(context).size.height / 27), child: const Text('No replies')); + if (widget.replies.isEmpty) { + return Align(alignment: Alignment.topCenter, heightFactor: (MediaQuery.of(context).size.height / 27), child: Text(l10n.noReplies)); } - return BlocListener( - listener: (context, state) { - if (state.status == InboxStatus.success && inboxRepliesBeingMarkedAsRead.isNotEmpty && state.inboxReplyMarkedAsRead != null) { - inboxRepliesBeingMarkedAsRead.remove(state.inboxReplyMarkedAsRead); - inboxRepliesMarkedAsRead.add(state.inboxReplyMarkedAsRead!); - setState(() {}); - } - }, - child: ListView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: widget.replies.length, - itemBuilder: (context, index) { - if (widget.showAll || !inboxRepliesMarkedAsRead.contains(widget.replies[index].commentReply.id)) { - return Column( - children: [ - Divider( - height: 1.0, - thickness: 1.0, - color: ElevationOverlay.applySurfaceTint( - Theme.of(context).colorScheme.surface, - Theme.of(context).colorScheme.surfaceTint, - 10, - ), + return ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.replies.length, + itemBuilder: (context, 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, + ), + ), + CommentReference( + comment: widget.replies[index].toCommentView(), + now: now, + 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)), + onDeleteAction: (int commentId, bool deleted) => context.read().add(DeleteCommentEvent(deleted: deleted, commentId: commentId)), + onReportAction: (int commentId) { + showReportCommentActionBottomSheet( + context, + commentId: commentId, + ); + }, + onReplyEditAction: (CommentView commentView, bool isEdit) async => navigateToCreateCommentPage( + context, + commentView: isEdit ? commentView : null, + parentCommentView: isEdit ? null : commentView, + onCommentSuccess: (commentView) { + context.read().add(UpdateCommentEvent(commentView: commentView, isEdit: isEdit)); + }, + ), + isOwnComment: widget.replies[index].creator.id == context.read().state.account?.userId, + child: IconButton( + onPressed: () { + context.read().add(MarkReplyAsReadEvent(commentReplyId: widget.replies[index].commentReply.id, read: !widget.replies[index].commentReply.read, showAll: widget.showAll)); + }, + icon: Icon( + Icons.check, + semanticLabel: l10n.markAsRead, + color: widget.replies[index].commentReply.read ? Colors.green : null, ), - CommentReference( - comment: widget.replies[index].toCommentView(), - now: now, - 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)), - onDeleteAction: (int commentId, bool deleted) => context.read().add(DeleteCommentEvent(deleted: deleted, commentId: commentId)), - onReportAction: (int commentId) { - showReportCommentActionBottomSheet( - context, - commentId: commentId, - ); - }, - onReplyEditAction: (CommentView commentView, bool isEdit) async => navigateToCreateCommentPage( - context, - commentView: isEdit ? commentView : null, - parentCommentView: isEdit ? null : commentView, - onCommentSuccess: (commentView) { - context.read().add(UpdateCommentEvent(commentView: commentView, isEdit: isEdit)); - }, - ), - isOwnComment: widget.replies[index].creator.id == context.read().state.account?.userId, - child: widget.replies[index].commentReply.read == false && !inboxRepliesMarkedAsRead.contains(widget.replies[index].commentReply.id) - ? !inboxRepliesBeingMarkedAsRead.contains(widget.replies[index].commentReply.id) - ? IconButton( - onPressed: () { - setState(() => inboxRepliesBeingMarkedAsRead.add(widget.replies[index].commentReply.id)); - context.read().add(MarkReplyAsReadEvent(commentReplyId: widget.replies[index].commentReply.id, read: true, showAll: widget.showAll)); - }, - icon: const Icon( - Icons.check, - semanticLabel: 'Mark as read', - ), - visualDensity: VisualDensity.compact, - ) - : const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator()), - ) - : null, - ), - ], - ); - } - return Container(); - }, - ), + visualDensity: VisualDensity.compact, + ), + ), + ], + ); + }, ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 47b0818fe..3a0877502 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -591,6 +591,14 @@ "@endSearch": {}, "errorDownloadingMedia": "Could not download the media file to share: {errorMessage}", "@errorDownloadingMedia": {}, + "errorMarkingReplyRead": "There was an error marking the reply as read.", + "@errorMarkingReplyRead": { + "description": "Error message for marking a reply read" + }, + "errorMarkingReplyUnread": "There was an error marking the reply as unread.", + "@errorMarkingReplyUnread": { + "description": "Error message for marking a reply unread" + }, "exceptionProcessingUri": "An error occurred while processing the link. It may not be available on your instance.", "@exceptionProcessingUri": { "description": "An unspecified error during link processing." @@ -929,6 +937,10 @@ "@markAllAsRead": { "description": "The mark all as read action" }, + "markAsRead": "Mark as read", + "@markAsRead": { + "description": "Label for the action to mark a message as read" + }, "markPostAsReadOnMediaView": "Mark Read After Viewing Media", "@markPostAsReadOnMediaView": { "description": "Toggle to mark posts as read after viewing media." @@ -1085,6 +1097,10 @@ }, "noPostsFound": "No posts found.", "@noPostsFound": {}, + "noReplies": "No replies", + "@noReplies": { + "description": "Label for when there are no replies in the list" + }, "noResultsFound": "No results found.", "@noResultsFound": {}, "noSubscriptions": "No Subscriptions",