implement guild subscriptions, blocking, aesthetic improvements

This commit is contained in:
Yusur 2025-07-06 00:50:57 +02:00
parent c1c005cc4e
commit 05dca27149
23 changed files with 292 additions and 68 deletions

View file

@ -2,10 +2,13 @@
## 0.4.0 ## 0.4.0
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) - Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library
- Added user strikes, memberships and user blocks - Added user blocks
- Added user strikes: a strike logs the content of a removed message for future use
- Implemented guild subscriptions
+ Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile
- Added ✨color themes✨ - Added ✨color themes✨
- Users can now set their display name and biography in `/settings` - Users can now set their display name, biography and color theme in `/settings`
## 0.3.3 ## 0.3.3

View file

@ -6,11 +6,13 @@ AJAX hooks for the website.
''' '''
import re import re
from flask import Blueprint, request from flask import Blueprint, abort, flash, redirect, request
from sqlalchemy import delete, insert, select from sqlalchemy import delete, insert, select
from .models import Guild, db, User, Post, PostUpvote from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote
from flask_login import current_user, login_required from flask_login import current_user, login_required
current_user: User
bp = Blueprint('ajax', __name__) bp = Blueprint('ajax', __name__)
@bp.route('/username_availability/<username>') @bp.route('/username_availability/<username>')
@ -71,3 +73,81 @@ def post_upvote(id):
db.session.commit() db.session.commit()
return { 'status': 'ok', 'count': p.upvotes() } return { 'status': 'ok', 'count': p.upvotes() }
@bp.route('/@<username>/block', methods=['POST'])
@login_required
def block_user(username):
u = db.session.execute(select(User).where(User.username == username)).scalar()
if u is None:
abort(404)
is_block = 'reverse' not in request.form
is_unblock = request.form.get('reverse') == '1'
if is_block:
if current_user.has_blocked(u):
flash(f'{u.handle()} is already blocked')
else:
db.session.execute(insert(UserBlock).values(
actor_id = current_user.id,
target_id = u.id
))
db.session.commit()
flash(f'{u.handle()} is now blocked')
if is_unblock:
if not current_user.has_blocked(u):
flash('You didn\'t block this user')
else:
db.session.execute(delete(UserBlock).where(
UserBlock.c.actor_id == current_user.id,
UserBlock.c.target_id == u.id
))
db.session.commit()
flash(f'Removed block on {u.handle()}')
return redirect(request.args.get('next', u.url())), 303
@bp.route('/+<name>/subscribe', methods=['POST'])
@login_required
def subscribe_guild(name):
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
if gu is None:
abort(404)
is_join = 'reverse' not in request.form
is_leave = request.form.get('reverse') == '1'
membership = db.session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id)).scalar()
if is_join:
if membership is None:
membership = db.session.execute(insert(Member).values(
guild_id = gu.id,
user_id = current_user.id,
is_subscribed = True
).returning(Member)).scalar()
elif membership.is_subscribed == False:
membership.is_subscribed = True
db.session.add(membership)
else:
return redirect(gu.url()), 303
db.session.commit()
flash(f"You are now subscribed to {gu.handle()}")
if is_leave:
if membership is None:
return redirect(gu.url()), 303
elif membership.is_subscribed == True:
membership.is_subscribed = False
db.session.add(membership)
else:
return redirect(gu.url()), 303
db.session.commit()
flash(f"Unsubscribed from {gu.handle()}.")
return redirect(gu.url()), 303

View file

