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

Add feature for mark read on scroll #1139

Merged
merged 20 commits into from
Mar 5, 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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
- Added additional font scale option for medium
- Added ability to subscribe/unsubscribe to community from long press action on posts
- Added option to hide top app bar on scroll
- Ability to search through settings/preferences contribution from @ggichure.
- Ability to search through settings/preferences - contribution from @ggichure
- Setting to use colorized usernames - contribution from @ggichure.
- Show the number of new comments a read post has received since last visited
- Added ability to mark read on scroll - contribution from @Fmstrat

## Changed
- Small UI adjustments for account switcher
Expand Down
4 changes: 2 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ buildscript {
// Versions recommended/needed by flutter_local_notifications and background_fetch
// These can be upgraded over time.
ext {
compileSdkVersion = 33
targetSdkVersion = 33
compileSdkVersion = 34
targetSdkVersion = 34
appCompatVersion = "1.6.1"
}
repositories {
Expand Down
16 changes: 15 additions & 1 deletion lib/community/widgets/post_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class PostCard extends StatefulWidget {
final Function(int) onVoteAction;
final Function(bool) onSaveAction;
final Function(bool) onReadAction;
final Function(double) onUpAction;
final Function() onDownAction;

final ListingType? listingType;

Expand All @@ -34,6 +36,8 @@ class PostCard extends StatefulWidget {
required this.onVoteAction,
required this.onSaveAction,
required this.onReadAction,
required this.onUpAction,
required this.onDownAction,
required this.listingType,
required this.indicateRead,
});
Expand Down Expand Up @@ -64,6 +68,9 @@ class _PostCardState extends State<PostCard> {
/// This is used to temporarily disable the swipe action to allow for detection of full screen swipe to go back
bool isOverridingSwipeGestureAction = false;

/// The vertical drag distance between moves
double verticalDragDistance = 0;

@override
void initState() {
super.initState();
Expand All @@ -81,7 +88,9 @@ class _PostCardState extends State<PostCard> {

return Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) => {},
onPointerDown: (PointerDownEvent event) {
widget.onDownAction();
},
onPointerUp: (event) {
setState(() => isOverridingSwipeGestureAction = false);

Expand All @@ -98,12 +107,17 @@ class _PostCardState extends State<PostCard> {
postViewMedia: widget.postViewMedia,
);
}

widget.onUpAction(verticalDragDistance);
},
onPointerCancel: (event) => {},
onPointerMove: (PointerMoveEvent event) {
// Get the horizontal drag distance
double horizontalDragDistance = event.delta.dx;

// Set the vertical drag distance
verticalDragDistance = event.delta.dy;

// We are checking to see if there is a left to right swipe here. If there is a left to right swipe, and LTR swipe actions are disabled, then we disable the DismissDirection temporarily
// to allow for the full screen swipe to go back. Otherwise, we retain the default behaviour
if (horizontalDragDistance > 0) {
Expand Down
2 changes: 2 additions & 0 deletions lib/community/widgets/post_card_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ class _PostCardListState extends State<PostCardList> {
onVoteAction: (int voteType) => widget.onVoteAction(postViewMedia.postView.post.id, voteType),
onSaveAction: (bool saved) => widget.onSaveAction(postViewMedia.postView.post.id, saved),
onReadAction: (bool read) => widget.onToggleReadAction(postViewMedia.postView.post.id, read),
onUpAction: (double verticalDragDistance) {},
onDownAction: () {},
listingType: widget.listingType,
indicateRead: widget.indicateRead,
);
Expand Down
2 changes: 2 additions & 0 deletions lib/core/enums/local_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ enum LocalSettings {
useDisplayNamesForUsers(name: 'setting_use_display_names_for_users', key: 'showUserDisplayNames', category: LocalSettingsCategories.posts, subCategory: LocalSettingsSubCategories.general),
markPostAsReadOnMediaView(
name: 'setting_general_mark_post_read_on_media_view', key: 'markPostAsReadOnMediaView', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.feed),
markPostAsReadOnScroll(name: 'setting_general_mark_post_read_on_scroll', key: 'markPostAsReadOnScroll', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.feed),
showInAppUpdateNotification(
name: 'setting_notifications_show_inapp_update', key: 'showInAppUpdateNotifications', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.notifications),
scoreCounters(name: 'setting_score_counters', key: "showScoreCounters", category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.feed),
Expand Down Expand Up @@ -270,6 +271,7 @@ extension LocalizationExt on AppLocalizations {
'openLinksInReaderMode': openLinksInReaderMode,
'showUserDisplayNames': showUserDisplayNames,
'markPostAsReadOnMediaView': markPostAsReadOnMediaView,
'markPostAsReadOnScroll': markPostAsReadOnScroll,
'showInAppUpdateNotifications': showInAppUpdateNotifications,
'enableInboxNotifications': enableInboxNotifications,
'showScoreCounters': showScoreCounters,
Expand Down
3 changes: 2 additions & 1 deletion lib/core/singletons/lemmy_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ enum LemmyFeature {
sortTypeControversial(0, 19, 0, preRelease: ["rc", "1"]),
sortTypeScaled(0, 19, 0, preRelease: ["rc", "1"]),
commentSortTypeControversial(0, 19, 0, preRelease: ["rc", "1"]),
blockInstance(0, 19, 0, preRelease: ["rc", "1"]);
blockInstance(0, 19, 0, preRelease: ["rc", "1"]),
multiRead(0, 19, 0, preRelease: ["rc", "1"]);

final int major;
final int minor;
Expand Down
47 changes: 46 additions & 1 deletion lib/feed/bloc/feed_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ class FeedBloc extends Bloc<FeedEvent, FeedState> {

/// Handles post related actions on a given item within the feed
Future<void> _onFeedItemActioned(FeedItemActionedEvent event, Emitter<FeedState> emit) async {
assert(!(event.postViewMedia == null && event.postId == null));
assert(!(event.postViewMedia == null && event.postId == null && event.postIds == null));
emit(state.copyWith(status: FeedStatus.fetching));

// TODO: Check if the current account has permission to perform the PostAction
Expand Down Expand Up @@ -208,6 +208,51 @@ class FeedBloc extends Bloc<FeedEvent, FeedState> {
state.postViewMedias[existingPostViewMediaIndex].postView = originalPostView;
return emit(state.copyWith(status: FeedStatus.failure));
}
case PostAction.multiRead:
List<int> eventPostIds = event.postIds ?? [];

if (eventPostIds.isNotEmpty) {
// Optimistically read the posts
List<int> existingPostViewMediaIndexes = [];
List<int> postIds = [];
List<PostViewMedia> postViewMedias = [];
List<PostView> originalPostViews = [];
for (int i = 0; i < state.postViewMedias.length; i++) {
if (eventPostIds.contains(state.postViewMedias[i].postView.post.id)) {
existingPostViewMediaIndexes.add(i);
postIds.add(state.postViewMedias[i].postView.post.id);
postViewMedias.add(state.postViewMedias[i]);
originalPostViews.add(state.postViewMedias[i].postView);
}
}

try {
for (int i = 0; i < existingPostViewMediaIndexes.length; i++) {
PostView updatedPostView = optimisticallyReadPost(postViewMedias[i], event.value);
state.postViewMedias[existingPostViewMediaIndexes[i]].postView = updatedPostView;
}

// Emit the state to update UI immediately
emit(state.copyWith(status: FeedStatus.success));
emit(state.copyWith(status: FeedStatus.fetching));

List<int> failed = await markPostsAsRead(postIds, event.value);
if (failed.isEmpty) return emit(state.copyWith(status: FeedStatus.success));

// Restore the original post contents if not successful
for (int i = 0; i < failed.length; i++) {
state.postViewMedias[existingPostViewMediaIndexes[failed[i]]].postView = originalPostViews[failed[i]];
}
return emit(state.copyWith(status: FeedStatus.failure));
} catch (e) {
// Restore the original post contents
// They will all be restored, but this is an unlikely scenario
for (int i = 0; i < existingPostViewMediaIndexes.length; i++) {
state.postViewMedias[existingPostViewMediaIndexes[i]].postView = originalPostViews[i];
}
return emit(state.copyWith(status: FeedStatus.failure));
}
}
case PostAction.delete:
// Optimistically delete the post
int existingPostViewMediaIndex = state.postViewMedias.indexWhere((PostViewMedia postViewMedia) => postViewMedia.postView.post.id == event.postId);
Expand Down
4 changes: 3 additions & 1 deletion lib/feed/bloc/feed_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,16 @@ final class FeedItemActionedEvent extends FeedEvent {
/// If both are provided, [postId] will take precedence
final int? postId;

final List<int>? postIds;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add a small comment describing what this parameter is used for (e.g., If passed in, the [postAction] is applied to all the posts in the list)


/// This indicates the relevant action to perform on the post
final PostAction postAction;

/// This indicates the value to assign the action to. It is of type dynamic to allow for any type
/// TODO: Change the dynamic type to the correct type(s) if possible
final dynamic value;

const FeedItemActionedEvent({this.postViewMedia, this.postId, required this.postAction, this.value});
const FeedItemActionedEvent({this.postViewMedia, this.postId, this.postIds, required this.postAction, this.value});
}

final class FeedClearMessageEvent extends FeedEvent {}
Expand Down
2 changes: 2 additions & 0 deletions lib/feed/view/feed_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ class _FeedViewState extends State<FeedView> {
final l10n = AppLocalizations.of(context)!;

bool tabletMode = thunderBloc.state.tabletMode;
bool markPostReadOnScroll = thunderBloc.state.markPostReadOnScroll;
bool hideTopBarOnScroll = thunderBloc.state.hideTopBarOnScroll;

return MultiBlocListener(
Expand Down Expand Up @@ -339,6 +340,7 @@ class _FeedViewState extends State<FeedView> {
FeedPostList(
postViewMedias: postViewMedias,
tabletMode: tabletMode,
markPostReadOnScroll: markPostReadOnScroll,
queuedForRemoval: queuedForRemoval,
),
// Widgets to display on the feed when feedType == FeedType.community
Expand Down
120 changes: 102 additions & 18 deletions lib/feed/view/feed_widget.dart
Original file line number Diff line number Diff line change
@@ -1,33 +1,77 @@
import 'dart:async';

import 'package:flutter/material.dart';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:visibility_detector/visibility_detector.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';

import 'package:thunder/community/widgets/post_card.dart';
import 'package:thunder/core/auth/bloc/auth_bloc.dart';
import 'package:thunder/core/models/post_view_media.dart';
import 'package:thunder/feed/bloc/feed_bloc.dart';
import 'package:thunder/feed/view/feed_page.dart';
import 'package:thunder/post/enums/post_action.dart';
import 'package:thunder/thunder/bloc/thunder_bloc.dart';

class FeedPostList extends StatelessWidget {
class FeedPostList extends StatefulWidget {
/// Determines the number of columns to display
final bool tabletMode;

/// Determines whether to mark posts as read on scroll
final bool markPostReadOnScroll;

/// The list of posts that have been queued for removal using the dismiss read action
final List<int>? queuedForRemoval;

/// The list of posts to show on the feed
final List<PostViewMedia> postViewMedias;

const FeedPostList({
super.key,
required this.postViewMedias,
required this.tabletMode,
required this.markPostReadOnScroll,
this.queuedForRemoval,
});

@override
State<FeedPostList> createState() => _FeedPostListState();
}

class _FeedPostListState extends State<FeedPostList> {
/// The index of the last tapped post.
/// This is used to calculate the read status of posts in the range [0, lastTappedIndex]
int lastTappedIndex = -1;

/// Whether the user is scrolling down or not. The logic for determining read posts will
/// only be applied when the user is scrolling down
bool isScrollingDown = false;

/// List of post ids to queue for being marked as read.
Set<int> markReadPostIds = <int>{};

/// List of post ids that have already previously been detected as read
Set<int> readPostIds = <int>{};

/// Timer for debouncing the read action
Timer? debounceTimer;

@override
void dispose() {
debounceTimer?.cancel();
super.dispose();
}

@override
Widget build(BuildContext context) {
final ThunderState thunderState = context.read<ThunderBloc>().state;
final FeedState state = context.read<FeedBloc>().state;
final bool isUserLoggedIn = context.read<AuthBloc>().state.isLoggedIn;

// Widget representing the list of posts on the feed
return SliverMasonryGrid.count(
crossAxisCount: tabletMode ? 2 : 1,
crossAxisCount: widget.tabletMode ? 2 : 1,
crossAxisSpacing: 40,
mainAxisSpacing: 0,
itemBuilder: (BuildContext context, int index) {
Expand All @@ -54,26 +98,66 @@ class FeedPostList extends StatelessWidget {
),
);
},
child: queuedForRemoval?.contains(postViewMedias[index].postView.post.id) != true
? PostCard(
postViewMedia: postViewMedias[index],
communityMode: state.feedType == FeedType.community,
onVoteAction: (int voteType) {
context.read<FeedBloc>().add(FeedItemActionedEvent(postId: postViewMedias[index].postView.post.id, postAction: PostAction.vote, value: voteType));
},
onSaveAction: (bool saved) {
context.read<FeedBloc>().add(FeedItemActionedEvent(postId: postViewMedias[index].postView.post.id, postAction: PostAction.save, value: saved));
},
onReadAction: (bool read) {
context.read<FeedBloc>().add(FeedItemActionedEvent(postId: postViewMedias[index].postView.post.id, postAction: PostAction.read, value: read));
child: widget.queuedForRemoval?.contains(widget.postViewMedias[index].postView.post.id) != true
? VisibilityDetector(
key: Key('post-card-vis-$index'),
onVisibilityChanged: (info) {
if (!isUserLoggedIn || !widget.markPostReadOnScroll || !isScrollingDown) return;

if (index <= lastTappedIndex && info.visibleFraction == 0) {
for (int i = index; i >= 0; i--) {
// If we already checked this post's read status, or we already marked it as read, skip it
if (readPostIds.contains(widget.postViewMedias[i].postView.post.id)) continue;
if (markReadPostIds.contains(widget.postViewMedias[i].postView.post.id)) continue;

// Otherwise, check the post read status
if (widget.postViewMedias[i].postView.read == false) {
markReadPostIds.add(widget.postViewMedias[i].postView.post.id);
} else {
readPostIds.add(widget.postViewMedias[i].postView.post.id);
}
}

// Debounce the read action to account for quick scrolling. This reduces the number of times the read action is triggered
debounceTimer?.cancel();

debounceTimer = Timer(const Duration(milliseconds: 500), () {
if (markReadPostIds.isNotEmpty) {
context.read<FeedBloc>().add(FeedItemActionedEvent(postIds: [...markReadPostIds], postAction: PostAction.multiRead, value: true));
markReadPostIds = <int>{};
}
});
}
},
listingType: state.postListingType,
indicateRead: thunderState.dimReadPosts,
)
child: PostCard(
postViewMedia: widget.postViewMedias[index],
communityMode: state.feedType == FeedType.community,
onVoteAction: (int voteType) {
context.read<FeedBloc>().add(FeedItemActionedEvent(postId: widget.postViewMedias[index].postView.post.id, postAction: PostAction.vote, value: voteType));
},
onSaveAction: (bool saved) {
context.read<FeedBloc>().add(FeedItemActionedEvent(postId: widget.postViewMedias[index].postView.post.id, postAction: PostAction.save, value: saved));
},
onReadAction: (bool read) {
context.read<FeedBloc>().add(FeedItemActionedEvent(postId: widget.postViewMedias[index].postView.post.id, postAction: PostAction.read, value: read));
},
onDownAction: () {
if (lastTappedIndex != index) lastTappedIndex = index;
},
onUpAction: (double verticalDragDistance) {
bool updatedIsScrollingDown = verticalDragDistance < 0;

if (isScrollingDown != updatedIsScrollingDown) {
isScrollingDown = updatedIsScrollingDown;
}
},
listingType: state.postListingType,
indicateRead: thunderState.dimReadPosts,
))
: null,
);
},
childCount: postViewMedias.length,
childCount: widget.postViewMedias.length,
);
}
}
1 change: 1 addition & 0 deletions lib/instance/pages/instance_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ class _InstancePageState extends State<InstancePage> {
),
if (viewType == SearchType.posts)
FeedPostList(
markPostReadOnScroll: false,
postViewMedias: state.posts ?? [],
tabletMode: tabletMode,
),
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,10 @@
"@markPostAsReadOnMediaView": {
"description": "Toggle to mark posts as read after viewing media."
},
"markPostAsReadOnScroll": "Mark Read On Scroll",
"@markPostAsReadOnScroll": {
"description": "Toggle to mark posts as read as you scroll past them in the feed."
},
"medium": "Medium",
"@medium": {
"description": "Description for medium font scale"
Expand Down
1 change: 1 addition & 0 deletions lib/post/enums/post_action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ enum PostAction {
delete(permissionType: PermissionType.user),
report(permissionType: PermissionType.user),
read(permissionType: PermissionType.user),
multiRead(permissionType: PermissionType.user),

/// Moderator level post actions
lock(permissionType: PermissionType.moderator),
Expand Down
Loading
Loading