view community, view posts in community

This commit is contained in:
rimu 2023-10-02 22:16:44 +13:00
parent c8f76c2d54
commit 2c0fc55e35
18 changed files with 365 additions and 28 deletions

View file

@ -5,7 +5,7 @@ starting any large pieces of work to ensure alignment with the roadmap, architec
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! Our code needs to be simple enough that new developers of all
probably enjoy your time here! The codebase needs to be simple enough that new developers of all
skill levels can easily understand what's going on and onboard quickly without a lot of upfront
time investment. Sometimes this will mean writing slightly more verbose/boring code or avoiding the
use of advanced design patterns.
@ -33,6 +33,8 @@ VS Code coders are encouraged to try the free community edition of PyCharm but i
Use PEP 8 conventions for line length, naming, indentation. Use descriptive commit messages.
Database model classes are singular. As in "Car", not "Cars".
### Directory structure
Where possible, the structure should match the URL structure of the site. e.g. "domain.com/admin"
@ -50,8 +52,6 @@ helpful functions that pertain to modules in that directory only.
Changes to this file are turned into changes in the DB by using '[migrations](https://www.onlinetutorialspoint.com/flask/flask-how-to-upgrade-or-downgrade-database-migrations.html)'.
- /community/* pertains to viewing, posting within and managing communities.
Python developers who are new to Flask will be able to quickly become productive with
# Code of conduct
## Our Pledge

View file

@ -271,6 +271,7 @@ def shared_inbox():
if post is not None:
db.session.add(post)
community.post_count += 1
db.session.commit()
else:
post_id, parent_comment_id, root_id = find_reply_parent(in_reply_to)
@ -294,6 +295,7 @@ def shared_inbox():
if post_reply is not None:
db.session.add(post_reply)
community.post_reply_count += 1
db.session.commit()
else:
activity_log.exception_message = 'Unacceptable type: ' + object_type
@ -398,6 +400,7 @@ def shared_inbox():
if join_request:
member = CommunityMember(user_id=user.id, community_id=community.id)
db.session.add(member)
community.subscriptions_count += 1
db.session.commit()
activity_log.result = 'success'
else:

View file

@ -68,3 +68,8 @@ class CreatePost(FlaskForm):
return False
return True
class NewReplyForm(FlaskForm):
body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?'})
submit = SubmitField(_l('Comment'))

View file

@ -2,13 +2,13 @@ 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_login import login_user, logout_user, current_user, login_required
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, CreatePost
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm
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
@ -70,8 +70,8 @@ def add_remote():
def show_community(community: Community):
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)
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
is_owner = current_user.is_authenticated and any(mod.user_id == current_user.id and mod.is_owner == True for mod in mods)
if community.private_mods:
mod_list = []
@ -80,10 +80,11 @@ def show_community(community: Community):
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
return render_template('community/community.html', community=community, title=community.title,
is_moderator=is_moderator, is_owner=is_owner, mods=mod_list)
is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=community.posts)
@bp.route('/<actor>/subscribe', methods=['GET'])
@login_required
def subscribe(actor):
remote = False
actor = actor.strip()
@ -137,6 +138,7 @@ def subscribe(actor):
@bp.route('/<actor>/unsubscribe', methods=['GET'])
@login_required
def unsubscribe(actor):
community = actor_to_community(actor)
@ -162,6 +164,7 @@ def unsubscribe(actor):
@bp.route('/<actor>/submit', methods=['GET', 'POST'])
@login_required
def add_post(actor):
community = actor_to_community(actor)
form = CreatePost()
@ -194,8 +197,11 @@ def add_post(actor):
else:
raise Exception('invalid post type')
db.session.add(post)
community.post_count += 1
db.session.commit()
# todo: federate post creation out to followers
flash('Post has been added')
return redirect(f"/c/{community.link()}")
else:
@ -204,3 +210,13 @@ def add_post(actor):
return render_template('community/add_post.html', title=_('Add post to community'), form=form, community=community,
images_disabled=images_disabled)
@bp.route('/post/<int:post_id>')
def show_post(post_id: int):
post = Post.query.get_or_404(post_id)
mods = post.community.moderators()
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
form = NewReplyForm()
return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator,
canonical=post.ap_id, form=form)

View file

@ -2,13 +2,13 @@ from datetime import datetime
from app import db
from app.main import bp
from flask import g, jsonify, flash, request
from flask import g, session, flash, request
from flask_moment import moment
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, get_setting
from app.utils import render_template, get_setting, gibberish
from app.models import Community, CommunityMember
@ -43,9 +43,3 @@ def list_local_communities():
def list_subscribed_communities():
communities = Community.query.join(CommunityMember).filter(CommunityMember.user_id == current_user.id).all()
return render_template('list_communities.html', communities=communities)
@bp.before_app_request
def before_request():
g.locale = str(get_locale())

View file

@ -255,8 +255,8 @@ class Post(db.Model):
nsfl = db.Column(db.Boolean, default=False)
sticky = db.Column(db.Boolean, default=False)
indexable = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow)
posted_at = db.Column(db.DateTime, index=True, default=datetime.utcnow)
created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) # this is when the content arrived here
posted_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) # this is when the original server created it
last_active = db.Column(db.DateTime, index=True, default=datetime.utcnow)
ip = db.Column(db.String(50))
up_votes = db.Column(db.Integer, default=0)
@ -435,6 +435,23 @@ class ActivityPubLog(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class Filter(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(50))
filter_posts = db.Column(db.Boolean, default=True)
filter_replies = db.Column(db.Boolean, default=False)
hide_type = db.Column(db.Integer, default=0) # 0 = hide with warning, 1 = hide completely
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
class FilterKeyword(db.Model):
id = db.Column(db.Integer, primary_key=True)
keyword = db.Column(db.String(100))
filter_id = db.Column(db.Integer, db.ForeignKey('filter.id'))
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
@login.user_loader
def load_user(id):
return User.query.get(int(id))

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="6.3531513mm" height="6.3394589mm" viewBox="0 0 6.3531516 6.3394588" version="1.1" id="svg8" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" sodipodi:docname="external_link_web.svg">
<defs id="defs2"/>
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="3.959798" inkscape:cx="33.546238" inkscape:cy="62.365132" inkscape:document-units="mm" inkscape:current-layer="layer1" inkscape:document-rotation="0" showgrid="false" inkscape:window-width="1872" inkscape:window-height="1039" inkscape:window-x="48" inkscape:window-y="0" inkscape:window-maximized="1" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0"/>
<metadata id="metadata5">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1">
<path style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.67451;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m 2.4720007,1.0436168 -1.65871069,0.0019 c -0.2368211,-0.01848 -0.4529512,-0.0047 -0.4359367,0.373061 l -0.039823,4.025901 c -0.00912,0.444233 0.1987852,0.590785 0.5416462,0.552243 l 3.81418619,0.0014 c 0.425508,0.02448 0.594604,-0.164973 0.588101,-0.500924 l -0.0065,-1.611746" id="path24" sodipodi:nodetypes="cccccccc"/>
<path style="fill:none;stroke:#000000;stroke-width:0.67451;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m 3.8509437,0.33937181 1.910781,-0.0021 c 0.171289,0.01557 0.273418,0.09464 0.252261,0.286884 l 0.0011,1.87940799" id="path851" sodipodi:nodetypes="cccc"/>
<path style="fill:none;stroke:#000000;stroke-width:0.67451;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m 2.3884557,3.9829408 3.471423,-3.51892699 v 0" id="path853"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -2,3 +2,6 @@ $primary-colour: #0071CE;
$primary-colour-hover: #0A5CA0;
$green: #00b550;
$green-hover: #83f188;
$light-grey: #ddd;
$grey: #bbb;
$dark-grey: #777;

View file

@ -277,4 +277,17 @@ fieldset legend {
height: auto;
}
.post_list .post_teaser {
border-bottom: solid 2px #ddd;
padding-top: 8px;
padding-bottom: 8px;
}
.post_list .post_teaser h3 {
font-size: 120%;
margin-top: 8px;
}
.post_list .post_teaser h3 a {
text-decoration: none;
}
/*# sourceMappingURL=structure.css.map */