@ -1,8 +1,10 @@
from flask_login import current_user from flask_login import current_user
from sqlalchemy import func, select from sqlalchemy import and_, distinct, func, select
from .models import db, Post, Guild, User from .models import Comment, Member, db, Post, Guild, User
current_user: User
def cuser() -> User: def cuser() -> User:
return current_user if current_user.is_authenticated else None return current_user if current_user.is_authenticated else None
@ -22,13 +24,18 @@ def topic_timeline(gname):
def user_timeline(user_id): def user_timeline(user_id):
return select(Post).join(User, User.id == Post.author_id).where( return select(Post).join(User, User.id == Post.author_id).where(
Post.visible_by(cuser()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) Post.visible_by(cuser_id()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
).order_by(Post.created_at.desc()) ).order_by(Post.created_at.desc())
def top_guilds_query(): def top_guilds_query():
q_post_count = func.count().label('post_count') q_post_count = func.count(distinct(Post.id)).label('post_count')
qr = select(Guild, q_post_count)\ q_sub_count = func.count(distinct(Member.id)).label('sub_count')
.join(Post, Post.topic_id == Guild.id).group_by(Guild)\ qr = select(Guild, q_post_count, q_sub_count)\
.having(q_post_count > 5).order_by(q_post_count.desc()) .join(Post, Post.topic_id == Guild.id, isouter=True)\
.join(Member, and_(Member.guild_id == Guild.id, Member.is_subscribed == True), isouter=True)\
.group_by(Guild).having(q_post_count > 5).order_by(q_post_count.desc(), q_sub_count.desc())
return qr return qr
def new_comments(p: Post):
return select(Comment).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None,
Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())).order_by(Comment.created_at.desc())

View file

@ -2,7 +2,7 @@
import markdown import markdown
from markupsafe import Markup from markupsafe import Markup
from suou import Snowflake from suou import Siq, Snowflake
from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension
from . import app from . import app
@ -29,6 +29,12 @@ def to_b32l(n):
app.template_filter('b32l')(to_b32l) app.template_filter('b32l')(to_b32l)
@app.template_filter()
def to_cb32(n):
return '0' + Siq.from_bytes(n).to_cb32()
app.template_filter('cb32')(to_cb32)
@app.template_filter() @app.template_filter()
def append(text, l: list): def append(text, l: list):
l.append(text) l.append(text)

View file

@ -214,10 +214,28 @@ class User(Base):
def not_suspended(cls): def not_suspended(cls):
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now()) return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
def has_blocked(self, other: User | None) -> bool:
if other is None or not other.is_authenticated:
return False
return bool(db.session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id)).scalar())
@not_implemented()
def end_friendship(self, other: User):
"""
Remove any relationship between two users.
Executed before block.
"""
# TODO implement in 0.5
...
def has_subscriber(self, other: User) -> bool:
# TODO implement in 0.5
return False #bool(db.session.execute(select(Friendship).where(...)).scalar())
@classmethod @classmethod
def has_not_blocked(cls, actor, target): def has_not_blocked(cls, actor, target):
""" """
Filter out a content if the author has blocked current user. Filter out a content if the author has blocked current user. Returns a query.
XXX untested. XXX untested.
""" """
@ -242,6 +260,8 @@ class User(Base):
def strike_count(self) -> int: def strike_count(self) -> int:
return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar() return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar()
# UserBlock table is at the top !!
## END User ## END User
class Guild(Base): class Guild(Base):
@ -272,9 +292,16 @@ class Guild(Base):
def handle(self): def handle(self):
return f'+{self.name}' return f'+{self.name}'
def subscriber_count(self):
return db.session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True)).scalar()
# utilities # utilities
posts = relationship('Post', back_populates='guild') posts = relationship('Post', back_populates='guild')
def has_subscriber(self, other: User) -> bool:
if other is None or not other.is_authenticated:
return False
return bool(db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True)).scalar())
Topic = deprecated('renamed to Guild')(Guild) Topic = deprecated('renamed to Guild')(Guild)
@ -306,7 +333,7 @@ class Member(Base):
user = relationship(User, primaryjoin = lambda: User.id == Member.user_id) user = relationship(User, primaryjoin = lambda: User.id == Member.user_id)
guild = relationship(Guild) guild = relationship(Guild)
banned_by = relationship(User, primaryjoin= lambda: User.id == Member.banned_by_id) banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id)
@property @property
def is_banned(self): def is_banned(self):
@ -356,9 +383,9 @@ class Post(Base):
def url(self): def url(self):
return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '') return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '')
@not_implemented @not_implemented('slugify is not a dependency as of now')
def generate_slug(self): def generate_slug(self) -> str:
return slugify.slugify(self.title, max_length=64) return "slugify.slugify(self.title, max_length=64)"
def upvotes(self) -> int: def upvotes(self) -> int:
return (db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False)).scalar() return (db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False)).scalar()
@ -397,8 +424,8 @@ class Post(Base):
return Post.removed_at == None return Post.removed_at == None
@classmethod @classmethod
def visible_by(cls, user: User): def visible_by(cls, user_id: int | None):
return or_(Post.author_id == user.id, Post.privacy.in_((0, 1))) return or_(Post.author_id == user_id, Post.privacy.in_((0, 1)))
class Comment(Base): class Comment(Base):

