add subscribing and unsubscribing, add post editing

This commit is contained in:
2026-04-28 19:03:29 +03:00
parent f3acf64e6d
commit ff2c6606f8
8 changed files with 219 additions and 33 deletions

View File

@@ -102,6 +102,9 @@ class Users(Model):
def get_badges(self):
return Badges.findall({'user_id': int(self.id)})
def is_subscribed(self, thread_id):
return Subscriptions.count({'user_id': self.id, 'thread_id': thread_id}) > 0
class Topics(Model):
table = 'topics'
@@ -327,7 +330,8 @@ class Posts(Model):
for mention in html_content.mentions:
Mentions.create({
'revision_id': revision.id,
'mentioned_iser_id': mention['mentioned_iser_id'],
'mentioned_user_id': mention['mentioned_user_id'],
'original_mention_text': mention['mention_text'],
'start_index': mention['start'],
'end_index': mention['end'],
})
@@ -335,6 +339,32 @@ class Posts(Model):
post.update({'current_revision_id': revision.id})
return post
def edit(self, new_content: str, language: str = 'babycode'):
from .lib.babycode import babycode_to_html, babycode_to_rssxml, BABYCODE_VERSION
html_content = babycode_to_html(new_content)
rssxml_content = babycode_to_rssxml(new_content)
with db.transaction():
revision = PostHistory.create({
'post_id': self.id,
'content': html_content.result,
'content_rss': rssxml_content,
'is_initial_revision': False,
'original_markup': new_content,
'markup_language': language,
'format_version': BABYCODE_VERSION,
})
for mention in html_content.mentions:
Mentions.create({
'revision_id': revision.id,
'mentioned_user_id': mention['mentioned_user_id'],
'original_mention_text': mention['mention_text'],
'start_index': mention['start'],
'end_index': mention['end'],
})
self.update({'current_revision_id': revision.id})
class PostHistory(Model):
table = 'post_history'

View File

@@ -1,7 +1,9 @@
from flask import Blueprint, abort
from flask import Blueprint, abort, render_template, redirect, url_for, request
from functools import wraps
from ..auth import login_required, get_active_user
from ..models import Posts
from ..models import Posts, Threads
from ..util import get_post_url
from ..db import db
bp = Blueprint('posts', __name__, url_prefix='/posts/')
@@ -9,10 +11,11 @@ def ownership_required(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
post = Posts.find({'id': kwargs.get('post_id', None)})
user = get_active_user()
if not post:
abort(404)
if post.user_id != get_active_user().id:
if post.user_id != user.id:
abort(403)
return view_func(*args, **kwargs)
@@ -35,7 +38,46 @@ def ownership_or_mod_required(view_func):
@login_required
@ownership_required
def edit(post_id):
return 'stub'
post = Posts.find({'id': post_id})
thread = Threads.find({'id': post.thread_id})
user = get_active_user()
if not thread:
# what?
abort(404)
if thread.locked() and not user.is_mod():
abort(403)
thread_predicate = f'{Posts.FULL_POSTS_QUERY} WHERE posts.thread_id = ?'
context_prev_q = f'{thread_predicate} AND posts.created_at < ? ORDER BY posts.created_at DESC LIMIT 2'
context_next_q = f'{thread_predicate} AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 2'
context_next = db.query(context_next_q, thread.id, post.created_at)
context_prev = db.query(context_prev_q, thread.id, post.created_at)
return render_template(
'posts/edit.html', post=post.get_full_post_view(),
context_next=context_next, context_prev=context_prev
)
@bp.post('/<int:post_id>/edit/')
@login_required
@ownership_required
def edit_post(post_id):
post = Posts.find({'id': post_id})
thread = Threads.find({'id': post.thread_id})
user = get_active_user()
if not thread:
# what?
abort(404)
if thread.locked() and not user.is_mod():
abort(403)
post.edit(request.form.get('babycode_content', ''))
return redirect(get_post_url(post.id, _anchor=True))
@bp.get('/<int:post_id>/delete/')
@login_required

View File

@@ -1,7 +1,8 @@
from flask import Blueprint, redirect, url_for, render_template, request, abort
from functools import wraps
from ..auth import login_required, get_active_user
from ..models import Threads, Posts, Topics, Users, Reactions
from ..models import Threads, Posts, Topics, Users, Reactions, Subscriptions
from ..util import get_form_checkbox, time_now
import math
bp = Blueprint('threads', __name__, url_prefix='/threads/')
@@ -56,7 +57,15 @@ def thread(thread_id, slug):
page = max(1, min(int(request.args.get('page', default=1)), page_count))
except ValueError:
abort(404)
return render_template('threads/thread.html', thread=thread, posts=thread.get_posts(PER_PAGE, page), page=page, page_count=page_count, topic=topic, started_by=started_by, topics=Topics.get_list(), Reactions=Reactions)
posts = thread.get_posts(PER_PAGE, page)
last_post = posts[-1]
return render_template(
'threads/thread.html', thread=thread,
posts=posts, page=page,
page_count=page_count, topic=topic,
started_by=started_by, topics=Topics.get_list(),
Reactions=Reactions, last_post=last_post
)
@bp.post('/<int:thread_id>/')
@login_required
@@ -68,6 +77,13 @@ def reply(thread_id):
if not user.can_post_to_thread_or_topic(thread):
return redirect(url_for('.thread_by_id', thread_id=thread_id))
post = Posts.new(user.id, thread.id, request.form.get('babycode_content'))
if get_form_checkbox('subscribe'):
if not Subscriptions.find({'user_id': user.id, 'thread_id': thread.id}):
Subscriptions.create({
'user_id': user.id,
'thread_id': thread.id,
'last_seen': time_now(),
})
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=post.id, _anchor=f'post-{post.id}'))
@bp.get('/<int:thread_id>/edit/')
@@ -82,6 +98,48 @@ def edit(thread_id):
def edit_post(thread_id):
return 'stub'
@bp.post('/<int:thread_id>/subscribe/')
@login_required
def subscribe(thread_id):
thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
user = get_active_user()
last_post_id = request.form.get('last_post_id', None)
if last_post_id is None:
abort(400)
if user.is_subscribed(thread_id):
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
Subscriptions.create({
'user_id': user.id,
'thread_id': thread_id,
'last_seen': request.form.get('last_post_timestamp', time_now())
})
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
@bp.post('/<int:thread_id>/unsubscribe/')
@login_required
def unsubscribe(thread_id):
thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
user = get_active_user()
last_post_id = request.form.get('last_post_id', None)
if last_post_id is None:
abort(400)
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread_id})
if not subscription:
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
subscription.delete()
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
@bp.get('/<int:thread_id>/feed.atom/')
def feed(thread_id):
return 'stub'