View file

@ -64,3 +64,21 @@ nav, etc which are used site-wide */
height: auto;
}
}
.post_list {
.post_teaser {
h3 {
font-size: 120%;
margin-top: 8px;
a {
text-decoration: none;
}
}
border-bottom: solid 2px $light-grey;
padding-top: 8px;
padding-bottom: 8px;
}
}

View file

@ -288,4 +288,11 @@ nav.navbar {
left: 26px;
}
.external_link_icon {
width: 12px;
height: 12px;
margin-left: 4px;
margin-bottom: 3px;
}
/*# sourceMappingURL=styles.css.map */

View file

@ -83,4 +83,11 @@ nav.navbar {
top: 104px;
left: 26px;
}
}
.external_link_icon {
width: 12px;
height: 12px;
margin-left: 4px;
margin-bottom: 3px;
}

View file

@ -26,6 +26,9 @@
<meta name="msapplication-TileColor" content="#da532c">
<meta name="msapplication-config" content="/static/browserconfig.xml">
<meta name="theme-color" content="#ffffff">
{% if canonical %}
<link rel="canonical" href="{{ canonical }}" />
{% endif %}
{% endblock %}
</head>
<body class="d-flex flex-column" style="padding-top: 43px;">
@ -77,8 +80,8 @@
{% endblock %}
{% block scripts %}
{{ moment.include_moment() }}
{{ moment.lang(g.locale) }}
{{ str(moment.include_moment()).replace('<script>', '<script nonce="' + session['nonce'] + '">')|safe }}
{{ str(moment.lang(g.locale)).replace('<script>', '<script nonce="' + session['nonce'] + '">')|safe }}
{% endblock %}
{{ bootstrap.load_js() }}
<script type="text/javascript" src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>

