sort posts by top, hot, new

This commit is contained in:
rimu 2024-01-03 20:14:39 +13:00
parent acfe35d98d
commit b431a79518
16 changed files with 298 additions and 30 deletions

View file

@ -701,7 +701,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
user.instance.ip_address = ip_address
user.instance.dormant = False
if 'community' in vars() and community is not None:
if community.is_local() and request_json['type'] not in ['Announce', 'Follow', 'Accept']:
if community.is_local() and request_json['type'] not in ['Announce', 'Follow', 'Accept', 'Undo']:
announce_activity_to_followers(community, user, request_json)
# community.flush_cache()
if 'post' in vars() and post is not None:

View file

@ -20,7 +20,7 @@ from PIL import Image, ImageOps
from io import BytesIO
from app.utils import get_request, allowlist_html, html_to_markdown, get_setting, ap_datetime, markdown_to_html, \
is_image_url, domain_from_url, gibberish, ensure_directory_exists, markdown_to_text, head_request
is_image_url, domain_from_url, gibberish, ensure_directory_exists, markdown_to_text, head_request, post_ranking
def public_key():
@ -729,6 +729,8 @@ def downvote_post(post, user):
db.session.add(vote)
else:
pass # they have already downvoted this post
post.ranking = post_ranking(post.score, post.posted_at)
db.session.commit()
def downvote_post_reply(comment, user):
@ -831,6 +833,8 @@ def upvote_post(post, user):
effect = 0
post.author.reputation += effect
db.session.add(vote)
post.ranking = post_ranking(post.score, post.posted_at)
db.session.commit()
def delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id):
@ -972,6 +976,7 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
post.image = image
if post is not None:
db.session.add(post)
post.ranking = post_ranking(post.score, post.posted_at)
community.post_count += 1
community.last_active = utcnow()
activity_log.result = 'success'
@ -986,7 +991,7 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
db.session.add(vote)
post.up_votes += 1
post.score += 1
post.ranking += 1
post.ranking = post_ranking(post.score, post.posted_at)
db.session.commit()
return post

View file

@ -19,7 +19,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership, ap_datetime, \
request_etag_matches, return_304, instance_banned, can_create, can_upvote, can_downvote
from feedgen.feed import FeedGenerator
from datetime import timezone
from datetime import timezone, timedelta
@bp.route('/add_local', methods=['GET', 'POST'])
@ -98,6 +98,7 @@ def show_community(community: Community):
abort(404)
page = request.args.get('page', 1, type=int)
sort = request.args.get('sort', '')
mods = community.moderators()
@ -113,17 +114,24 @@ def show_community(community: Community):
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
if current_user.is_anonymous or current_user.ignore_bots:
posts = community.posts.filter(Post.from_bot == False).order_by(desc(Post.last_active)).paginate(page=page, per_page=100, error_out=False)
posts = community.posts.filter(Post.from_bot == False)
else:
posts = community.posts.order_by(desc(Post.last_active)).paginate(page=page, per_page=100, error_out=False)
posts = community.posts
if sort == '' or sort == 'hot':
posts = posts.order_by(desc(Post.ranking))
elif sort == 'top':
posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=7)).order_by(desc(Post.score))
elif sort == 'new':
posts = posts.order_by(desc(Post.posted_at))
posts = posts.paginate(page=page, per_page=100, error_out=False)
description = shorten_string(community.description, 150) if community.description else None
og_image = community.image.source_url if community.image_id else None
next_url = url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name,
page=posts.next_num) if posts.has_next else None
page=posts.next_num, sort=sort) if posts.has_next else None
prev_url = url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name,
page=posts.prev_num) if posts.has_prev and page != 1 else None
page=posts.prev_num, sort=sort) if posts.has_prev and page != 1 else None
return render_template('community/community.html', community=community, title=community.title,
is_moderator=is_moderator, is_owner=is_owner, is_admin=is_admin, mods=mod_list, posts=posts, description=description,

View file

@ -15,7 +15,7 @@ from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE
from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \
Instance
from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, validate_image, allowlist_html, \
html_to_markdown, is_image_url, ensure_directory_exists, inbox_domain
html_to_markdown, is_image_url, ensure_directory_exists, inbox_domain, post_ranking
from sqlalchemy import desc, text
import os
from opengraph_parse import parse_page
@ -103,6 +103,7 @@ def retrieve_mods_and_backfill(community_id: int):
post = post_json_to_model(activity['object']['object'], user, community)
post.ap_create_id = activity['object']['id']
post.ap_announce_id = activity['id']
post.ranking = post_ranking(post.score, post.posted_at)
db.session.commit()
activities_processed += 1
@ -267,7 +268,9 @@ def save_post(form, post: Post):
post.score = -1
post.ranking = -1
db.session.add(postvote)
post.ranking = post_ranking(post.score, utcnow())
db.session.add(post)
g.site.last_active = utcnow()

View file

@ -1,3 +1,6 @@
from datetime import datetime, timedelta
from math import log
from sqlalchemy.sql.operators import or_
from app import db, cache
@ -12,7 +15,7 @@ from sqlalchemy import select, desc
from sqlalchemy_searchable import search
from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains, \
ap_datetime, ip_address
from app.models import Community, CommunityMember, Post, Site, User
from app.models import Community, CommunityMember, Post, Site, User, utcnow
@bp.route('/', methods=['HEAD', 'GET', 'POST'])
@ -40,7 +43,7 @@ def index():
if domains_ids:
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
posts = posts.order_by(desc(Post.last_active)).paginate(page=page, per_page=100, error_out=False)
posts = posts.order_by(desc(Post.ranking)).paginate(page=page, per_page=100, error_out=False)
next_url = url_for('main.index', page=posts.next_num) if posts.has_next else None
prev_url = url_for('main.index', page=posts.prev_num) if posts.has_prev and page != 1 else None
@ -54,6 +57,78 @@ def index():
rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed", rss_feed_name=f"Posts on " + g.site.name)
@bp.route('/new', methods=['HEAD', 'GET', 'POST'])
def new_posts():
verification_warning()
# If nothing has changed since their last visit, return HTTP 304
current_etag = f"new_{hash(str(g.site.last_active))}"
if current_user.is_anonymous and request_etag_matches(current_etag):
return return_304(current_etag)
page = request.args.get('page', 1, type=int)
if current_user.is_anonymous:
posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
else:
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(
CommunityMember.is_banned == False)
posts = posts.join(User, CommunityMember.user_id == User.id).filter(User.id == current_user.id)
domains_ids = blocked_domains(current_user.id)
if domains_ids:
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
posts = posts.order_by(desc(Post.posted_at)).paginate(page=page, per_page=100, error_out=False)
next_url = url_for('main.new_posts', page=posts.next_num) if posts.has_next else None
prev_url = url_for('main.new_posts', page=posts.prev_num) if posts.has_prev and page != 1 else None
active_communities = Community.query.filter_by(banned=False).order_by(desc(Community.last_active)).limit(5).all()
return render_template('new_posts.html', posts=posts, active_communities=active_communities,
POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK,
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url,
rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed",
rss_feed_name=f"Posts on " + g.site.name)
@bp.route('/top', methods=['HEAD', 'GET', 'POST'])
def top_posts():
verification_warning()
# If nothing has changed since their last visit, return HTTP 304
current_etag = f"best_{hash(str(g.site.last_active))}"
if current_user.is_anonymous and request_etag_matches(current_etag):
return return_304(current_etag)
page = request.args.get('page', 1, type=int)
if current_user.is_anonymous:
posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
else:
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(
CommunityMember.is_banned == False)
posts = posts.join(User, CommunityMember.user_id == User.id).filter(User.id == current_user.id)
domains_ids = blocked_domains(current_user.id)
if domains_ids:
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.score)).paginate(page=page, per_page=100, error_out=False)
next_url = url_for('main.top_posts', page=posts.next_num) if posts.has_next else None
prev_url = url_for('main.top_posts', page=posts.prev_num) if posts.has_prev and page != 1 else None
active_communities = Community.query.filter_by(banned=False).order_by(desc(Community.last_active)).limit(5).all()
return render_template('top_posts.html', posts=posts, active_communities=active_communities,
POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK,
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url,
rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed",
rss_feed_name=f"Posts on " + g.site.name)
@bp.route('/communities', methods=['GET'])
def list_communities():
verification_warning()
@ -99,10 +174,12 @@ def robots():
@bp.route('/test')
def test():
ip = request.headers.get('X-Forwarded-For') or request.remote_addr
if ',' in ip: # Remove all but first ip addresses
ip = ip[:ip.index(',')].strip()
return ip
return 'done'
#ip = request.headers.get('X-Forwarded-For') or request.remote_addr
#if ',' in ip: # Remove all but first ip addresses
# ip = ip[:ip.index(',')].strip()
#return ip
def verification_warning():