View File

@@ -1,7 +1,8 @@
{%- from 'common/icons.html' import icn_info, icn_warn, icn_error, icn_bookmark -%}
{% macro timestamp(unix_ts) -%}
<span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>
{#<span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>#}
<time datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
{%- endmacro %}
{% macro subheader(title, desc='') -%}
@@ -103,9 +104,9 @@
<button type="button" class="minimal">&bullet;</button>
<button type="button" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>
</span>
<a href="##">babycode help</a>
</span>
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}}>{{ prefill }}</textarea>
<a href="##">babycode help</a>
{%- endif -%}
{%- endcall -%}
{%- endmacro %}
@@ -142,11 +143,17 @@
</div>
<div class="post-content">
<div class="plank even minimal secondary-bg no-shadow post-info">
<a href="{{get_post_url(post.id, _anchor=true)}}"><i>Posted on {{timestamp(post.created_at)}}</i></a>
<a href="{{get_post_url(post.id, _anchor=true)}}">
{%- if post.edited_at <= post.created_at -%}
<i>Posted on {{timestamp(post.created_at)}}</i>
{%- else -%}
<i>Edited on {{timestamp(post.edited_at)}}</i>
{%- endif -%}
</a>
{%- if show_toolbar -%}
<span class="thread-actions">
{%- if owns -%}
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id)}}">Edit</a>
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id, _anchor='babycode-content')}}">Edit</a>
{%- endif -%}
{%- if can_reply -%}
<button disabled title="This feature requires JavaScript to be enabled.">Quote</button>
@@ -158,28 +165,37 @@
</span>
{%- endif -%}
</div>
<div class="plank even no-shadow post-content-inner minimal">{{post.content | safe}}
{%- if render_sig and post.signature_rendered -%}
<aside class="post-signature">{{post.signature_rendered | safe}}</aside>
<div class="plank even no-shadow post-content-inner minimal">
{%- if not is_editing -%}
{{post.content | safe}}
{%- if render_sig and post.signature_rendered -%}
<aside class="post-signature">{{post.signature_rendered | safe}}</aside>
{%- endif -%}
{%- else -%}
{{- babycode_editor_component(prefill=post.original_markup) -}}
{%- endif -%}
</div>
{%- if show_reactions -%}
<div class="plank even secondary-bg minimal no-shadow">
<span class="button-row">
{%- for reaction in Reactions.for_post(post.id) -%}
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
{% set reactors_trimmed = reactors[:10] %}
{% set reactors_str = reactors_trimmed | join (',\n') %}
{% if reactors | count > 10 %}
{% set reactors_str = reactors_str + '\n...and many others' %}
{% endif %}
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
<button disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
</span>
{%- if is_logged_in() -%}<button disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%}
{%- if show_reactions -%}
<span class="button-row">
{%- for reaction in Reactions.for_post(post.id) -%}
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
{% set reactors_trimmed = reactors[:10] %}
{% set reactors_str = reactors_trimmed | join (',\n') %}
{% if reactors | count > 10 %}
{% set reactors_str = reactors_str + '\n...and many others' %}
{% endif %}
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
<button disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
</span>
{%- if is_logged_in() -%}<button disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%}
{%- elif is_editing -%}
<input type="submit" value="Save">
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a>
{%- endif -%}
</div>
{%- endif -%}
</div>
{%- endmacro %}

