Add user setting to auto-mark fetched posts as read.

- Rather than apps collecting up viewed posts ids, and sending many
  mark as read requests, users can now turn this setting on, and any
  results from /post/list will be auto-marked as read.
- Fixes #5144
This commit is contained in:
Dessalines 2024-11-01 21:17:58 -04:00
parent 02ba54c589
commit bd0e68fb00
16 changed files with 67 additions and 54 deletions

View file

@ -142,6 +142,7 @@ pub async fn save_user_settings(
enable_keyboard_navigation: data.enable_keyboard_navigation, enable_keyboard_navigation: data.enable_keyboard_navigation,
enable_animated_images: data.enable_animated_images, enable_animated_images: data.enable_animated_images,
collapse_bot_comments: data.collapse_bot_comments, collapse_bot_comments: data.collapse_bot_comments,
auto_mark_fetched_posts_as_read: data.auto_mark_fetched_posts_as_read,
..Default::default() ..Default::default()
}; };

View file

@ -5,19 +5,13 @@ use lemmy_api_common::{
context::LemmyContext, context::LemmyContext,
post::{CreatePostLike, PostResponse}, post::{CreatePostLike, PostResponse},
send_activity::{ActivityChannel, SendActivityData}, send_activity::{ActivityChannel, SendActivityData},
utils::{ utils::{check_bot_account, check_community_user_action, check_local_vote_mode, VoteItem},
check_bot_account,
check_community_user_action,
check_local_vote_mode,
mark_post_as_read,
VoteItem,
},
}; };
use lemmy_db_schema::{ use lemmy_db_schema::{
source::{ source::{
community::Community, community::Community,
local_site::LocalSite, local_site::LocalSite,
post::{Post, PostLike, PostLikeForm}, post::{Post, PostLike, PostLikeForm, PostRead},
}, },
traits::{Crud, Likeable}, traits::{Crud, Likeable},
}; };
@ -73,7 +67,8 @@ pub async fn like_post(
.with_lemmy_type(LemmyErrorType::CouldntLikePost)?; .with_lemmy_type(LemmyErrorType::CouldntLikePost)?;
} }
mark_post_as_read(person_id, post_id, &mut context.pool()).await?; // Mark Post Read
PostRead::mark_as_read(&mut context.pool(), &[post_id], person_id).await?;
let community = Community::read(&mut context.pool(), post.community_id).await?; let community = Community::read(&mut context.pool(), post.community_id).await?;

View file

@ -2,8 +2,7 @@ use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, post::MarkPostAsRead, SuccessResponse}; use lemmy_api_common::{context::LemmyContext, post::MarkPostAsRead, SuccessResponse};
use lemmy_db_schema::source::post::PostRead; use lemmy_db_schema::source::post::PostRead;
use lemmy_db_views::structs::LocalUserView; use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult, MAX_API_PARAM_ELEMENTS}; use lemmy_utils::error::{LemmyErrorType, LemmyResult, MAX_API_PARAM_ELEMENTS};
use std::collections::HashSet;
#[tracing::instrument(skip(context))] #[tracing::instrument(skip(context))]
pub async fn mark_post_as_read( pub async fn mark_post_as_read(
@ -11,7 +10,7 @@ pub async fn mark_post_as_read(
context: Data<LemmyContext>, context: Data<LemmyContext>,
local_user_view: LocalUserView, local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> { ) -> LemmyResult<Json<SuccessResponse>> {
let post_ids = HashSet::from_iter(data.post_ids.clone()); let post_ids = &data.post_ids;
if post_ids.len() > MAX_API_PARAM_ELEMENTS { if post_ids.len() > MAX_API_PARAM_ELEMENTS {
Err(LemmyErrorType::TooManyItems)?; Err(LemmyErrorType::TooManyItems)?;
@ -21,13 +20,9 @@ pub async fn mark_post_as_read(
// Mark the post as read / unread // Mark the post as read / unread
if data.read { if data.read {
PostRead::mark_as_read(&mut context.pool(), post_ids, person_id) PostRead::mark_as_read(&mut context.pool(), post_ids, person_id).await?;
.await
.with_lemmy_type(LemmyErrorType::CouldntMarkPostAsRead)?;
} else { } else {
PostRead::mark_as_unread(&mut context.pool(), post_ids, person_id) PostRead::mark_as_unread(&mut context.pool(), post_ids, person_id).await?;
.await
.with_lemmy_type(LemmyErrorType::CouldntMarkPostAsRead)?;
} }
Ok(Json(SuccessResponse::default())) Ok(Json(SuccessResponse::default()))

View file

@ -2,10 +2,9 @@ use actix_web::web::{Data, Json};
use lemmy_api_common::{ use lemmy_api_common::{
context::LemmyContext, context::LemmyContext,
post::{PostResponse, SavePost}, post::{PostResponse, SavePost},
utils::mark_post_as_read,
}; };
use lemmy_db_schema::{ use lemmy_db_schema::{
source::post::{PostSaved, PostSavedForm}, source::post::{PostRead, PostSaved, PostSavedForm},
traits::Saveable, traits::Saveable,
}; };
use lemmy_db_views::structs::{LocalUserView, PostView}; use lemmy_db_views::structs::{LocalUserView, PostView};
@ -42,7 +41,7 @@ pub async fn save_post(
) )
.await?; .await?;
mark_post_as_read(person_id, post_id, &mut context.pool()).await?; PostRead::mark_as_read(&mut context.pool(), &[post_id], person_id).await?;
Ok(Json(PostResponse { post_view })) Ok(Json(PostResponse { post_view }))
} }

View file

@ -138,6 +138,8 @@ pub struct SaveUserSettings {
pub show_upvotes: Option<bool>, pub show_upvotes: Option<bool>,
pub show_downvotes: Option<bool>, pub show_downvotes: Option<bool>,
pub show_upvote_percentage: Option<bool>, pub show_upvote_percentage: Option<bool>,
/// Whether to automatically mark fetched posts as read.
pub auto_mark_fetched_posts_as_read: Option<bool>,
} }
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]

View file

@ -28,7 +28,7 @@ use lemmy_db_schema::{
password_reset_request::PasswordResetRequest, password_reset_request::PasswordResetRequest,
person::{Person, PersonUpdateForm}, person::{Person, PersonUpdateForm},
person_block::PersonBlock, person_block::PersonBlock,
post::{Post, PostLike, PostRead}, post::{Post, PostLike},
registration_application::RegistrationApplication, registration_application::RegistrationApplication,
site::Site, site::Site,
}, },
@ -64,7 +64,7 @@ use lemmy_utils::{
use moka::future::Cache; use moka::future::Cache;
use regex::{escape, Regex, RegexSet}; use regex::{escape, Regex, RegexSet};
use rosetta_i18n::{Language, LanguageId}; use rosetta_i18n::{Language, LanguageId};
use std::{collections::HashSet, sync::LazyLock}; use std::sync::LazyLock;
use tracing::warn; use tracing::warn;
use url::{ParseError, Url}; use url::{ParseError, Url};
use urlencoding::encode; use urlencoding::encode;
@ -140,19 +140,6 @@ pub fn is_top_mod(
} }
} }
/// Marks a post as read for a given person.
#[tracing::instrument(skip_all)]
pub async fn mark_post_as_read(
person_id: PersonId,
post_id: PostId,
pool: &mut DbPool<'_>,
) -> LemmyResult<()> {
PostRead::mark_as_read(pool, HashSet::from([post_id]), person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntMarkPostAsRead)?;
Ok(())
}
/// Updates the read comment count for a post. Usually done when reading or creating a new comment. /// Updates the read comment count for a post. Usually done when reading or creating a new comment.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub async fn update_read_comments( pub async fn update_read_comments(

View file

@ -12,7 +12,6 @@ use lemmy_api_common::{
get_url_blocklist, get_url_blocklist,
honeypot_check, honeypot_check,
local_site_to_slur_regex, local_site_to_slur_regex,
mark_post_as_read,
process_markdown_opt, process_markdown_opt,
}, },
}; };
@ -22,7 +21,7 @@ use lemmy_db_schema::{
actor_language::CommunityLanguage, actor_language::CommunityLanguage,
community::Community, community::Community,
local_site::LocalSite, local_site::LocalSite,
post::{Post, PostInsertForm, PostLike, PostLikeForm}, post::{Post, PostInsertForm, PostLike, PostLikeForm, PostRead},
}, },
traits::{Crud, Likeable}, traits::{Crud, Likeable},
utils::diesel_url_create, utils::diesel_url_create,
@ -169,7 +168,7 @@ pub async fn create_post(
.await .await
.with_lemmy_type(LemmyErrorType::CouldntLikePost)?; .with_lemmy_type(LemmyErrorType::CouldntLikePost)?;
mark_post_as_read(person_id, post_id, &mut context.pool()).await?; PostRead::mark_as_read(&mut context.pool(), &[post_id], person_id).await?;
build_post_response(&context, community_id, local_user_view, post_id).await build_post_response(&context, community_id, local_user_view, post_id).await
} }

View file

@ -2,10 +2,13 @@ use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{ use lemmy_api_common::{
context::LemmyContext, context::LemmyContext,
post::{GetPost, GetPostResponse}, post::{GetPost, GetPostResponse},
utils::{check_private_instance, is_mod_or_admin_opt, mark_post_as_read, update_read_comments}, utils::{check_private_instance, is_mod_or_admin_opt, update_read_comments},
}; };
use lemmy_db_schema::{ use lemmy_db_schema::{
source::{comment::Comment, post::Post}, source::{
comment::Comment,
post::{Post, PostRead},
},
traits::Crud, traits::Crud,
}; };
use lemmy_db_views::{ use lemmy_db_views::{
@ -62,7 +65,7 @@ pub async fn get_post(
let post_id = post_view.post.id; let post_id = post_view.post.id;
if let Some(person_id) = person_id { if let Some(person_id) = person_id {
mark_post_as_read(person_id, post_id, &mut context.pool()).await?; PostRead::mark_as_read(&mut context.pool(), &[post_id], person_id).await?;
update_read_comments( update_read_comments(
person_id, person_id,

View file

@ -10,7 +10,10 @@ use lemmy_api_common::{
post::{GetPosts, GetPostsResponse}, post::{GetPosts, GetPostsResponse},
utils::{check_conflicting_like_filters, check_private_instance}, utils::{check_conflicting_like_filters, check_private_instance},
}; };
use lemmy_db_schema::source::community::Community; use lemmy_db_schema::{
newtypes::PostId,
source::{community::Community, post::PostRead},
};
use lemmy_db_views::{ use lemmy_db_views::{
post_view::PostQuery, post_view::PostQuery,
structs::{LocalUserView, PaginationCursor, SiteView}, structs::{LocalUserView, PaginationCursor, SiteView},
@ -88,6 +91,14 @@ pub async fn list_posts(
.await .await
.with_lemmy_type(LemmyErrorType::CouldntGetPosts)?; .with_lemmy_type(LemmyErrorType::CouldntGetPosts)?;
// If in their user settings, auto-mark fetched posts as read
if let Some(local_user) = local_user {
if local_user.auto_mark_fetched_posts_as_read {
let post_ids = posts.iter().map(|p| p.post.id).collect::<Vec<PostId>>();
PostRead::mark_as_read(&mut context.pool(), &post_ids, local_user.person_id).await?;
}
}
// if this page wasn't empty, then there is a next page after the last post on this page // if this page wasn't empty, then there is a next page after the last post on this page
let next_page = posts.last().map(PaginationCursor::after_post); let next_page = posts.last().map(PaginationCursor::after_post);
Ok(Json(GetPostsResponse { posts, next_page })) Ok(Json(GetPostsResponse { posts, next_page }))

View file

@ -39,6 +39,10 @@ use diesel::{
TextExpressionMethods, TextExpressionMethods,
}; };
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use lemmy_utils::{
error::{LemmyErrorExt, LemmyResult},
LemmyErrorType,
};
use std::collections::HashSet; use std::collections::HashSet;
#[async_trait] #[async_trait]
@ -322,36 +326,41 @@ impl Saveable for PostSaved {
impl PostRead { impl PostRead {
pub async fn mark_as_read( pub async fn mark_as_read(
pool: &mut DbPool<'_>, pool: &mut DbPool<'_>,
post_ids: HashSet<PostId>, post_ids: &[PostId],
person_id: PersonId, person_id: PersonId,
) -> Result<usize, Error> { ) -> LemmyResult<usize> {
let conn = &mut get_conn(pool).await?; let conn = &mut get_conn(pool).await?;
let forms = post_ids let forms = post_ids
.into_iter() .iter()
.map(|post_id| PostReadForm { post_id, person_id }) .map(|post_id| PostReadForm {
post_id: *post_id,
person_id,
})
.collect::<Vec<PostReadForm>>(); .collect::<Vec<PostReadForm>>();
insert_into(post_read::table) insert_into(post_read::table)
.values(forms) .values(forms)
.on_conflict_do_nothing() .on_conflict_do_nothing()
.execute(conn) .execute(conn)
.await .await
.with_lemmy_type(LemmyErrorType::CouldntMarkPostAsRead)
} }
pub async fn mark_as_unread( pub async fn mark_as_unread(
pool: &mut DbPool<'_>, pool: &mut DbPool<'_>,
post_id_: HashSet<PostId>, post_ids: &[PostId],
person_id_: PersonId, person_id_: PersonId,
) -> Result<usize, Error> { ) -> LemmyResult<usize> {
let conn = &mut get_conn(pool).await?; let conn = &mut get_conn(pool).await?;
diesel::delete( diesel::delete(
post_read::table post_read::table
.filter(post_read::post_id.eq_any(post_id_)) .filter(post_read::post_id.eq_any(post_ids))
.filter(post_read::person_id.eq(person_id_)), .filter(post_read::person_id.eq(person_id_)),
) )
.execute(conn) .execute(conn)
.await .await
.with_lemmy_type(LemmyErrorType::CouldntMarkPostAsRead)
} }
} }
@ -417,7 +426,6 @@ mod tests {
use lemmy_utils::error::LemmyResult; use lemmy_utils::error::LemmyResult;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serial_test::serial; use serial_test::serial;
use std::collections::HashSet;
use url::Url; use url::Url;
#[tokio::test] #[tokio::test]
@ -521,7 +529,7 @@ mod tests {
// Post Read // Post Read
let marked_as_read = PostRead::mark_as_read( let marked_as_read = PostRead::mark_as_read(
pool, pool,
HashSet::from([inserted_post.id, inserted_post2.id]), &[inserted_post.id, inserted_post2.id],
inserted_person.id, inserted_person.id,
) )
.await?; .await?;
@ -545,7 +553,7 @@ mod tests {
assert_eq!(1, saved_removed); assert_eq!(1, saved_removed);
let read_removed = PostRead::mark_as_unread( let read_removed = PostRead::mark_as_unread(
pool, pool,
HashSet::from([inserted_post.id, inserted_post2.id]), &[inserted_post.id, inserted_post2.id],
inserted_person.id, inserted_person.id,
) )
.await?; .await?;

View file

@ -474,6 +474,7 @@ diesel::table! {
enable_animated_images -> Bool, enable_animated_images -> Bool,
collapse_bot_comments -> Bool, collapse_bot_comments -> Bool,
default_comment_sort_type -> CommentSortTypeEnum, default_comment_sort_type -> CommentSortTypeEnum,
auto_mark_fetched_posts_as_read -> Bool,
} }
} }

View file

@ -65,6 +65,8 @@ pub struct LocalUser {
/// Whether to auto-collapse bot comments. /// Whether to auto-collapse bot comments.
pub collapse_bot_comments: bool, pub collapse_bot_comments: bool,
pub default_comment_sort_type: CommentSortType, pub default_comment_sort_type: CommentSortType,
/// Whether to automatically mark fetched posts as read.
pub auto_mark_fetched_posts_as_read: bool,
} }
#[derive(Clone, derive_new::new)] #[derive(Clone, derive_new::new)]
@ -119,6 +121,8 @@ pub struct LocalUserInsertForm {
pub collapse_bot_comments: Option<bool>, pub collapse_bot_comments: Option<bool>,
#[new(default)] #[new(default)]
pub default_comment_sort_type: Option<CommentSortType>, pub default_comment_sort_type: Option<CommentSortType>,
#[new(default)]
pub auto_mark_fetched_posts_as_read: Option<bool>,
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
@ -149,4 +153,5 @@ pub struct LocalUserUpdateForm {
pub enable_animated_images: Option<bool>, pub enable_animated_images: Option<bool>,
pub collapse_bot_comments: Option<bool>, pub collapse_bot_comments: Option<bool>,
pub default_comment_sort_type: Option<CommentSortType>, pub default_comment_sort_type: Option<CommentSortType>,
pub auto_mark_fetched_posts_as_read: Option<bool>,
} }

View file

@ -1644,7 +1644,7 @@ mod tests {
// Mark a post as read // Mark a post as read
PostRead::mark_as_read( PostRead::mark_as_read(
pool, pool,
HashSet::from([data.inserted_bot_post.id]), &[data.inserted_bot_post.id],
data.local_user_view.person.id, data.local_user_view.person.id,
) )
.await?; .await?;

View file

@ -241,6 +241,7 @@ mod tests {
enable_keyboard_navigation: inserted_sara_local_user.enable_keyboard_navigation, enable_keyboard_navigation: inserted_sara_local_user.enable_keyboard_navigation,
enable_animated_images: inserted_sara_local_user.enable_animated_images, enable_animated_images: inserted_sara_local_user.enable_animated_images,
collapse_bot_comments: inserted_sara_local_user.collapse_bot_comments, collapse_bot_comments: inserted_sara_local_user.collapse_bot_comments,
auto_mark_fetched_posts_as_read: false,
}, },
creator: Person { creator: Person {
id: inserted_sara_person.id, id: inserted_sara_person.id,

View file

@ -0,0 +1,3 @@
ALTER TABLE local_user
DROP COLUMN auto_mark_fetched_posts_as_read;

View file

@ -0,0 +1,3 @@
ALTER TABLE local_user
ADD COLUMN auto_mark_fetched_posts_as_read boolean DEFAULT FALSE NOT NULL;