View file

@ -1,15 +1,20 @@
from flask import Blueprint from flask import Blueprint, redirect, url_for
from flask_restx import Resource, Api from flask_restx import Resource
from sqlalchemy import select from sqlalchemy import select
from suou import Snowflake from suou import Snowflake
from suou.flask_sqlalchemy import require_auth
from suou.flask_restx import Api
from ..models import Post, User, db from ..models import Post, User, db
rest_bp = Blueprint('rest', __name__, url_prefix='/v1') rest_bp = Blueprint('rest', __name__, url_prefix='/v1')
rest = Api(rest_bp) rest = Api(rest_bp)
auth_required = require_auth(User, db)
@rest.route('/nurupo') @rest.route('/nurupo')
class Nurupo(Resource): class Nurupo(Resource):
def get(self): def get(self):
@ -18,9 +23,20 @@ class Nurupo(Resource):
## TODO coverage of REST is still partial, but it's planned ## TODO coverage of REST is still partial, but it's planned
## to get complete sooner or later ## to get complete sooner or later
## XXX there is a bug in suou.sqlalchemy.auth_required() — apparently, /user/@me does not
## redirect, neither is able to get user injected.
## Auth-based REST endpoints won't be fully functional until 0.6 in most cases
@rest.route('/user/@me')
class UserInfoMe(Resource):
@auth_required(required=True)
def get(self, user: User):
return redirect(url_for('rest.UserInfo', user.id)), 302
@rest.route('/user/<b32l:id>') @rest.route('/user/<b32l:id>')
class UserInfo(Resource): class UserInfo(Resource):
def get(self, id: int): def get(self, id: int):
## TODO sanizize REST to make blocked users inaccessible
u: User | None = db.session.execute(select(User).where(User.id == id)).scalar() u: User | None = db.session.execute(select(User).where(User.id == id)).scalar()
if u is None: if u is None:
return dict(error='User not found'), 404 return dict(error='User not found'), 404
@ -34,6 +50,7 @@ class UserInfo(Resource):
) )
return dict(users={f'{Snowflake(id):l}': uj}) return dict(users={f'{Snowflake(id):l}': uj})
@rest.route('/post/<b32l:id>') @rest.route('/post/<b32l:id>')
class SinglePost(Resource): class SinglePost(Resource):
def get(self, id: int): def get(self, id: int):
@ -48,4 +65,4 @@ class SinglePost(Resource):
created_at = p.created_at.isoformat('T') created_at = p.created_at.isoformat('T')
) )
return dict(posts={f'{Snowflake(id):l}': pj}) return dict(posts={f'{Snowflake(id):l}': pj})

View file

@ -15,7 +15,7 @@
--c7-accent: #606080 --c7-accent: #606080
--c8-accent: #aeaac0 --c8-accent: #aeaac0
--c9-accent: #3ae0b8 --c9-accent: #3ae0b8
--c10-accent: #a828ba --c10-accent: #8828ea
--c11-accent: #1871d8 --c11-accent: #1871d8
--c12-accent: #885a18 --c12-accent: #885a18
--c13-accent: #38a856 --c13-accent: #38a856

View file

@ -14,15 +14,15 @@ blockquote
padding-right: 1em padding-right: 1em
border-right: 4px solid var(--border) border-right: 4px solid var(--border)
.callout
color: var(--text-alt)
.success .success
color: var(--success) color: var(--success)
.error .error
color: var(--error) color: var(--error)
.callout
color: var(--text-alt)
.message-content .message-content
p p
margin: 4px 0 margin: 4px 0