View file

@ -18,7 +18,7 @@ from app.models import Post, PostReply, \
from app.post import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, ap_datetime, return_304, \
request_etag_matches, ip_address, user_ip_banned, instance_banned, can_downvote, can_upvote
request_etag_matches, ip_address, user_ip_banned, instance_banned, can_downvote, can_upvote, post_ranking
def show_post(post_id: int):
@ -155,11 +155,11 @@ def show_post(post_id: int):
og_image = post.image.source_url if post.image_id else None
description = shorten_string(markdown_to_text(post.body), 150) if post.body else None
return render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator,
return render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community,
canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH,
description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE,
POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_ARTICLE=constants.POST_TYPE_ARTICLE,
etag=f"{post.id}_{hash(post.last_active)}", markdown_editor=True, community=community)
etag=f"{post.id}_{hash(post.last_active)}", markdown_editor=True)
@bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST'])
@ -247,11 +247,12 @@ def post_vote(post_id: int, vote_direction):
current_user.last_seen = utcnow()
current_user.ip_address = ip_address()
if not current_user.banned:
post.ranking = post_ranking(post.score, post.created_at)
db.session.commit()
current_user.recalculate_attitude()
db.session.commit()
post.flush_cache()
return render_template('post/_post_voting_buttons.html', post=post,
return render_template('post/_post_voting_buttons.html', post=post, community=post.community,
upvoted_class=upvoted_class,
downvoted_class=downvoted_class)
@ -327,7 +328,7 @@ def comment_vote(comment_id, vote_direction):
comment.post.flush_cache()
return render_template('post/_voting_buttons.html', comment=comment,
upvoted_class=upvoted_class,
downvoted_class=downvoted_class)
downvoted_class=downvoted_class, community=comment.community)
@bp.route('/post/<int:post_id>/comment/<int:comment_id>')

View file

@ -0,0 +1,11 @@
<div class="btn-group mt-1 mb-2">
<a href="/" class="btn {{ 'btn-primary' if request.path == '/' else 'btn-outline-secondary' }}" rel="nofollow">
{{ _('Hot') }}
</a>
<a href="/top" class="btn {{ 'btn-primary' if request.path == '/top' else 'btn-outline-secondary' }}" rel="nofollow">
{{ _('Top') }}
</a>
<a href="/new" class="btn {{ 'btn-primary' if request.path == '/new' else 'btn-outline-secondary' }}" rel="nofollow">
{{ _('New') }}
</a>
</div>

View file

@ -40,7 +40,7 @@
</table>
<nav aria-label="Pagination" class="mt-4">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary">
<a href="{{ prev_url }}" class="btn btn-primary" rel="nofollow">
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}

View file

@ -0,0 +1,11 @@
<div class="btn-group mt-1 mb-2">
<a href="?sort=hot" class="btn {{ 'btn-primary' if request.args.get('sort', '') == '' or request.args.get('sort', '') == 'hot' else 'btn-outline-secondary' }}" rel="nofollow">
{{ _('Hot') }}
</a>
<a href="?sort=top" class="btn {{ 'btn-primary' if request.args.get('sort', '') == 'top' else 'btn-outline-secondary' }}" rel="nofollow">
{{ _('Top') }}
</a>
<a href="?sort=new" class="btn {{ 'btn-primary' if request.args.get('sort', '') == 'new' else 'btn-outline-secondary' }}" rel="nofollow">
{{ _('New') }}
</a>
</div>

View file

@ -35,6 +35,7 @@
</nav>
<h1 class="mt-2">{{ community.title }}</h1>
{% endif %}
{% include "community/_community_nav.html" %}
<div class="post_list">
{% for post in posts %}
{% include 'post/_post_teaser.html' %}
@ -45,12 +46,12 @@
<nav aria-label="Pagination" class="mt-4">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary">
<a href="{{ prev_url }}" class="btn btn-primary" rel='nofollow'>
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary">
<a href="{{ next_url }}" class="btn btn-primary" rel='nofollow'>
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}

View file

@ -4,7 +4,7 @@
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<h1 class="mt-2">{{ _('Recent posts') }}</h1>
{% include "_home_nav.html" %}
<div class="post_list">
{% for post in posts %}
{% include 'post/_post_teaser.html' %}
@ -15,12 +15,12 @@
<nav aria-label="Pagination" class="mt-4">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary">
<a href="{{ prev_url }}" class="btn btn-primary" rel="nofollow">
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary">
<a href="{{ next_url }}" class="btn btn-primary" rel="nofollow">
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}

View file

@ -15,12 +15,9 @@
{{ _('Subscribed') }}
</a>
</div>
</div>
<div class="col-auto">
<div class="btn-group">
<a href="{{ url_for('community.add_local') }}" class="btn btn-outline-secondary">{{ _('Create local') }}</a>
<a href="{{ url_for('community.add_remote') }}" class="btn btn-outline-secondary">{{ _('Add remote') }}</a>
</div>

View file

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% from 'bootstrap5/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
{% include "_home_nav.html" %}
<div class="post_list">
{% for post in posts %}
{% include 'post/_post_teaser.html' %}
{% else %}
<p>{{ _('No posts yet.') }}</p>
{% endfor %}
</div>
<nav aria-label="Pagination" class="mt-4">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary" rel="nofollow">
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary" rel="nofollow">
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}
</nav>
</div>
<div class="col-12 col-md-4">
<div class="card">
<div class="card-body">
<form method="get">
<input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search') }}" />
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Active communities') }}</h2>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
{% for community in active_communities %}
<li class="list-group-item">
<a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />
{{ community.display_name() }}
</a>
</li>
{% endfor %}
</ul>
<p class="mt-4"><a class="btn btn-primary" href="/communities">Explore communities</a></p>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('About %(site_name)s', site_name=g.site.name) }}</h2>
</div>
<div class="card-body">
<p><strong>{{ g.site.description|safe }}</strong></p>
<p>{{ g.site.sidebar|safe }}</p>
<p class="mt-4">
<a class="no-underline" href="{{ rss_feed }}" rel="nofollow"><span class="fe fe-rss"></span> RSS feed</a>
</p>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% from 'bootstrap5/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
{% include "_home_nav.html" %}
<div class="post_list">
{% for post in posts %}
{% include 'post/_post_teaser.html' %}
{% else %}
<p>{{ _('No posts yet.') }}</p>
{% endfor %}
</div>
<nav aria-label="Pagination" class="mt-4">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary" rel="nofollow">
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary" rel="nofollow">
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}
</nav>
</div>
<div class="col-12 col-md-4">
<div class="card">
<div class="card-body">
<form method="get">
<input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search') }}" />
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Active communities') }}</h2>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
{% for community in active_communities %}
<li class="list-group-item">
<a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />
{{ community.display_name() }}
</a>
</li>
{% endfor %}
</ul>
<p class="mt-4"><a class="btn btn-primary" href="/communities">Explore communities</a></p>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('About %(site_name)s', site_name=g.site.name) }}</h2>
</div>
<div class="card-body">
<p><strong>{{ g.site.description|safe }}</strong></p>
<p>{{ g.site.sidebar|safe }}</p>
<p class="mt-4">
<a class="no-underline" href="{{ rss_feed }}" rel="nofollow"><span class="fe fe-rss"></span> RSS feed</a>
</p>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -454,3 +454,16 @@ def awaken_dormant_instance(instance):
instance.gone_forever = True
instance.dormant = True
db.session.commit()
epoch = datetime(1970, 1, 1)
def epoch_seconds(date):
td = date - epoch
return td.days * 86400 + td.seconds + (float(td.microseconds) / 1000000)
def post_ranking(score, date: datetime):
order = math.log(max(abs(score), 1), 10)
sign = 1 if score > 0 else -1 if score < 0 else 0
seconds = epoch_seconds(date) - 1685766018
return round(sign * order + seconds / 45000, 7)

View file

@ -16,7 +16,6 @@ class Config(object):
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
#SQLALCHEMY_ECHO = True # set to true to see debugging SQL in console
RECAPTCHA3_PUBLIC_KEY = os.environ.get("RECAPTCHA3_PUBLIC_KEY")
RECAPTCHA3_PRIVATE_KEY = os.environ.get("RECAPTCHA3_PRIVATE_KEY")
MODE = os.environ.get('MODE') or 'development'