organise communities under topics

This commit is contained in:
rimu 2024-01-28 18:11:32 +13:00
parent 04a4abe9d5
commit 241fe8ec38
18 changed files with 383 additions and 29 deletions

View file

@ -74,6 +74,9 @@ def create_app(config_class=Config):
from app.domain import bp as domain_bp
app.register_blueprint(domain_bp)
from app.topic import bp as topic_bp
app.register_blueprint(topic_bp)
def get_resource_as_string(name, charset='utf-8'):
with app.open_resource(name) as f:
return f.read().decode(charset)

View file

@ -57,7 +57,10 @@ def login():
db.session.commit()
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('main.index')
if len(current_user.communities()) == 0:
next_page = url_for('topic.choose_topics')
else:
next_page = url_for('main.index')
response = make_response(redirect(next_page))
if form.low_bandwidth_mode.data:
response.set_cookie('low_bandwidth', '1', expires=datetime(year=2099, month=12, day=30))
@ -108,9 +111,9 @@ def register():
if current_app.config['MODE'] == 'development':
current_app.logger.info('Verify account:' + url_for('auth.verify_email', token=user.verification_token, _external=True))
flash(_('Great, you are now a registered user! Choose some communities to join. Use the topic filter to narrow things down.'))
flash(_('Great, you are now a registered user!'))
resp = make_response(redirect(url_for('main.list_communities')))
resp = make_response(redirect(url_for('topic.choose_topics')))
if user_ip_banned():
resp.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30))
return resp
@ -176,7 +179,10 @@ def verify_email(token):
flash(_('Thank you for verifying your email address.'))
else:
flash(_('Email address validation failed.'), 'error')
return redirect(url_for('main.index'))
if len(user.communities()) == 0:
return redirect(url_for('topic.choose_topics'))
else:
return redirect(url_for('main.index'))
@bp.route('/validation_required')

View file

@ -315,7 +315,7 @@ def unsubscribe(actor):
activity.result = 'success'
db.session.commit()
if not success:
flash('There was a problem while trying to join', 'error')
flash('There was a problem while trying to unsubscribe', 'error')
if proceed:
db.session.query(CommunityMember).filter_by(user_id=current_user.id, community_id=community.id).delete()

View file

@ -121,6 +121,7 @@ class File(db.Model):
class Topic(db.Model):
id = db.Column(db.Integer, primary_key=True)
machine_name = db.Column(db.String(50), index=True)
name = db.Column(db.String(50))
num_communities = db.Column(db.Integer, default=0)
communities = db.relationship('Community', lazy='dynamic', backref='topic', cascade="all, delete-orphan")
@ -540,7 +541,7 @@ class User(UserMixin, db.Model):
def communities(self) -> List[Community]:
return Community.query.filter(Community.banned == False).\
join(CommunityMember).filter(CommunityMember.is_banned == False).all()
join(CommunityMember).filter(CommunityMember.is_banned == False, CommunityMember.user_id == self.id).all()
def profile_id(self):
return self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/u/{self.user_name}"

View file

@ -478,6 +478,16 @@ fieldset legend {
content: ">";
}
}
.communities_table tbody tr th {
padding: 0;
}
.communities_table tbody tr th a {
padding-top: 10px;
padding-bottom: 10px;
width: 100%;
display: inline-block;
}
.community_header {
background-repeat: no-repeat;
background-position: center center;
@ -528,6 +538,25 @@ fieldset legend {
height: auto;
}
#choose_topics_card label.form-control-label {
display: none;
}
#choose_topics_card .form-group {
margin-bottom: 0;
}
#choose_topics_card ul.form-control {
border: none;
list-style-type: none;
padding-top: 0;
margin-bottom: 0;
}
#choose_topics_card ul.form-control li {
vertical-align: center;
}
#choose_topics_card ul.form-control li label {
height: 44px;
}
.form-check .form-check-input {
position: relative;
top: 4px;

View file

@ -82,6 +82,18 @@ nav, etc which are used site-wide */
}
}
.communities_table {
tbody tr th {
padding: 0;
a {
padding-top: 10px;
padding-bottom: 10px;
width: 100%;
display: inline-block;
}
}
}
.community_header {
background-repeat: no-repeat;
background-position: center center;
@ -133,6 +145,27 @@ nav, etc which are used site-wide */
}
}
#choose_topics_card {
label.form-control-label {
display: none;
}
.form-group {
margin-bottom: 0;
}
ul.form-control {
border: none;
list-style-type: none;
padding-top: 0;
margin-bottom: 0;
li {
vertical-align: center;
label {
height: 44px;
}
}
}
}
.form-check .form-check-input {
position: relative;
top: 4px;

View file

@ -122,10 +122,10 @@
<li><a class="dropdown-item{% if active_child == 'all_posts' %} active{% endif %}" href="/all"><span class="fe fe-all"></span>{{ _('All posts') }}</a></li>
</ul>
<li class="nav-item dropdown{% if active_parent == 'communities' %} active{% endif %}">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="/communities" aria-haspopup="true" aria-expanded="false">{{ _('Communities') }}</a>
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="/topics" aria-haspopup="true" aria-expanded="false">{{ _('Topics') }}</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item{% if active_child == 'list_communities' %} active{% endif %}" href="/communities">{{ _('Browse all') }}</a></li>
<li><a class="dropdown-item{% if active_child == 'list_topics' %} active{% endif %}" href="/topics">{{ _('By topic') }}</a></li>
<li><a class="dropdown-item{% if active_child == 'list_communities' %} active{% endif %}" href="/topics">{{ _('Browse by topic') }}</a></li>
<li><a class="dropdown-item{% if active_child == 'list_topics' %} active{% endif %}" href="/communities">{{ _('All communities') }}</a></li>
</ul>
</li>
<li class="nav-item"><a class="nav-link" href="/auth/login">{{ _('Log in') }}</a></li>
@ -140,10 +140,10 @@
<li><a class="dropdown-item{% if active_child == 'all_posts' %} active{% endif %}" href="/all"><span class="fe fe-all"></span>{{ _('All posts') }}</a></li>
</ul>
<li class="nav-item dropdown{% if active_parent == 'communities' %} active{% endif %}">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="/communities" aria-haspopup="true" aria-expanded="false">{{ _('Communities') }}</a>
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="/topics" aria-haspopup="true" aria-expanded="false">{{ _('Topics') }}</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item{% if active_child == 'list_communities' %} active{% endif %}" href="/communities">{{ _('Browse all') }}</a></li>
<li><a class="dropdown-item{% if active_child == 'list_topics' %} active{% endif %}" href="/topics">{{ _('By topic') }}</a></li>
<li><a class="dropdown-item{% if active_child == 'list_communities' %} active{% endif %}" href="/topics">{{ _('Browse by topic') }}</a></li>
<li><a class="dropdown-item{% if active_child == 'list_topics' %} active{% endif %}" href="/communities">{{ _('All communities') }}</a></li>
{% if moderating_communities %}
<li><h6 class="dropdown-header">{{ _('Moderating') }}</h6></li>
{% for community in moderating_communities %}

View file

@ -1,6 +1,8 @@
<div class="mobile_create_post d-md-none mt-1">
<a class="btn btn-primary" href="/community/{{ community.link() }}/submit">{{ _('Create post') }}</a>
</div>
{% if community %}
<div class="mobile_create_post d-md-none mt-1">
<a class="btn btn-primary" href="/community/{{ community.link() }}/submit">{{ _('Create post') }}</a>
</div>
{% endif %}
<div class="btn-group mt-1 mb-2">
<a href="?sort=hot&layout={{ post_layout }}" class="btn {{ 'btn-primary' if sort == '' or sort == 'hot' else 'btn-outline-secondary' }}" rel="nofollow">
{{ _('Hot') }}

View file

@ -9,9 +9,9 @@
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/communities">{{ _('Communities') }}</a></li>
{% if community.topic_id %}
<li class="breadcrumb-item"><a href="/communities?topic_id={{ community.topic.id }}" rel="nofollow">{{ community.topic.name }}</a></li>
<li class="breadcrumb-item"><a href="/topics">{{ _('Topics') }}</a></li>
<li class="breadcrumb-item"><a href="/topic/{{ community.topic.machine_name }}" rel="nofollow">{{ community.topic.name }}</a></li>
{% endif %}
<li class="breadcrumb-item active">{{ community.title|shorten }}</li>
</ol>
@ -28,9 +28,9 @@
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/communities">{{ _('Communities') }}</a></li>
{% if community.topic_id %}
<li class="breadcrumb-item"><a href="/communities?topic_id={{ community.topic.id }}" rel="nofollow">{{ community.topic.name }}</a></li>
<li class="breadcrumb-item"><a href="/topics">{{ _('Topics') }}</a></li>
<li class="breadcrumb-item"><a href="/topic/{{ community.topic.machine_name }}" rel="nofollow">{{ community.topic.name }}</a></li>
{% endif %}
<li class="breadcrumb-item active">{{ community.title|shorten }}</li>
</ol>
@ -50,9 +50,9 @@
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/communities">{{ _('Communities') }}</a></li>
{% if community.topic_id %}
<li class="breadcrumb-item"><a href="/communities?topic_id={{ community.topic.id }}" rel="nofollow">{{ community.topic.name }}</a></li>
<li class="breadcrumb-item"><a href="/topics">{{ _('Topics') }}</a></li>
<li class="breadcrumb-item"><a href="/topic/{{ community.topic.machine_name }}" rel="nofollow">{{ community.topic.name }}</a></li>
{% endif %}
<li class="breadcrumb-item active">{{ community.title|shorten }}</li>
</ol>

View file

@ -8,8 +8,8 @@
<table class="communities_table table table-striped table-hover w-100">
<tbody>
{% for topic in topics %}
<tr class="">
<td><a href="/communities?topic_id={{ topic.id }}">{{ topic.name }}</a></td>
<tr>
<th class="pl-2"><a href="/topic/{{ topic.machine_name }}">{{ topic.name }}</a></th>
</tr>
{% endfor %}
</tbody>
@ -18,4 +18,5 @@
{% else %}
<p>{{ _('There are no communities yet.') }}</p>
{% endif %}
<p><a href="/communities" class="btn btn-primary">{{ _('Explore communities') }}</a></p>
{% endblock %}

View file

@ -4,9 +4,9 @@
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/communities">{{ _('Communities') }}</a></li>
{% if post.community.topic_id %}
<li class="breadcrumb-item"><a href="/communities?topic_id={{ post.community.topic.id }}" rel="nofollow">{{ post.community.topic.name }}</a></li>
{% if community.topic_id %}
<li class="breadcrumb-item"><a href="/topics">{{ _('Topics') }}</a></li>
<li class="breadcrumb-item"><a href="/topic/{{ community.topic.machine_name }}" rel="nofollow">{{ community.topic.name }}</a></li>
{% endif %}
<li class="breadcrumb-item"><a href="/c/{{ post.community.link() }}">{{ post.community.title }}</a></li>
<li class="breadcrumb-item active">{{ post.title|shorten(15) }}</li>
@ -51,9 +51,9 @@
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/communities">{{ _('Communities') }}</a></li>
{% if post.community.topic_id %}
<li class="breadcrumb-item"><a href="/communities?topic_id={{ post.community.topic.id }}" rel="nofollow">{{ post.community.topic.name }}</a></li>
{% if community.topic_id %}
<li class="breadcrumb-item"><a href="/topics">{{ _('Topics') }}</a></li>
<li class="breadcrumb-item"><a href="/topic/{{ community.topic.machine_name }}" rel="nofollow">{{ community.topic.name }}</a></li>
{% endif %}
<li class="breadcrumb-item"><a href="/c/{{ post.community.link() }}">{{ post.community.title }}</a></li>
<li class="breadcrumb-item active">{{ post.title|shorten(15) }}</li>

View file

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6" id="choose_topics_card">
<div class="card-title text-center">{{ _('Please choose at least 3 topics that interest you.') }}</div>
{{ render_form(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/topics">{{ _('Topics') }}</a></li>
<li class="breadcrumb-item active">{{ topic.name|shorten }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ topic.name }}
</h1>
{% include "community/_community_nav.html" %}
{% if post_layout == 'masonry' or post_layout == 'masonry_wide' %}
<div class="post_list_{{ post_layout }}">
{% for post in posts %}
{% include 'post/_post_teaser_masonry.html' %}
{% else %}
<p>{{ _('No posts in this topic yet.') }}</p>
{% endfor %}
</div>
{% else %}
<div class="post_list">
{% for post in posts %}
{% include 'post/_post_teaser.html' %}
{% else %}
<p>{{ _('No posts in this topic yet.') }}</p>
{% endfor %}
</div>
{% endif %}
<nav aria-label="Pagination" class="mt-4" role="navigation">
{% 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 side_pane" role="complementary">
{% if topic_communities %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Topic communities') }}</h2>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
{% for community in topic_communities %}
<li class="list-group-item">
<a href="/c/{{ community.link() }}" aria-label="{{ _('Go to community') }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" alt="" />
{{ community.display_name() }}
</a>
</li>
{% endfor %}
</ul>
<p class="mt-4"><a class="btn btn-primary" href="/communities">{{ _('Explore communities') }}</a></p>
</div>
</div>
{% endif %}
{% include "_inoculation_links.html" %}
</div>
</div>
<div class="row">
</div>
{% endblock %}

5
app/topic/__init__.py Normal file
View file

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('topic', __name__)
from app.topic import routes

14
app/topic/forms.py Normal file
View file

@ -0,0 +1,14 @@
from flask import request
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
from flask_babel import _, lazy_gettext as _l
from app.utils import MultiCheckboxField
from app import db
class ChooseTopicsForm(FlaskForm):
chosen_topics = MultiCheckboxField(_l('Choose some topics you are interested in'), coerce=int)
submit = SubmitField(_l('Choose'))

131
app/topic/routes.py Normal file
View file

@ -0,0 +1,131 @@
from datetime import timedelta
from random import randint
from flask import request, flash, json, url_for, current_app, redirect, abort
from flask_login import login_required, current_user
from flask_babel import _
from sqlalchemy import text, desc
from app.activitypub.signature import post_request
from app.constants import SUBSCRIPTION_NONMEMBER
from app.inoculation import inoculation
from app.models import Topic, Community, Post, utcnow, CommunityMember, CommunityJoinRequest
from app.topic import bp
from app import db, celery, cache
from app.topic.forms import ChooseTopicsForm
from app.utils import render_template, user_filters_posts, moderating_communities, joined_communities, \
community_membership
@bp.route('/topic/<topic_name>', methods=['GET'])
def show_topic(topic_name):
page = request.args.get('page', 1, type=int)
sort = request.args.get('sort', '' if current_user.is_anonymous else current_user.default_sort)
low_bandwidth = request.cookies.get('low_bandwidth', '0') == '1'
post_layout = request.args.get('layout', 'list' if not low_bandwidth else None)
# translate topic_name from /topic/fediverse to topic_id
topic = Topic.query.filter(Topic.machine_name == topic_name.strip().lower()).first()
if topic:
# get posts from communities in that topic
posts = Post.query.join(Community, Post.community_id == Community.id).filter(Community.topic_id == topic.id, Community.banned == False)
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))
elif sort == 'active':
posts = posts.order_by(desc(Post.last_active))
if current_user.is_anonymous or current_user.ignore_bots:
posts = posts.filter(Post.from_bot == False)
content_filters = {}
else:
content_filters = user_filters_posts(current_user.id)
per_page = 100
if post_layout == 'masonry':
per_page = 200
elif post_layout == 'masonry_wide':
per_page = 300
posts = posts.paginate(page=page, per_page=per_page, error_out=False)
topic_communities = Community.query.filter(Community.topic_id == topic.id).order_by(Community.name)
next_url = url_for('topic.show_topic',
topic_name=topic_name,
page=posts.next_num, sort=sort, layout=post_layout) if posts.has_next else None
prev_url = url_for('topic.show_topic',
topic_name=topic_name,
page=posts.prev_num, sort=sort, layout=post_layout) if posts.has_prev and page != 1 else None
return render_template('topic/show_topic.html', title=_(topic.name), posts=posts, topic=topic, sort=sort,
page=page, post_layout=post_layout, next_url=next_url, prev_url=prev_url,
topic_communities=topic_communities, content_filters=content_filters,
show_post_community=True, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)])
else:
abort(404)
@bp.route('/choose_topics', methods=['GET', 'POST'])
@login_required
def choose_topics():
form = ChooseTopicsForm()
form.chosen_topics.choices = topics_for_form()
if form.validate_on_submit():
if form.chosen_topics.data:
for topic_id in form.chosen_topics.data:
join_topic(topic_id)
flash(_('You have joined some communities relating to those interests. Find them on the Topics menu or browse the home page.'))
cache.delete_memoized(joined_communities, current_user.id)
return redirect(url_for('main.index'))
else:
flash(_('You did not choose any topics. Would you like to choose individual communities instead?'))
return redirect(url_for('main.list_communities'))
else:
return render_template('topic/choose_topics.html', form=form)
def topics_for_form():
topics = Topic.query.order_by(Topic.name).all()
result = []
for topic in topics:
result.append((topic.id, topic.name))
return result
def join_topic(topic_id):
communities = Community.query.filter_by(topic_id=topic_id, banned=False).all()
for community in communities:
if not community.user_is_banned(current_user) and community_membership(current_user, community) == SUBSCRIPTION_NONMEMBER:
if not community.is_local():
join_request = CommunityJoinRequest(user_id=current_user.id, community_id=community.id)
db.session.add(join_request)
db.session.commit()
if current_app.debug:
send_community_follow(community.id, join_request)
else:
send_community_follow.delay(community.id, join_request.id)
member = CommunityMember(user_id=current_user.id, community_id=community.id)
db.session.add(member)
db.session.commit()
cache.delete_memoized(community_membership, current_user, community)
@celery.task
def send_community_follow(community_id, join_request_id):
with current_app.app_context():
community = Community.query.get(community_id)
follow = {
"actor": f"https://{current_app.config['SERVER_NAME']}/u/{current_user.user_name}",
"to": [community.ap_profile_id],
"object": community.ap_profile_id,
"type": "Follow",
"id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{join_request_id}"
}
success = post_request(community.ap_inbox_url, follow, current_user.private_key,
current_user.profile_id() + '#main-key')

View file

@ -30,3 +30,5 @@ class Config(object):
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL') or 'redis://localhost:6379/0'
RESULT_BACKEND = os.environ.get('RESULT_BACKEND') or 'redis://localhost:6379/0'
SQLALCHEMY_ECHO = False # set to true to see SQL in console
WTF_CSRF_TIME_LIMIT = None # a value of None ensures csrf token is valid for the lifetime of the session

View file

@ -0,0 +1,34 @@
"""topic machine name
Revision ID: 52e8d73b69ba
Revises: 86b6fd708bd0
Create Date: 2024-01-27 20:40:15.535403
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '52e8d73b69ba'
down_revision = '86b6fd708bd0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('topic', schema=None) as batch_op:
batch_op.add_column(sa.Column('machine_name', sa.String(length=50), nullable=True))
batch_op.create_index(batch_op.f('ix_topic_machine_name'), ['machine_name'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('topic', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_topic_machine_name'))
batch_op.drop_column('machine_name')
# ### end Alembic commands ###