View file

@ -103,9 +103,9 @@ header.header
// __ aside styles __ // // __ aside styles __ //
aside.card aside.card
overflow: hidden overflow: hidden
> :first-child > :is(h1, h2, h3, h4, h5, h6):first-child
background-color: var(--accent) background-color: var(--accent)
padding: 12px padding: 6px 12px
margin: -12px -12px 0 -12px margin: -12px -12px 0 -12px
position: relative position: relative
a a
@ -120,6 +120,9 @@ aside.card
padding: 12px padding: 12px
&:last-child &:last-child
border-bottom: none border-bottom: none
> p
padding: 12px
margin: 0
.flash .flash
@ -171,8 +174,6 @@ ul.message-options
list-style: none list-style: none
padding: 0 padding: 0
font-size: smaller font-size: smaller
.comment-frame &
margin-bottom: -4px
.post-frame .post-frame
margin-left: 3em margin-left: 3em
@ -321,12 +322,30 @@ button, [type="submit"], [type="reset"], [type="button"]
.comment-frame .comment-frame
border: 1px solid var(--border) border: 1px solid var(--border)
padding: 12px background: var(--background)
padding: 12px 12px 6px
border-radius: 24px border-radius: 24px
border-start-start-radius: 0 border-start-start-radius: 0
min-width: 50% min-width: 50%
width: 0 width: 0
margin-right: auto margin-inline-end: auto
margin-bottom: 12px
position: relative
&::before
content: ''
border: 1px solid var(--border)
border-inline-end: 0
border-bottom: 0
background: var(--background)
height: 1em
width: 1em
position: absolute
left: calc(-1px - .5em)
top: -1px
transform: skewX(45deg)
li:has(> &)
list-style: none
.border-accent .border-accent
border: var(--accent) 1px solid border: var(--accent) 1px solid
@ -352,3 +371,8 @@ button.card
background-color: var(--accent) background-color: var(--accent)
color: var(--background) color: var(--background)
.big_icon
display: block
margin: 12px auto
font-size: 36px
text-align: center

View file