View File

@@ -0,0 +1,24 @@
{%- from 'common/macros.html' import full_post with context -%}
{%- extends 'base.html' -%}
{%- block title -%}editing a post{%- endblock -%}
{%- block content -%}
{%- for post in context_prev -%}
<div class="post plank">{{- full_post(post=post, show_toolbar=false, show_reactions=false) -}}</div>
{%- endfor -%}
<div class="plank secondary-bg context-explain">
<span>&uarr;&uarr;&uarr;</span>
<i>Context</i>
<span>&uarr;&uarr;&uarr;</span>
</div>
<form class="post plank" method="POST">
{{- full_post(post=post, is_editing=true, show_toolbar=false, show_reactions=false) -}}
</form>
<div class="plank secondary-bg context-explain">
<span>&darr;&darr;&darr;</span>
<i>Context</i>
<span>&darr;&darr;&darr;</span>
</div>
{%- for post in context_next -%}
<div class="post plank">{{- full_post(post=post, show_toolbar=false, show_reactions=false) -}}</div>
{%- endfor -%}
{%- endblock -%}

View File

@@ -24,7 +24,11 @@
{%- if thread.user_id == get_active_user().id -%}
<a class="linkbutton" href="{{url_for('threads.edit', thread_id=thread.id)}}">Edit</a>
{%- endif -%}
<button>Subscribe</button>
<form action="{{url_for('threads.subscribe' if not get_active_user().is_subscribed(thread.id) else 'threads.unsubscribe', thread_id=thread.id)}}" method="POST">
<input type="hidden" name="last_post_timestamp" value="{{last_post.created_at}}">
<input type="hidden" name="last_post_id" value="{{last_post.id}}">
<input type="submit" value="{{'Subscribe' if not get_active_user().is_subscribed(thread.id) else 'Unsubscribe'}}">
</form>
<button disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark&hellip;</button>
{%- endif -%}
<a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a>

View File

@@ -1,6 +1,7 @@
from flask import url_for, session
from flask import url_for, session, request
from .models import Posts, Threads
from .auth import is_logged_in
import time
def get_post_url(post_id, _anchor=False, external=False):
post = Posts.find({'id': post_id})
@@ -24,3 +25,9 @@ def get_csrf_token():
def csrf_input():
return f'<input type="hidden" name="csrf" value="{get_csrf_token()}">'
def get_form_checkbox(name: str) -> bool:
return request.form.get(name, None) == 'on'
def time_now() -> int:
return int(time.time())

View File

@@ -182,7 +182,7 @@ button, .linkbutton, input[type="submit"] {
}
.tab-content {
min-height: 250px;
&.hidden {
display: none;
@@ -192,7 +192,7 @@ button, .linkbutton, input[type="submit"] {
.babycode-editor {
width: 100%;
height: 150px;
min-height: 150px;
}
.post-edit-form {
@@ -620,6 +620,11 @@ form.full-width {
}
}
.context-explain {
display: flex;
justify-content: space-evenly;
}
/* babycode tags */
.inline-code {
background-color: var(--code-bg-color);