diff --git a/app/community/routes.py b/app/community/routes.py index fbffb8f3..95002e95 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -228,6 +228,13 @@ def show_community(community: Community): posts = posts.filter(Post.nsfl == False) if current_user.hide_nsfw == 1: posts = posts.filter(Post.nsfw == False) + if current_user.hide_read_posts: + cu_rp = current_user.read_post.all() + cu_rp_ids = [] + for p in cu_rp: + cu_rp_ids.append(p.id) + for p_id in cu_rp_ids: + posts = posts.filter(Post.id != p_id) content_filters = user_filters_posts(current_user.id) posts = posts.filter(Post.deleted == False) diff --git a/app/domain/routes.py b/app/domain/routes.py index cd5adade..d04828bc 100644 --- a/app/domain/routes.py +++ b/app/domain/routes.py @@ -38,6 +38,16 @@ def show_domain(domain_id): content_filters = user_filters_posts(current_user.id) else: content_filters = {} + + # don't show posts a user has already interacted with + if current_user.hide_read_posts: + cu_rp = current_user.read_post.all() + cu_rp_ids = [] + for p in cu_rp: + cu_rp_ids.append(p.id) + for p_id in cu_rp_ids: + posts = posts.filter(Post.id != p_id) + # pagination posts = posts.paginate(page=page, per_page=100, error_out=False) next_url = url_for('domain.show_domain', domain_id=domain_id, page=posts.next_num) if posts.has_next else None diff --git a/app/main/routes.py b/app/main/routes.py index 864a7bd4..df8479a1 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -71,6 +71,13 @@ def home_page(sort, view_filter): posts = posts.filter(Post.nsfl == False) if current_user.hide_nsfw == 1: posts = posts.filter(Post.nsfw == False) + if current_user.hide_read_posts: + cu_rp = current_user.read_post.all() + cu_rp_ids = [] + for p in cu_rp: + cu_rp_ids.append(p.id) + for p_id in cu_rp_ids: + posts = posts.filter(Post.id != p_id) domains_ids = blocked_domains(current_user.id) if domains_ids: diff --git a/app/models.py b/app/models.py index 43fe8378..31e1fbde 100644 --- a/app/models.py +++ b/app/models.py @@ -613,6 +613,12 @@ user_role = db.Table('user_role', db.PrimaryKeyConstraint('user_id', 'role_id') ) +# table to hold users' 'read' post ids +read_posts = db.Table('read_posts', + db.Column('user_id', db.Integer, db.ForeignKey('user.id'), index=True), + db.Column('read_post_id', db.Integer, db.ForeignKey('post.id'), index=True), + db.Column('interacted_at', db.DateTime, index=True, default=utcnow) # this is when the content is interacte with + ) class User(UserMixin, db.Model): query_class = FullTextSearchQuery @@ -692,6 +698,12 @@ class User(UserMixin, db.Model): roles = db.relationship('Role', secondary=user_role, lazy='dynamic', cascade="all, delete") + hide_read_posts = db.Column(db.Boolean, default=False) + # db relationship tracked by the "read_posts" table + # this is the User side, so its referencing the Post side + # read_by is the corresponding Post object variable + read_post = db.relationship('Post', secondary=read_posts, back_populates='read_by', lazy='dynamic') + def __repr__(self): return ''.format(self.user_name, self.id) @@ -1017,6 +1029,18 @@ class User(UserMixin, db.Model): except Exception as e: return str(e) + # mark a post as 'read' for this user + def mark_post_as_read(self, post): + # check if its already marked as read, if not, mark it as read + if not self.has_read_post(post): + self.read_post.append(post) + + # check if post has been read by this user + # returns true if the post has been read, false if not + def has_read_post(self, post): + return self.read_post.filter(read_posts.c.read_post_id == post.id).count() > 0 + + class ActivityLog(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -1079,6 +1103,11 @@ class Post(db.Model): replies = db.relationship('PostReply', lazy='dynamic', backref='post') language = db.relationship('Language', foreign_keys=[language_id]) + # db relationship tracked by the "read_posts" table + # this is the Post side, so its referencing the User side + # read_post is the corresponding User object variable + read_by = db.relationship('User', secondary=read_posts, back_populates='read_post', lazy='dynamic') + def is_local(self): return self.ap_id is None or self.ap_id.startswith('https://' + current_app.config['SERVER_NAME']) @@ -1560,6 +1589,7 @@ class PostReply(db.Model): return undo + class Domain(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(255), index=True) diff --git a/app/post/routes.py b/app/post/routes.py index 702cb077..49174d03 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -247,6 +247,12 @@ def show_post(post_id: int): if post.type == POST_TYPE_LINK and body_has_no_archive_link(post.body_html) and url_needs_archive(post.url): archive_link = generate_archive_link(post.url) + # for logged in users who have the 'hide read posts' function enabled + # mark this post as read + if current_user.is_authenticated and current_user.hide_read_posts: + current_user.mark_post_as_read(post) + db.session.commit() + response = render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, is_owner=community.is_owner(), community=post.community, breadcrumbs=breadcrumbs, related_communities=related_communities, mods=mod_list, @@ -330,6 +336,12 @@ def post_vote(post_id: int, vote_direction): cache.delete_memoized(recently_upvoted_posts, current_user.id) cache.delete_memoized(recently_downvoted_posts, current_user.id) + # for logged in users who have the 'hide read posts' function enabled + # mark this post as read + if current_user.is_authenticated and current_user.hide_read_posts: + current_user.mark_post_as_read(post) + db.session.commit() + template = 'post/_post_voting_buttons.html' if request.args.get('style', '') == '' else 'post/_post_voting_buttons_masonry.html' return render_template(template, post=post, community=post.community, recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted) diff --git a/app/post/util.py b/app/post/util.py index 150d9977..f904abb3 100644 --- a/app/post/util.py +++ b/app/post/util.py @@ -113,3 +113,4 @@ def url_needs_archive(url) -> bool: def generate_archive_link(url) -> bool: return 'https://archive.ph/' + url + diff --git a/app/templates/base.html b/app/templates/base.html index 1e3db0e0..cb1cf8d0 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -197,6 +197,7 @@
  • {{ _('Edit profile & settings') }}
  • {{ _('Chats') }}
  • {{ _('Bookmarks') }}
  • +
  • {{ _('Read Posts') }}
  • diff --git a/app/templates/user/edit_settings.html b/app/templates/user/edit_settings.html index b834d7b3..1742764e 100644 --- a/app/templates/user/edit_settings.html +++ b/app/templates/user/edit_settings.html @@ -27,6 +27,7 @@
    Visibility
    {{ render_field(form.searchable) }} {{ render_field(form.indexable) }} + {{ render_field(form.hide_read_posts) }}
    Preferences
    {{ render_field(form.interface_language) }} {{ render_field(form.markdown_editor) }} diff --git a/app/templates/user/read_posts.html b/app/templates/user/read_posts.html new file mode 100644 index 00000000..8427646d --- /dev/null +++ b/app/templates/user/read_posts.html @@ -0,0 +1,39 @@ +{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} + {% extends 'themes/' + theme() + '/base.html' %} +{% else %} + {% extends "base.html" %} +{% endif %} +{% set active_child = 'read_posts' %} + +{% block app_content %} +
    +
    + +

    {{ _('Read Posts') }}

    +
    + {% for post in posts.items -%} + {% include 'post/_post_teaser.html' %} + {% endfor -%} +
    + + +
    +
    +{% endblock %} diff --git a/app/topic/routes.py b/app/topic/routes.py index 59dfeb11..cb4bbc80 100644 --- a/app/topic/routes.py +++ b/app/topic/routes.py @@ -65,6 +65,13 @@ def show_topic(topic_path): posts = posts.filter(Post.nsfl == False) if current_user.hide_nsfw == 1: posts = posts.filter(Post.nsfw == False) + if current_user.hide_read_posts: + cu_rp = current_user.read_post.all() + cu_rp_ids = [] + for p in cu_rp: + cu_rp_ids.append(p.id) + for p_id in cu_rp_ids: + posts = posts.filter(Post.id != p_id) posts = posts.filter(Post.deleted == False) content_filters = user_filters_posts(current_user.id) diff --git a/app/user/forms.py b/app/user/forms.py index 7a4b3e5a..dc702ad2 100644 --- a/app/user/forms.py +++ b/app/user/forms.py @@ -43,6 +43,7 @@ class SettingsForm(FlaskForm): markdown_editor = BooleanField(_l('Use markdown editor GUI when writing')) searchable = BooleanField(_l('Show profile in user list')) indexable = BooleanField(_l('My posts appear in search results')) + hide_read_posts = BooleanField(_l('Do not display posts with which I have already interacted (opened/upvoted/downvoted)')) manually_approves_followers = BooleanField(_l('Manually approve followers')) vote_privately = BooleanField(_l('Vote privately')) sorts = [('hot', _l('Hot')), diff --git a/app/user/routes.py b/app/user/routes.py index 623b87fa..5aee3efe 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -379,6 +379,7 @@ def user_settings(): current_user.newsletter = form.newsletter.data current_user.searchable = form.searchable.data current_user.indexable = form.indexable.data + current_user.hide_read_posts = form.hide_read_posts.data current_user.default_sort = form.default_sort.data current_user.default_filter = form.default_filter.data current_user.theme = form.theme.data @@ -405,6 +406,7 @@ def user_settings(): form.email_unread.data = current_user.email_unread form.searchable.data = current_user.searchable form.indexable.data = current_user.indexable + form.hide_read_posts.data = current_user.hide_read_posts form.default_sort.data = current_user.default_sort form.default_filter.data = current_user.default_filter form.theme.data = current_user.theme @@ -1170,3 +1172,40 @@ def fediverse_redirect(actor): send_to = request.cookies.get('remote_instance_url') form.instance_url.data = send_to return render_template('user/fediverse_redirect.html', form=form, user=user, send_to=send_to, current_app=current_app) + +@bp.route('/read-posts') +@login_required +def user_read_posts(): + page = request.args.get('page', 1, type=int) + low_bandwidth = request.cookies.get('low_bandwidth', '0') == '1' + + posts = Post.query.filter(Post.deleted == False) + + if current_user.ignore_bots == 1: + posts = posts.filter(Post.from_bot == False) + if current_user.hide_nsfl == 1: + posts = posts.filter(Post.nsfl == False) + if current_user.hide_nsfw == 1: + posts = posts.filter(Post.nsfw == False) + + # get the list of post.ids that the + # current_user has already read/voted on + cu_rp = current_user.read_post.all() + cu_rp_ids = [] + for p in cu_rp: + cu_rp_ids.append(p.id) + + # filter for just those post.ids + posts = posts.filter(Post.id.in_(cu_rp_ids)) + + posts = posts.paginate(page=page, per_page=100 if current_user.is_authenticated and not low_bandwidth else 50, + error_out=False) + next_url = url_for('user.user_read_posts', page=posts.next_num) if posts.has_next else None + prev_url = url_for('user.user_read_posts', page=posts.prev_num) if posts.has_prev and page != 1 else None + + return render_template('user/read_posts.html', title=_('Read Posts'), posts=posts, show_post_community=True, + low_bandwidth=low_bandwidth, user=current_user, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.get_id()), + menu_topics=menu_topics(), site=g.site, + next_url=next_url, prev_url=prev_url) diff --git a/migrations/versions/238d7c60d676_adding_created_at_to_read_posts_table.py b/migrations/versions/238d7c60d676_adding_created_at_to_read_posts_table.py new file mode 100644 index 00000000..261dd72f --- /dev/null +++ b/migrations/versions/238d7c60d676_adding_created_at_to_read_posts_table.py @@ -0,0 +1,34 @@ +"""adding created_at to read_posts table + +Revision ID: 238d7c60d676 +Revises: 6e0b98e1fdc6 +Create Date: 2024-09-28 09:26:22.000646 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '238d7c60d676' +down_revision = '6e0b98e1fdc6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('read_posts', schema=None) as batch_op: + batch_op.add_column(sa.Column('interacted_at', sa.DateTime(), nullable=True)) + batch_op.create_index(batch_op.f('ix_read_posts_interacted_at'), ['interacted_at'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('read_posts', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_read_posts_interacted_at')) + batch_op.drop_column('interacted_at') + + # ### end Alembic commands ### diff --git a/migrations/versions/27b71f6cb21e_adding_read_posts_table.py b/migrations/versions/27b71f6cb21e_adding_read_posts_table.py new file mode 100644 index 00000000..1865dfb8 --- /dev/null +++ b/migrations/versions/27b71f6cb21e_adding_read_posts_table.py @@ -0,0 +1,41 @@ +"""adding read_posts table + +Revision ID: 27b71f6cb21e +Revises: fdaeb0b2c078 +Create Date: 2024-09-27 10:17:54.020762 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '27b71f6cb21e' +down_revision = 'fdaeb0b2c078' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('read_posts', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('read_post_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['read_post_id'], ['post.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + with op.batch_alter_table('read_posts', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_read_posts_read_post_id'), ['read_post_id'], unique=False) + batch_op.create_index(batch_op.f('ix_read_posts_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('read_posts', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_read_posts_user_id')) + batch_op.drop_index(batch_op.f('ix_read_posts_read_post_id')) + + op.drop_table('read_posts') + # ### end Alembic commands ### diff --git a/migrations/versions/6e0b98e1fdc6_adding_hide_read_posts_bool_to_user.py b/migrations/versions/6e0b98e1fdc6_adding_hide_read_posts_bool_to_user.py new file mode 100644 index 00000000..1c050f0e --- /dev/null +++ b/migrations/versions/6e0b98e1fdc6_adding_hide_read_posts_bool_to_user.py @@ -0,0 +1,32 @@ +"""adding hide_read_posts bool to User + +Revision ID: 6e0b98e1fdc6 +Revises: 27b71f6cb21e +Create Date: 2024-09-27 10:35:19.451448 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6e0b98e1fdc6' +down_revision = '27b71f6cb21e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('hide_read_posts', sa.Boolean(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('hide_read_posts') + + # ### end Alembic commands ###