@ -11,18 +11,18 @@
{% endfor %} {% endfor %}
</head> </head>
<body class="admin"> <body class="admin">
<div class="header"> <header class="header">
<h1><a href="{{ url_for('admin.homepage') }}"><span class="faint">{{ app_name }}:</span> Admin</a></h1> <h1><a href="{{ url_for('admin.homepage') }}"><span class="faint">{{ app_name }}:</span> Admin</a></h1>
</div> </header>
<div class="content"> <div class="content">
{% for message in get_flashed_messages() %} {% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div> <div class="flash">{{ message }}</div>
{% endfor %} {% endfor %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<div class="footer"> <footer class="footer">
<p><a href="/">Back to {{ app_name }}</a>.</p> <p><a href="/">Back to {{ app_name }}</a>.</p>
</div> </footer>
<script src="/static/lib.js"></script> <script src="/static/lib.js"></script>
</body> </body>
</html> </html>

View file

@ -5,7 +5,7 @@
<ul> <ul>
{% for strike in strike_list %} {% for strike in strike_list %}
<li> <li>
<p><strong>#{{ strike.id }}</strong> to {{ strike.user.handle() }}</p> <p><strong>#{{ strike.id | to_cb32 }}</strong> to {{ strike.user.handle() }}</p>
<ul class="inline"> <ul class="inline">
<li>Reason: <strong>{{ report_reasons[strike.reason_code] }}</strong></li> <li>Reason: <strong>{{ report_reasons[strike.reason_code] }}</strong></li>
<!-- you might not want to see why --> <!-- you might not want to see why -->

View file

@ -9,7 +9,6 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="card"> <div class="card">
<form action="{{ url_for('create.create') }}" method="POST" enctype="multipart/form-data" class="boundaryless"> <form action="{{ url_for('create.create') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

View file

@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros/title.html" import title_tag with context %} {% from "macros/title.html" import title_tag with context %}
{% from "macros/create.html" import privacy_select with context %} {% from "macros/create.html" import privacy_select with context %}
{% from "macros/icon.html" import icon, callout with context %}
{% block title %}{{ title_tag('Editing: ' + p.title, False) }}{% endblock %} {% block title %}{{ title_tag('Editing: ' + p.title, False) }}{% endblock %}
@ -16,10 +17,12 @@
<span class="a11y">Text:</span> <span class="a11y">Text:</span>
<textarea name="text" placeholder="What's happening?" class="create_text">{{ p.text_content }}</textarea></dd> <textarea name="text" placeholder="What's happening?" class="create_text">{{ p.text_content }}</textarea></dd>
</div> </div>
<div>{{ privacy_select(p.privacy) }}</div> <div>{{ privacy_select(p.privacy) }}</div>
<div><input type="submit" value="Save" /></dd> <div>
<input type="submit" value="Save" />
</div> </div>
</form> </form>
<p class="error">{{ icon('delete') }} <a href="/delete/post/{{ p.id | to_b32l }}">Delete post</a></p>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,27 @@
{% from "macros/icon.html" import icon with context%}
{% macro block_button(target, blocked = False) %}
<form method="POST" action="{{ target.url() }}/block">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
{% if blocked %}
<input type="hidden" name="reverse" value="1" />
<button type="submit" class="card">{{ icon('self') }} Remove block</button>
{% else %}
<button type="submit" class="card">{{ icon('block') }} Block</button>
{% endif %}
</form>
{% endmacro %}
{% macro subscribe_button(target, subbed = False) %}
<form method="POST" action="{{ target.url() }}/subscribe">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
{% if subbed %}
<input type="hidden" name="reverse" value="1" />
<button type="submit" class="card">{{ icon('leave') }} Unsubscribe</button>
{% else %}
<button type="submit" class="card">{{ icon('join') }} Subscribe</button>
{% endif %}
</form>
{% endmacro %}

View file

@ -1,4 +1,6 @@
{% from "macros/icon.html" import icon with context %}
{% macro checked_if(cond) %} {% macro checked_if(cond) %}
{% if cond -%} {% if cond -%}
checked="" checked=""
@ -12,11 +14,11 @@ disabled=""
{% endmacro %} {% endmacro %}
{% macro privacy_select(value = 0) %} {% macro privacy_select(value = 0) %}
<ul> <ul class="grid">
<li><input type="radio" name="privacy" value="0" id="new__privacy_0" {{ checked_if(value == 0) }} /><label for="new__privacy_0" >Public <small class="faint">(everyone in your profile or public timeline)</small></label></li> <li><input type="radio" name="privacy" value="0" id="new__privacy_0" {{ checked_if(value == 0) }} /><label for="new__privacy_0" >{{ icon('topic_travel') }} Public <small class="faint">(everyone in your profile or public timeline)</small></label></li>
<li><input type="radio" name="privacy" value="1" id="new__privacy_1" {{ checked_if(value == 1) }} /><label for="new__privacy_1" >Unlisted <small class="faint">(everyone in your profile, hide from public timeline)</small></label></li> <li><input type="radio" name="privacy" value="1" id="new__privacy_1" {{ checked_if(value == 1) }} /><label for="new__privacy_1" >{{ icon('link_post') }} Unlisted <small class="faint">(everyone in your profile, hide from public timeline)</small></label></li>
<li><input type="radio" name="privacy" value="2" id="new__privacy_2" {{ checked_if(value == 2) }} /><label for="new__privacy_2" >Friends <small class="faint">(only people you follow each other)</small></label></li> <li><input type="radio" name="privacy" value="2" id="new__privacy_2" {{ checked_if(value == 2) }} /><label for="new__privacy_2" >{{ icon('custom_feed') }} Friends <small class="faint">(only people you follow each other)</small></label></li>
<li><input type="radio" name="privacy" value="3" id="new__privacy_3" {{ checked_if(value == 3) }} /><label for="new__privacy_3" >Only you <small class="faint">(nobody else)</small></label></li> <li><input type="radio" name="privacy" value="3" id="new__privacy_3" {{ checked_if(value == 3) }} /><label for="new__privacy_3" >{{ icon('lock') }} Only you <small class="faint">(nobody else)</small></label></li>
</ul> </ul>
{% endmacro %} {% endmacro %}

View file

@ -3,9 +3,13 @@
<i class="icon icon-{{ name }}{{ '_fill' if fill }}"></i> <i class="icon icon-{{ name }}{{ '_fill' if fill }}"></i>
{% endmacro %} {% endmacro %}
{% macro callout(useicon = "spoiler") %} {% macro callout(useicon = "spoiler", classes = "") %}
<div class="callout"> <div class="callout {{ classes }}">
{{ icon(useicon) }} {{ icon(useicon) }}
{{ caller() }} {{ caller() }}
</div> </div>
{% endmacro %}
{% macro big_icon(name, fill = False) %}
<div class="big_icon">{{ icon(name, fill) }}</div>
{% endmacro %} {% endmacro %}

View file

@ -1,4 +1,7 @@
{% from "macros/icon.html" import icon with context %}
{% from "macros/button.html" import block_button, subscribe_button with context %}
{% macro nav_guild(gu) %} {% macro nav_guild(gu) %}
<aside class="card"> <aside class="card">
<h3>About <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h3> <h3>About <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h3>
@ -6,21 +9,37 @@
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li> <li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
<li> <li>
<strong>{{ gu.posts | count }}</strong> posts - <strong>{{ gu.posts | count }}</strong> posts -
<strong>-</strong> subscribers <strong>{{ gu.subscriber_count() }}</strong> subscribers
</li> </li>
</ul> </ul>
</aside> </aside>
{% if current_user.is_authenticated %}
{{ subscribe_button(gu, gu.has_subscriber(current_user)) }}
{% endif %}
{% endmacro %} {% endmacro %}
{% macro nav_user(user) %} {% macro nav_user(user) %}
<aside class="card"> <aside class="card">
<h3>About <a href="{{ user.url() }}">{{ user.display_name or user.handle() }}</a></h3> <h3>About <a href="{{ user.url() }}">{{ user.display_name or user.handle() }}</a></h3>
<ul> <ul>
{% if user.biography %} {% if user.biography %}
<li>{{ icon('info') }} {{ user.biography }}</li> <li>{{ icon('info') }} {{ user.biography }}</li>
{% endif %} {% endif %}
{% if current_user.is_authenticated and current_user.age() >= 18 and user.age() < 18 %}
<li class="error">{{ icon('spoiler') }} MINOR</li>
{% endif %}
</ul> </ul>
</aside> </aside>
{% if user == current_user %}
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
{% elif current_user.is_authenticated %}
{{ block_button(user, current_user.has_blocked(user)) }}
{{ subscribe_button(user, user.has_subscriber(current_user)) }}
{% else %}
<aside class="card">
<p><a href="/login">Log in</a> to subscribe and interact with {{ user.handle() }}</p>
</aside>
{% endif %}
{% endmacro %} {% endmacro %}
{% macro nav_top_communities(top_communities) %} {% macro nav_top_communities(top_communities) %}
@ -35,4 +54,4 @@
{% endif %} {% endif %}
</ul> </ul>
</aside> </aside>
{% endmacro %} {% endmacro %}

View file

@ -40,12 +40,12 @@
{% endif %} {% endif %}
</div> </div>
{% if current_user.is_administrator and p.report_count() %} {% if current_user.is_administrator and p.report_count() %}
{% call callout() %} {% call callout('spoiler', 'error') %}
<strong>{{ p.report_count() }}</strong> reports. <a href="{{ url_for('admin.reports') }}">Take action</a> <strong>{{ p.report_count() }}</strong> reports. <a href="{{ url_for('admin.reports') }}">Take action</a>
{% endcall %} {% endcall %}
{% endif %} {% endif %}
{% if p.is_removed %} {% if p.is_removed %}
{% call callout('delete') %} {% call callout('delete', 'error') %}
This post has been removed This post has been removed
{% endcall %} {% endcall %}
{% endif %} {% endif %}
@ -69,7 +69,7 @@
{{ comment_area(p.url()) }} {{ comment_area(p.url()) }}
<div class="comment-section"> <div class="comment-section">
<ul> <ul>
{% for comment in p.top_level_comments() %} {% for comment in comments %}
<li id="comment-{{ comment.id }}" data-endpoint="{{ comment.id|to_b32l }}"> <li id="comment-{{ comment.id }}" data-endpoint="{{ comment.id|to_b32l }}">
{{ single_comment(comment) }} {{ single_comment(comment) }}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %} {% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
{% from "macros/title.html" import title_tag with context %} {% from "macros/title.html" import title_tag with context %}
{% from "macros/icon.html" import icon, callout with context %} {% from "macros/icon.html" import icon, big_icon, callout with context %}
{% from "macros/nav.html" import nav_user with context %} {% from "macros/nav.html" import nav_user with context %}
{% block title %}{{ title_tag(user.handle() + 's content') }}{% endblock %} {% block title %}{{ title_tag(user.handle() + 's content') }}{% endblock %}
@ -16,9 +16,8 @@
{% endblock %} {% endblock %}
{% block nav %} {% block nav %}
{{ nav_user(user) }} {% if user.is_active and not user.has_blocked(current_user) %}
{% if user == current_user %} {{ nav_user(user) }}
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@ -40,9 +39,10 @@
{% endif %} {% endif %}
</ul> </ul>
{% elif not user.is_active %} {% elif not user.is_active %}
{{ big_icon('ban') }}
<p class="centered">{{ user.handle() }} is suspended</p> <p class="centered">{{ user.handle() }} is suspended</p>
{% else %} {% else %}
<p class="centered">{{ user.handle() }} never posted any content</p> <p class="centered">{{ user.handle() }} has never posted any content</p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -29,7 +29,7 @@
<h2>Appearance</h2> <h2>Appearance</h2>
<div> <div>
<label>Color scheme</label> <label>Color scheme</label>
<ul class="apply-theme"> <ul class="apply-theme grid">
<li><input type="radio" id="US__color_scheme_dark" name="color_scheme" value="dark" {{ checked_if((current_user.color_theme // 256) == 2) }}><label for="US__color_scheme_dark">Dark</label></li> <li><input type="radio" id="US__color_scheme_dark" name="color_scheme" value="dark" {{ checked_if((current_user.color_theme // 256) == 2) }}><label for="US__color_scheme_dark">Dark</label></li>
<li><input type="radio" id="US__color_scheme_light" name="color_scheme" value="light" {{ checked_if((current_user.color_theme // 256) == 1) }}><label for="US__color_scheme_light">Light</label></li> <li><input type="radio" id="US__color_scheme_light" name="color_scheme" value="light" {{ checked_if((current_user.color_theme // 256) == 1) }}><label for="US__color_scheme_light">Light</label></li>
<li><input type="radio" id="US__color_scheme_unset" name="color_scheme" value="unset" {{ checked_if((current_user.color_theme // 256) == 0) }}><label for="US__color_scheme_unset">System</label></li> <li><input type="radio" id="US__color_scheme_unset" name="color_scheme" value="unset" {{ checked_if((current_user.color_theme // 256) == 0) }}><label for="US__color_scheme_unset">System</label></li>
@ -37,7 +37,7 @@
</div> </div>
<div> <div>
<label>Color theme</label> <label>Color theme</label>
<ul class="apply-theme"> <ul class="apply-theme grid">
{% for color in colors %} {% for color in colors %}
<li><input type="radio" id="US__color_theme_{{ color.code }}" name="color_theme" value="{{ color.code }}" {{ checked_if((current_user.color_theme % 256) == color.code) }}><label for="US__color_theme_{{ color.code }}">{{ color.name }}</label></li> <li><input type="radio" id="US__color_theme_{{ color.code }}" name="color_theme" value="{{ color.code }}" {{ checked_if((current_user.color_theme % 256) == color.code) }}><label for="US__color_theme_{{ color.code }}">{{ color.name }}</label></li>
{% endfor %} {% endfor %}

View file

@ -1,4 +1,5 @@
from typing import Iterable
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for from flask import Blueprint, abort, flash, request, redirect, render_template, url_for
from flask_login import current_user from flask_login import current_user
from sqlalchemy import insert, select from sqlalchemy import insert, select
@ -6,7 +7,7 @@ from suou import Snowflake
from ..utils import is_b32l from ..utils import is_b32l
from ..models import Comment, Guild, db, User, Post from ..models import Comment, Guild, db, User, Post
from ..algorithms import user_timeline from ..algorithms import new_comments, user_timeline
bp = Blueprint('detail', __name__) bp = Blueprint('detail', __name__)
@ -64,12 +65,17 @@ def post_detail(id: int):
else: else:
abort(404) abort(404)
def comments_of(p: Post) -> Iterable[Comment]:
## TODO add sort argument
return db.paginate(new_comments(p))
@bp.route('/@<username>/comments/<b32l:id>/', methods=['GET', 'POST']) @bp.route('/@<username>/comments/<b32l:id>/', methods=['GET', 'POST'])
@bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST']) @bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
def user_post_detail(username: str, id: int, slug: str = ''): def user_post_detail(username: str, id: int, slug: str = ''):
post: Post | None = db.session.execute(select(Post).join(User, User.id == Post.author_id).where(Post.id == id, User.username == username)).scalar() post: Post | None = db.session.execute(select(Post).join(User, User.id == Post.author_id).where(Post.id == id, User.username == username)).scalar()
if post is None or (post.is_removed and post.author != current_user): if post is None or (post.author and post.author.has_blocked(current_user)) or (post.is_removed and post.author != current_user):
abort(404) abort(404)
if post.slug and slug != post.slug: if post.slug and slug != post.slug:
@ -78,14 +84,14 @@ def user_post_detail(username: str, id: int, slug: str = ''):
if request.method == 'POST': if request.method == 'POST':
single_post_post_hook(post) single_post_post_hook(post)
return render_template('singlepost.html', p=post) return render_template('singlepost.html', p=post, comments=comments_of(post))
@bp.route('/+<gname>/comments/<b32l:id>/', methods=['GET', 'POST']) @bp.route('/+<gname>/comments/<b32l:id>/', methods=['GET', 'POST'])
@bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST']) @bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
def guild_post_detail(gname, id, slug=''): def guild_post_detail(gname, id, slug=''):
post: Post | None = db.session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname)).scalar() post: Post | None = db.session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname)).scalar()
if post is None or (post.is_removed and post.author != current_user): if post is None or (post.author and post.author.has_blocked(current_user)) or (post.is_removed and post.author != current_user):
abort(404) abort(404)
if post.slug and slug != post.slug: if post.slug and slug != post.slug:
@ -94,7 +100,7 @@ def guild_post_detail(gname, id, slug=''):
if request.method == 'POST': if request.method == 'POST':
single_post_post_hook(post) single_post_post_hook(post)
return render_template('singlepost.html', p=post) return render_template('singlepost.html', p=post, comments=comments_of(post))

View file

@ -11,7 +11,7 @@ bp = Blueprint('frontpage', __name__)
@bp.route('/') @bp.route('/')
def homepage(): def homepage():
top_communities = [(x[0], x[1], 0) for x in top_communities = [(x[0], x[1], x[2]) for x in
db.session.execute(top_guilds_query().limit(10)).fetchall()] db.session.execute(top_guilds_query().limit(10)).fetchall()]
if current_user and current_user.is_authenticated: if current_user and current_user.is_authenticated:

View file

@ -18,7 +18,7 @@ dependencies = [
"PsycoPG2-binary", "PsycoPG2-binary",
"libsass", "libsass",
"setuptools>=78.1.0", "setuptools>=78.1.0",
"sakuragasaki46-suou>=0.3.3" "sakuragasaki46-suou>=0.3.4"
] ]
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [