Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimistically mark replies as read #1314

Merged
merged 3 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 30 additions & 16 deletions lib/inbox/bloc/inbox_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,7 +34,7 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {
void _init() {
on<GetInboxEvent>(
_getInboxEvent,
transformer: throttleDroppable(throttleDuration),
transformer: restartable(),
);
on<MarkReplyAsReadEvent>(
_markReplyAsReadEvent,
Expand Down Expand Up @@ -72,7 +74,7 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {
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(
Expand Down Expand Up @@ -116,7 +118,7 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {
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,
Expand All @@ -134,7 +136,7 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {

// 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(
Expand Down Expand Up @@ -213,7 +215,19 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {

Future<void> _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;
Expand All @@ -228,15 +242,13 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {
read: event.read,
));

// Remove the post from the current reply list, or just mark it as read
List<CommentReplyView> 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(
Expand All @@ -249,7 +261,7 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {

return emit(state.copyWith(
status: InboxStatus.success,
replies: replies,
replies: state.replies,
totalUnreadCount: totalUnreadCount,
repliesUnreadCount: getUnreadCountResponse.replies,
mentionsUnreadCount: getUnreadCountResponse.mentions,
Expand All @@ -268,6 +280,7 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {
privateMessages: state.privateMessages,
mentions: state.mentions,
replies: state.replies,
errorMessage: '',
));

Account? account = await fetchActiveProfileAccount();
Expand All @@ -291,7 +304,7 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {

Future<void> _createCommentEvent(CreateInboxCommentReplyEvent event, Emitter<InboxState> 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;
Expand All @@ -318,6 +331,7 @@ class InboxBloc extends Bloc<InboxEvent, InboxState> {
try {
emit(state.copyWith(
status: InboxStatus.refreshing,
errorMessage: '',
));
Account? account = await fetchActiveProfileAccount();
LemmyApiV3 lemmy = LemmyClient.instance.lemmyApiV3;
Expand Down
22 changes: 10 additions & 12 deletions lib/inbox/pages/inbox_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -162,22 +162,20 @@ class _InboxPageState extends State<InboxPage> {
);
case InboxStatus.refreshing:
case InboxStatus.success:
case InboxStatus.failure:
if (state.errorMessage?.isNotEmpty == true) {
showSnackbar(
state.errorMessage!,
trailingIcon: Icons.refresh_rounded,
trailingAction: () => context.read<InboxBloc>().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<InboxBloc>().add(const GetInboxEvent()),
loading: false,
),
],
);
}

return Container();
Expand Down
130 changes: 54 additions & 76 deletions lib/inbox/widgets/inbox_replies_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,6 @@ class InboxRepliesView extends StatefulWidget {
}

class _InboxRepliesViewState extends State<InboxRepliesView> {
List<int> inboxRepliesBeingMarkedAsRead = [];
List<int> inboxRepliesMarkedAsRead = [];

@override
void initState() {
super.initState();
Expand All @@ -54,82 +51,63 @@ class _InboxRepliesViewState extends State<InboxRepliesView> {
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<InboxBloc, InboxState>(
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<PostBloc>().add(VoteCommentEvent(commentId: commentId, score: voteType)),
onSaveAction: (int commentId, bool save) => context.read<PostBloc>().add(SaveCommentEvent(commentId: commentId, save: save)),
onDeleteAction: (int commentId, bool deleted) => context.read<PostBloc>().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<PostBloc>().add(UpdateCommentEvent(commentView: commentView, isEdit: isEdit));
},
),
isOwnComment: widget.replies[index].creator.id == context.read<AuthBloc>().state.account?.userId,
child: IconButton(
onPressed: () {
context.read<InboxBloc>().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<PostBloc>().add(VoteCommentEvent(commentId: commentId, score: voteType)),
onSaveAction: (int commentId, bool save) => context.read<PostBloc>().add(SaveCommentEvent(commentId: commentId, save: save)),
onDeleteAction: (int commentId, bool deleted) => context.read<PostBloc>().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<PostBloc>().add(UpdateCommentEvent(commentView: commentView, isEdit: isEdit));
},
),
isOwnComment: widget.replies[index].creator.id == context.read<AuthBloc>().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<InboxBloc>().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,
),
),
],
);
},
);
}

Expand Down
16 changes: 16 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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",
Expand Down
Loading