View file

@ -20,7 +20,43 @@
{% else %}
<h1 class="mt-3">{{ community.title }}</h1>
{% endif %}
<div class="post_list">
{% for post in posts %}
<div class="post_teaser">
<div class="row">
<div class="col">{{ post.author.user_name }} · {{ moment(post.posted_at).fromNow() }}</div>
</div>
<div class="row">
<div class="col{% if post.image_id %}-8{% endif %}">
<h3>
<a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}</a>
{% if post.type == post_type_link %}
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">domain</a>)</span>
{% endif %}
</h3>
</div>
{% if post.image_id %}
<div class="col-4">
<img src="{{ post.image.source_url}}" alt="{{ post.image.alt_text }}"
width="100" />
</div>
{% endif %}
</div>
<div class="row">
<div class="col-4">
up {{ post.score }} down
</div>
<div class="col-6">
<a href="{{ url_for('community.show_post', post_id=post.id, _anchor='replies') }}">{{post.reply_count}}</a> additional tools
</div>
<div class="col-2">...</div>
</div>
</div>
{% else %}
<p>{{ _('No posts in this community yet.') }}</p>
{% endfor %}
</div>
</div>
<div class="col-4">
@ -28,7 +64,7 @@
<div class="card-body">
<div class="row">
<div class="col-6">
{% if current_user.subscribed(community) %}
{% if current_user.is_authenticated and current_user.subscribed(community) %}
<a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
{% else %}
<a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/subscribe">{{ _('Subscribe') }}</a>
@ -39,7 +75,7 @@
</div>
</div>
<form method="get">
<input type="search" name="search" class="form-control mt-2" placeholder="Search this community" />
<input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search this community') }}" />
</form>
</div>
</div>

View file

@ -0,0 +1,120 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col-8 position-relative">
<div class="row">
{% if post.image_id %}
<div class="col-8">
<h1 class="mt-3">{{ post.title }}</h1>
{% if post.url %}
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}</a>
{% if post.type == post_type_link %}
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">domain</a>)</span>
{% endif %}
</small></p>
{% endif %}
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ post.author.user_name }}</small></p>
</div>
<div class="col-4">
{% if post.url %}
<a href="post.url" rel="nofollow ugc"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}"
width="100" /></a>
{% else %}
<a href="post.image.source_url"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}"
width="100" /></a>
{% endif %}
</div>
{% else %}
<h1 class="mt-3">{{ post.title }}</h1>
{% if post.url %}
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" /></a>
{% if post.type == post_type_link %}
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">domain</a>)</span>
{% endif %}
</small></p>
{% endif %}
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ post.author.user_name }}</small></p>
{% endif %}
</div>
{% if post.body_html %}
<div class="row post_full">
<div class="col">
{{ post.body_html|safe }}
</div>
<hr />
</div>
{% endif %}
{% if post.comments_enabled %}
<div class="row post_reply_form">
<div class="col">
{{ render_form(form) }}
</div>
<hr class="mt-4" />
</div>
{% else %}
<p>{{ _('Comments are disabled for this post.') }}</p>
{% endif %}
<div class="row post_replies">
<div class="col">
</div>
</div>
</div>
<div class="col-4">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-6">
{% if current_user.is_authenticated and current_user.subscribed(post.community) %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
{% else %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe">{{ _('Subscribe') }}</a>
{% endif %}
</div>
<div class="col-6">
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/submit">{{ _('Create a post') }}</a>
</div>
</div>
<form method="get">
<input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search this community') }}" />
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('About community') }}</h2>
</div>
<div class="card-body">
<p>{{ post.community.description|safe }}</p>
<p>{{ post.community.rules|safe }}</p>
{% if len(mods) > 0 and not post.community.private_mods %}
<h3>Moderators</h3>
<ol>
{% for mod in mods %}
<li><a href="/u/{{ mod.user_name }}">{{ mod.user_name }}</a></li>
{% endfor %}
</ol>
{% endif %}
</div>
</div>
{% if is_moderator %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Community Settings') }}</h2>
</div>
<div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p>
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -143,3 +143,13 @@ def domain_from_url(url: str) -> Domain:
domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first()
return domain
def shorten_string(input_str, max_length=50):
if len(input_str) <= max_length:
return input_str
else:
return input_str[:max_length - 3] + ''
def shorten_url(input: str, max_length=20):
return shorten_string(input.replace('https://', '').replace('http://', ''))

View file

@ -0,0 +1,47 @@
"""filter keywords
Revision ID: b1d3fc38b1f7
Revises: f8200275644a
Create Date: 2023-09-20 19:35:04.332862
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b1d3fc38b1f7'
down_revision = 'f8200275644a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('filter',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=50), nullable=True),
sa.Column('filter_posts', sa.Boolean(), nullable=True),
sa.Column('filter_replies', sa.Boolean(), nullable=True),
sa.Column('hide_type', sa.Integer(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('filter_keyword',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('keyword', sa.String(length=100), nullable=True),
sa.Column('filter_id', sa.Integer(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['filter_id'], ['filter.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('filter_keyword')
op.drop_table('filter')
# ### end Alembic commands ###

View file

@ -1,11 +1,12 @@
# This file is part of pyfedi, which is licensed under the GNU General Public License (GPL) version 3.0.
# You should have received a copy of the GPL along with this program. If not, see <http://www.gnu.org/licenses/>.
from flask_babel import get_locale
from app import create_app, db, cli
import os, click
from app.utils import getmtime
from flask import session, g
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE
from app.utils import getmtime, gibberish, shorten_string, shorten_url
app = create_app()
cli.register(app)
@ -15,7 +16,7 @@ cli.register(app)
def app_context_processor(): # NB there needs to be an identical function in cb.wsgi to make this work in production
def getmtime(filename):
return os.path.getmtime('app/static/' + filename)
return dict(getmtime=getmtime)
return dict(getmtime=getmtime, post_type_link=POST_TYPE_LINK, post_type_image=POST_TYPE_IMAGE, post_type_article=POST_TYPE_ARTICLE)
@app.shell_context_processor
@ -25,4 +26,22 @@ def make_shell_context():
with app.app_context():
app.jinja_env.globals['getmtime'] = getmtime
app.jinja_env.globals['len'] = len
app.jinja_env.globals['len'] = len
app.jinja_env.globals['str'] = str
app.jinja_env.filters['shorten'] = shorten_string
app.jinja_env.filters['shorten_url'] = shorten_url
@app.before_request
def before_request():
session['nonce'] = gibberish()
g.locale = str(get_locale())
@app.after_request
def after_request(response):
response.headers['Content-Security-Policy'] = f"script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net 'nonce-{session['nonce']}'"
response.headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains; preload'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
return response