UI to create posts

This commit is contained in:
rimu 2023-09-17 21:19:51 +12:00
parent 4888e2e2e2
commit 8c3c46271d
20 changed files with 376 additions and 35 deletions

View file

@ -3,4 +3,8 @@
Please discuss your ideas in an issue at https://codeberg.org/rimu/pyfedi/issues before
starting any large pieces of work to ensure alignment with the roadmap, architecture and processes.
The general style and philosphy behind the way things have been constructed is well described by
[The Grug Brained Developer](https://grugbrain.dev/). If that page resonates with you then you'll
probably enjoy your time here!
Mailing list, Matrix channel, etc still to come.

View file

@ -27,7 +27,7 @@ mail = Mail()
bootstrap = Bootstrap5()
moment = Moment()
babel = Babel()
cache = Cache(config={'CACHE_TYPE': os.environ.get('CACHE_TYPE'), 'CACHE_DIR': os.environ.get('CACHE_DIR') or '/dev/shm'})
cache = Cache()
def create_app(config_class=Config):

View file

@ -338,6 +338,7 @@ def shared_inbox():
activity_log.exception_message = 'Could not detect type of like'
if activity_log.result == 'success':
... # todo: recalculate 'hotness' of liked post/reply
# todo: if vote was on content in local community, federate the vote out to followers
# Follow: remote user wants to follow one of our communities
elif request_json['type'] == 'Follow': # Follow is when someone wants to join a community

View file

@ -176,7 +176,7 @@ def banned_user_agents():
return [] # todo: finish this function
@cache.cached(150)
@cache.memoize(150)
def instance_blocked(host: str) -> bool:
host = host.lower()
if 'https://' in host or 'http://' in host:

View file

@ -49,7 +49,13 @@ def register(app):
db.configure_mappers()
db.create_all()
db.session.append(Settings(name='allow_nsfw', value=json.dumps(False)))
db.session.append(Settings(name='allow_nsfl', value=json.dumps(False)))
db.session.append(Settings(name='allow_dislike', value=json.dumps(True)))
db.session.append(Settings(name='allow_local_image_posts', value=json.dumps(True)))
db.session.append(Settings(name='allow_remote_image_posts', value=json.dumps(True)))
db.session.append(Settings(name='registration_open', value=json.dumps(True)))
db.session.append(Settings(name='approve_registrations', value=json.dumps(False)))
db.session.append(Settings(name='federation', value=json.dumps(True)))
db.session.append(BannedInstances(domain='lemmygrad.ml'))
db.session.append(BannedInstances(domain='gab.com'))
db.session.append(BannedInstances(domain='exploding-heads.com'))

View file

@ -1,8 +1,10 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField, BooleanField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length
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 domain_from_url
class AddLocalCommunity(FlaskForm):
community_name = StringField(_l('Name'), validators=[DataRequired()])
@ -15,4 +17,54 @@ class AddLocalCommunity(FlaskForm):
class SearchRemoteCommunity(FlaskForm):
address = StringField(_l('Server address'), validators=[DataRequired()])
submit = SubmitField(_l('Search'))
submit = SubmitField(_l('Search'))
class CreatePost(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
type = HiddenField() # https://getbootstrap.com/docs/4.6/components/navs/#tabs
discussion_title = StringField(_l('Title'), validators={Optional(), Length(min=3, max=255)})
discussion_body = TextAreaField(_l('Body'), render_kw={'placeholder': 'Text (optional)'})
link_title = StringField(_l('Title'), validators={Optional(), Length(min=3, max=255)})
link_url = StringField(_l('URL'), render_kw={'placeholder': 'https://...'})
image_title = StringField(_l('Title'), validators={Optional(), Length(min=3, max=255)})
image_file = FileField(_('Image'))
# flair = SelectField(_l('Flair'), coerce=int)
nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('NSFL'))
notify = BooleanField(_l('Send me post reply notifications'))
submit = SubmitField(_l('Post'))
def validate(self, extra_validators=None) -> bool:
if not super().validate():
return False
if self.type.data is None or self.type.data == '':
self.type.data = 'discussion'
if self.type.data == 'discussion':
if self.discussion_title.data == '':
self.discussion_title.errors.append(_('Title is required.'))
return False
elif self.type.data == 'link':
if self.link_title.data == '':
self.link_title.errors.append(_('Title is required.'))
return False
if self.link_url.data == '':
self.link_url.errors.append(_('URL is required.'))
return False
domain = domain_from_url(self.link_url.data)
if domain.banned:
self.link_url.errors.append(_(f"Links to %s are not allowed.".format(domain.name)))
return False
elif self.type.data == 'image':
if self.image_title.data == '':
self.image_title.errors.append(_('Title is required.'))
return False
if self.image_file.data == '':
self.image_file.errors.append(_('File is required.'))
return False
elif self.type.data == 'poll':
self.discussion_title.errors.append(_('Poll not implemented yet.'))
return False
return True

View file

@ -1,16 +1,19 @@
from datetime import date, datetime, timedelta
import markdown2
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
from flask_login import login_user, logout_user, current_user
from flask_babel import _
from sqlalchemy import or_
from app import db
from app.activitypub.signature import RsaKeys, HttpSignature
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity
from app.community.util import search_for_community, community_url_exists
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost
from app.community.util import search_for_community, community_url_exists, actor_to_community
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post
from app.community import bp
from app.utils import get_setting, render_template
from sqlalchemy import or_
from app.utils import get_setting, render_template, allowlist_html
@bp.route('/add_local', methods=['GET', 'POST'])
@ -21,7 +24,7 @@ def add_local():
if form.validate_on_submit() and not community_url_exists(form.url.data):
# todo: more intense data validation
if form.url.data.trim().lower().startswith('/c/'):
if form.url.data.strip().lower().startswith('/c/'):
form.url.data = form.url.data[3:]
private_key, public_key = RsaKeys.generate_keypair()
community = Community(title=form.community_name.data, name=form.url.data, description=form.description.data,
@ -65,12 +68,7 @@ def add_remote():
# @bp.route('/c/<actor>', methods=['GET']) - defined in activitypub/routes.py, which calls this function for user requests. A bit weird.
def show_community(community: Community):
mods = CommunityMember.query.filter((CommunityMember.community_id == community.id) &
(or_(
CommunityMember.is_owner,
CommunityMember.is_moderator
))
).all()
mods = community.moderators()
is_moderator = any(mod.user_id == current_user.id for mod in mods)
is_owner = any(mod.user_id == current_user.id and mod.is_owner == True for mod in mods)
@ -107,7 +105,7 @@ def subscribe(actor):
"to": [community.ap_id],
"object": community.ap_id,
"type": "Follow",
"id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/" + join_request.id
"id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{join_request.id}"
}
try:
message = HttpSignature.signed_request(community.ap_inbox_url, follow, current_user.private_key,
@ -140,11 +138,7 @@ def subscribe(actor):
@bp.route('/<actor>/unsubscribe', methods=['GET'])
def unsubscribe(actor):
actor = actor.strip()
if '@' in actor:
community = Community.query.filter_by(banned=False, ap_id=actor).first()
else:
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
community = actor_to_community(actor)
if community is not None:
subscription = current_user.subscribed(community)
@ -165,3 +159,48 @@ def unsubscribe(actor):
return redirect('/c/' + actor)
else:
abort(404)
@bp.route('/<actor>/submit', methods=['GET', 'POST'])
def add_post(actor):
community = actor_to_community(actor)
form = CreatePost()
if get_setting('allow_nsfw', False) is False:
form.nsfw.render_kw = {'disabled': True}
if get_setting('allow_nsfl', False) is False:
form.nsfl.render_kw = {'disabled': True}
images_disabled = 'disabled' if not get_setting('allow_local_image_posts', True) else ''
form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()]
if form.validate_on_submit():
post = Post(user_id=current_user.id, community_id=form.communities.data, nsfw=form.nsfw.data,
nsfl=form.nsfl.data)
if form.type.data == '' or form.type.data == 'discussion':
post.title = form.discussion_title.data
post.body = form.discussion_body.data
post.body_html = allowlist_html(markdown2.markdown(post.body, safe_mode=True))
post.type = POST_TYPE_ARTICLE
elif form.type.data == 'link':
post.title = form.link_title.data
post.url = form.link_url.data
post.type = POST_TYPE_LINK
elif form.type.data == 'image':
post.title = form.image_title.data
post.type = POST_TYPE_IMAGE
# todo: handle file upload
elif form.type.data == 'poll':
...
else:
raise Exception('invalid post type')
db.session.add(post)
db.session.commit()
flash('Post has been added')
return redirect(f"/c/{community.link()}")
else:
form.communities.data = community.id
form.notify.data = True
return render_template('community/add_post.html', title=_('Add post to community'), form=form, community=community,
images_disabled=images_disabled)

View file

@ -67,5 +67,14 @@ def search_for_community(address: str):
def community_url_exists(url) -> bool:
community = Community.query.filter_by(url=url).first()
community = Community.query.filter_by(ap_profile_id=url).first()
return community is not None
def actor_to_community(actor) -> Community:
actor = actor.strip()
if '@' in actor:
community = Community.query.filter_by(banned=False, ap_id=actor).first()
else:
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
return community

View file

@ -4,6 +4,7 @@ POST_TYPE_LINK = 1
POST_TYPE_ARTICLE = 2
POST_TYPE_IMAGE = 3
POST_TYPE_VIDEO = 4
POST_TYPE_POLL = 5
DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"

View file

@ -8,7 +8,7 @@ from flask_login import current_user
from flask_babel import _, get_locale
from sqlalchemy import select
from sqlalchemy_searchable import search
from app.utils import render_template
from app.utils import render_template, get_setting
from app.models import Community, CommunityMember

View file

@ -1,8 +1,11 @@
from datetime import datetime, timedelta, date
from hashlib import md5
from time import time
from typing import List
from flask import current_app, escape
from flask_login import UserMixin
from sqlalchemy import or_
from werkzeug.security import generate_password_hash, check_password_hash
from flask_babel import _, lazy_gettext as _l
from sqlalchemy.orm import backref
@ -73,6 +76,7 @@ class Community(db.Model):
return self.icon.source_url
return ''
def header_image(self) -> str:
if self.image_id is not None:
if self.image.file_path is not None:
@ -93,6 +97,14 @@ class Community(db.Model):
else:
return self.ap_id
def moderators(self):
return CommunityMember.query.filter((CommunityMember.community_id == self.id) &
(or_(
CommunityMember.is_owner,
CommunityMember.is_moderator
))
).all()
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
@ -201,6 +213,10 @@ class User(UserMixin, db.Model):
else:
return SUBSCRIPTION_NONMEMBER
def communities(self) -> List[Community]:
return Community.query.filter(Community.banned == False).\
join(CommunityMember).filter(CommunityMember.is_banned == False).all()
@staticmethod
def verify_reset_password_token(token):
try:
@ -320,6 +336,7 @@ class CommunityMember(db.Model):
community_id = db.Column(db.Integer, db.ForeignKey('community.id'), primary_key=True)
is_moderator = db.Column(db.Boolean, default=False)
is_owner = db.Column(db.Boolean, default=False)
is_banned = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)

50
app/sorting.py Normal file
View file

@ -0,0 +1,50 @@
# from https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9
from math import sqrt, log
from datetime import datetime, timedelta
epoch = datetime(1970, 1, 1)
def epoch_seconds(date):
td = date - epoch
return td.days * 86400 + td.seconds + (float(td.microseconds) / 1000000)
def score(ups, downs):
return ups - downs
# used for ranking stories
def hot(ups, downs, date):
s = score(ups, downs)
order = log(max(abs(s), 1), 10)
sign = 1 if s > 0 else -1 if s < 0 else 0
seconds = epoch_seconds(date) - 1134028003 # this value seems to be an arbitrary time in 2005.
return round(sign * order + seconds / 45000, 7)
# used for ranking comments
def _confidence(ups, downs):
n = ups + downs
if n == 0:
return 0
z = 1.281551565545
p = float(ups) / n
left = p + 1 / (2 * n) * z * z
right = z * sqrt(p * (1 - p) / n + z * z / (4 * n * n))
under = 1 + 1 / n * z * z
return (left - right) / under
def confidence(ups, downs):
if ups + downs == 0:
return 0
else:
return _confidence(ups, downs)

View file

@ -6,7 +6,7 @@ document.addEventListener("DOMContentLoaded", function () {
// fires after all resources have loaded, including stylesheets and js files
window.addEventListener("load", function () {
setupPostTypeTabs(); // when choosing the type of your new post, store the chosen tab in a hidden field so the backend knows which fields to check
});
@ -21,6 +21,34 @@ function setupCommunityNameInput() {
}
}
function setupPostTypeTabs() {
const tabEl = document.querySelector('#discussion-tab')
if(tabEl) {
tabEl.addEventListener('show.bs.tab', event => {
document.getElementById('type').value = 'discussion';
});
}
const tabE2 = document.querySelector('#link-tab')
if(tabE2) {
tabE2.addEventListener('show.bs.tab', event => {
document.getElementById('type').value = 'link';
});
}
const tabE3 = document.querySelector('#image-tab')
if(tabE3) {
tabE3.addEventListener('show.bs.tab', event => {
document.getElementById('type').value = 'image';
});
}
const tabE4 = document.querySelector('#poll-tab')
if(tabE4) {
tabE4.addEventListener('show.bs.tab', event => {
document.getElementById('type').value = 'poll';
});
}
}
function titleToURL(title) {
// Convert the title to lowercase and replace spaces with hyphens

View file

@ -239,6 +239,14 @@ nav.navbar {
opacity: 0.9;
}
.tab-content > .tab-pane {
border-right: solid 1px #dee2e6;
border-bottom: solid 1px #dee2e6;
border-left: solid 1px #dee2e6;
border-radius: 0 0 5px 5px;
padding: 10px 15px 0 15px;
}
.dropdown-menu .dropdown-item.active {
background-color: #0071CE;
}

View file

@ -35,6 +35,14 @@ nav.navbar {
opacity: 0.9;
}
.tab-content > .tab-pane {
border-right: solid 1px #dee2e6;
border-bottom: solid 1px #dee2e6;
border-left: solid 1px #dee2e6;
border-radius: 0 0 5px 5px;
padding: 10px 15px 0 15px;
}
.dropdown-menu {
.dropdown-item.active {
background-color: $primary-colour;

View file

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form, render_field %}
{% block app_content %}
<div class="row">
<div class="col-8 position-relative">
<h1>{{ _('Create post') }}</h1>
<form method="post">
{{ form.csrf_token() }}
{{ render_field(form.communities) }}
<nav id="post_type_chooser">
<div class="nav nav-tabs nav-justified" id="typeTab" role="tablist">
<button class="nav-link active" id="discussion-tab" data-bs-toggle="tab" data-bs-target="#discussion-tab-pane"
type="button" role="tab" aria-controls="discussion-tab-pane" aria-selected="true">Discussion</button>
<button class="nav-link" id="link-tab" data-bs-toggle="tab" data-bs-target="#link-tab-pane"
type="button" role="tab" aria-controls="link-tab-pane" aria-selected="false">Link</button>
<button class="nav-link" id="image-tab" data-bs-toggle="tab" data-bs-target="#image-tab-pane"
type="button" role="tab" aria-controls="image-tab-pane" aria-selected="false" {{ images_disabled }}>Image</button>
<button class="nav-link" id="poll-tab" data-bs-toggle="tab" data-bs-target="#poll-tab-pane"
type="button" role="tab" aria-controls="poll-tab-pane" aria-selected="false" disabled>Poll</button>
</div>
</nav>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="discussion-tab-pane" role="tabpanel" aria-labelledby="home-tab" tabindex="0">
{{ render_field(form.discussion_title) }}
{{ render_field(form.discussion_body) }}
</div>
<div class="tab-pane fade" id="link-tab-pane" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">
{{ render_field(form.link_title) }}
{{ render_field(form.link_url) }}
</div>
<div class="tab-pane fade" id="image-tab-pane" role="tabpanel" aria-labelledby="contact-tab" tabindex="0">
{{ render_field(form.image_title) }}
{{ render_field(form.image_file) }}
</div>
<div class="tab-pane fade" id="poll-tab-pane" role="tabpanel" aria-labelledby="disabled-tab" tabindex="0">
Poll
</div>
</div>
{{ render_field(form.type) }}
<div class="row mt-4">
<div class="col-md-1">
{{ render_field(form.nsfw) }}
</div>
<div class="col-md-1">
{{ render_field(form.nsfl) }}
</div>
<div class="col">
</div>
</div>
{{ render_field(form.notify) }}
{{ render_field(form.submit) }}
</form>
</div>
<div class="col-4">
<div class="card mt-3">
<div class="card-header">
<h2>{{ community.title }}</h2>
</div>
<div class="card-body">
<p>{{ community.description|safe }}</p>
<p>{{ community.rules|safe }}</p>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -8,7 +8,7 @@
<div class="community_header" style="height: 240px; background-image: url({{ community.header_image() }});"></div>
<img class="community_icon_big bump_up rounded-circle" src="{{ community.icon_image() }}" />
<h1 class="mt-2">{{ community.title }}</h1>
{% else %}
{% elif community.icon_image() != '' %}
<div class="row">
<div class="col-2">
<img class="community_icon_big rounded-circle" src="{{ community.icon_image() }}" />
@ -17,6 +17,8 @@
<h1 class="mt-3">{{ community.title }}</h1>
</div>
</div>
{% else %}
<h1 class="mt-3">{{ community.title }}</h1>
{% endif %}
</div>
@ -33,7 +35,7 @@
{% endif %}
</div>
<div class="col-6">
<a class="w-100 btn btn-primary" href="#">{{ _('Create a post') }}</a>
<a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/submit">{{ _('Create a post') }}</a>
</div>
</div>
<form method="get">
@ -46,8 +48,8 @@
<h2>{{ _('About community') }}</h2>
</div>
<div class="card-body">
<p>{{ community.description }}</p>
<p>{{ community.rules }}</p>
<p>{{ community.description|safe }}</p>
<p>{{ community.rules|safe }}</p>
{% if len(mods) > 0 and not community.private_mods %}
<h3>Moderators</h3>
<ol>

View file

@ -1,4 +1,3 @@
import functools
import random
from urllib.parse import urlparse
@ -42,7 +41,7 @@ def get_request(uri, params=None, headers=None) -> requests.Response:
# saves an arbitrary object into a persistent key-value store. cached.
@cache.cached(timeout=50)
@cache.memoize(timeout=50)
def get_setting(name: str, default=None):
setting = Settings.query.filter_by(name=name).first()
if setting is None:
@ -55,7 +54,7 @@ def get_setting(name: str, default=None):
def set_setting(name: str, value):
setting = Settings.query.filter_by(name=name).first()
if setting is None:
db.session.append(Settings(name=name, value=json.dumps(value)))
db.session.add(Settings(name=name, value=json.dumps(value)))
else:
setting.value = json.dumps(value)
db.session.commit()

View file

@ -0,0 +1,48 @@
"""community ban field
Revision ID: f8200275644a
Revises: 6b84580a94cd
Create Date: 2023-09-16 19:21:13.085722
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f8200275644a'
down_revision = '6b84580a94cd'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('community_member', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_banned', sa.Boolean(), nullable=True))
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.add_column(sa.Column('comments_enabled', sa.Boolean(), nullable=True))
with op.batch_alter_table('post_reply', schema=None) as batch_op:
batch_op.add_column(sa.Column('body_html_safe', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('ap_create_id', sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column('ap_announce_id', sa.String(length=100), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('post_reply', schema=None) as batch_op:
batch_op.drop_column('ap_announce_id')
batch_op.drop_column('ap_create_id')
batch_op.drop_column('body_html_safe')
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.drop_column('comments_enabled')
with op.batch_alter_table('community_member', schema=None) as batch_op:
batch_op.drop_column('is_banned')
# ### end Alembic commands ###

View file

@ -20,7 +20,7 @@ def app_context_processor(): # NB there needs to be an identical function in cb
@app.shell_context_processor
def make_shell_context():
return {'db': db}
return {'db': db, 'app': app}
with app.app_context():