add reactions support to thread

This commit is contained in:
2025-08-04 21:10:23 +03:00
parent acac6ed778
commit a529c1db65
14 changed files with 485 additions and 34 deletions

View File

@ -5,7 +5,8 @@ from .auth import digest
from .routes.users import is_logged_in, get_active_user
from .constants import (
PermissionLevel, permission_level_string,
InfoboxKind, InfoboxIcons, InfoboxHTMLClass
InfoboxKind, InfoboxIcons, InfoboxHTMLClass,
REACTION_EMOJI,
)
from .lib.babycode import babycode_to_html, EMOJI
from datetime import datetime
@ -106,6 +107,7 @@ def create_app():
"PermissionLevel": PermissionLevel,
"__commit": commit,
"__emoji": EMOJI,
"REACTION_EMOJI": REACTION_EMOJI,
}
@app.context_processor

View File

@ -15,6 +15,38 @@ PermissionLevelString = {
PermissionLevel.ADMIN: 'Administrator',
}
REACTION_EMOJI = [
'smile',
'grin',
'neutral',
'wink',
'frown',
'angry',
'think',
'sob',
'surprised',
'smiletear',
'tongue',
'pensive',
'weary',
'imp',
'impangry',
'lobster',
'scissors',
]
def permission_level_string(perm):
return PermissionLevelString[PermissionLevel(int(perm))]

View File

@ -89,6 +89,9 @@ class DB:
self.table = table
self._where = [] # list of tuples
self._select = "*"
self._group_by = ""
self._order_by = ""
self._order_asc = True
def _build_where(self):
@ -104,6 +107,17 @@ class DB:
return " WHERE " + " AND ".join(conditions), params
def group_by(self, stmt):
self._group_by = stmt
return self
def order_by(self, stmt, asc = True):
self._order_by = stmt
self._order_asc = asc
return self
def select(self, columns = "*"):
self._select = columns
return self
@ -122,7 +136,16 @@ class DB:
def build_select(self):
sql = f"SELECT {self._select} FROM {self.table}"
where_clause, params = self._build_where()
return sql + where_clause, params
stmt = sql + where_clause
if self._group_by:
stmt += " GROUP BY " + self._group_by
if self._order_by:
stmt += " ORDER BY " + self._order_by + (" ASC" if self._order_asc else " DESC")
return stmt, params
def build_update(self, data):

View File

@ -256,3 +256,28 @@ class APIRateLimits(Model):
return True
else:
return False
class Reactions(Model):
table = "reactions"
@classmethod
def for_post(cls, post_id):
qb = db.QueryBuilder(cls.table)\
.select("reaction_text, COUNT(*) as c")\
.where({"post_id": post_id})\
.group_by("reaction_text")\
.order_by("c", False)
result = qb.all()
return result if result else []
@classmethod
def get_users(cls, post_id, reaction_text):
q = """
SELECT user_id, username FROM reactions
JOIN
users ON users.id = user_id
WHERE
post_id = ? AND reaction_text = ?
"""
return db.query(q, post_id, reaction_text)

View File

@ -1,7 +1,8 @@
from flask import Blueprint, request, url_for
from ..lib.babycode import babycode_to_html
from ..constants import REACTION_EMOJI
from .users import is_logged_in, get_active_user
from ..models import APIRateLimits, Threads
from ..models import APIRateLimits, Threads, Reactions
from ..db import db
bp = Blueprint("api", __name__, url_prefix="/api/")
@ -41,3 +42,57 @@ def babycode_preview():
return {'error': 'markup field missing or invalid type'}, 400
rendered = babycode_to_html(markup)
return {'html': rendered}
@bp.post('/add-reaction/<post_id>')
def add_reaction(post_id):
if not is_logged_in():
return {'error': 'not authorized', 'error_code': 401}, 401
user = get_active_user()
reaction_text = request.json.get('emoji')
if not reaction_text or not isinstance(reaction_text, str):
return {'error': 'emoji field missing or invalid type', 'error_code': 400}, 400
if reaction_text not in REACTION_EMOJI:
return {'error': 'unsupported reaction', 'error_code': 400}, 400
reaction = Reactions.find({
'user_id': user.id,
'post_id': int(post_id),
'reaction_text': reaction_text,
})
if reaction:
return {'error': 'reaction already exists', 'error_code': 409}, 409
reaction = Reactions.create({
'user_id': user.id,
'post_id': int(post_id),
'reaction_text': reaction_text,
})
return {'status': 'added'}
@bp.post('/remove-reaction/<post_id>')
def remove_reaction(post_id):
if not is_logged_in():
return {'error': 'not authorized'}, 401
user = get_active_user()
reaction_text = request.json.get('emoji')
if not reaction_text or not isinstance(reaction_text, str):
return {'error': 'emoji field missing or invalid type'}, 400
if reaction_text not in REACTION_EMOJI:
return {'error': 'unsupported reaction'}, 400
reaction = Reactions.find({
'user_id': user.id,
'post_id': int(post_id),
'reaction_text': reaction_text,
})
if not reaction:
return {'error': 'reaction does not exist'}, 404
reaction.delete()
return {'status': 'removed'}

View File

@ -3,7 +3,7 @@ from flask import (
)
from .users import login_required, mod_only, get_active_user, is_logged_in
from ..db import db
from ..models import Threads, Topics, Posts, Subscriptions
from ..models import Threads, Topics, Posts, Subscriptions, Reactions
from ..constants import InfoboxKind
from .posts import create_post
from slugify import slugify
@ -61,6 +61,7 @@ def thread(slug):
topic = topic,
topics = other_topics,
is_subscribed = is_subscribed,
Reactions = Reactions,
)

View File

@ -76,6 +76,13 @@ SCHEMA = [
"signature_rendered" TEXT NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS "reactions" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" REFERENCES users(id) ON DELETE CASCADE,
"post_id" REFERENCES posts(id) ON DELETE CASCADE,
"reaction_text" TEXT NOT NULL DEFAULT ''
)""",
# INDEXES
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)",
"CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)",
@ -87,6 +94,9 @@ SCHEMA = [
"CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)",
"CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)",
"CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)",
"CREATE INDEX IF NOT EXISTS reaction_post_text ON reactions(post_id, reaction_text)",
"CREATE INDEX IF NOT EXISTS reaction_user_post_text ON reactions(user_id, post_id, reaction_text)",
]
def create():

View File

@ -89,13 +89,13 @@
</form>
{% endmacro %}
{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false) %}
{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false, Reactions = none) %}
{% set postclass = "post" %}
{% if editing %}
{% set postclass = postclass + " editing" %}
{% endif %}
{% set post_permalink = url_for("threads.thread", slug = post['thread_slug'], after = post['id'], _anchor = ("post-" + (post['id'] | string))) %}
<div class=" {{ postclass }}" id="post-{{ post['id'] }}">
<div class=" {{ postclass }}" id="post-{{ post['id'] }}" data-post-id="{{ post['id'] }}">
<div class="usercard">
<div class="usercard-inner">
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
@ -128,9 +128,11 @@
{% set show_reply = true %}
{% if active_user and post['thread_is_locked'] and not active_user.is_mod() %}
{% if not active_user %}
{% set show_reply = false %}
{% elif active_user and active_user.is_guest() %}
{% elif post['thread_is_locked'] and not active_user.is_mod() %}
{% set show_reply = false %}
{% elif active_user.is_guest() %}
{% set show_reply = false %}
{% elif editing %}
{% set show_reply = false %}
@ -160,7 +162,6 @@
<div class="post-inner" data-post-permalink="{{ post_permalink }}" data-author-username="{{ post.username }}">{{ post['content'] | safe }}</div>
{% if render_sig and post['signature_rendered'] %}
<div class="signature-container">
<hr>
{{ post['signature_rendered'] | safe }}
</div>
{% endif %}
@ -168,6 +169,37 @@
{{ babycode_editor_form(cancel_url = post_permalink, prefill = post['original_markup'], ta_name = "new_content") }}
{% endif %}
</div>
{% if Reactions -%}
{% set can_react = true -%}
{% if not active_user -%}
{% set can_react = false -%}
{% elif post['thread_is_locked'] and not active_user.is_mod() -%}
{% set can_react = false -%}
{% elif active_user.is_guest() -%}
{% set can_react = false -%}
{% elif editing -%}
{% set can_react = false -%}
{% endif -%}
{% set reactions = Reactions.for_post(post.id) -%}
<div class="post-reactions">
{% for reaction in reactions %}
{% 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 = active_user is not none and active_user.username in reactors %}
<span class="reaction-container" data-emoji="{{ reaction.reaction_text }}" data-post-id="{{ post.id }}"><button type="button" class="reduced reaction-button {{"active" if has_reacted else ""}}" {{ "disabled" if not can_react else ""}} title="{{reactors_str}}"><img class=emoji src="/static/emoji/{{reaction.reaction_text}}.png"> x<span class="reaction-count" data-emoji="{{ reaction.reaction_text }}">{{reaction.c}}</span></button>
</span>
{% endfor %}
{% if can_react %}
<button type="button" class="reduced add-reaction-button" data-post-id="{{ post.id }}">Add reaction</button>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endmacro %}

View File

@ -50,7 +50,7 @@
</div>
</nav>
{% for post in posts %}
{{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length)) }}
{{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length), Reactions = Reactions) }}
{% endfor %}
</main>
@ -72,6 +72,7 @@
</span>
</div>
</dialog>
<input type='hidden' id='allowed-reaction-emoji' value='{{ REACTION_EMOJI | join(' ') }}'>
<input type='hidden' id='thread-subscribe-endpoint' value='{{ url_for('api.thread_updates', thread_id=thread.id) }}'>
<div id="new-post-notification" class="new-concept-notification hidden">
<div class="new-notification-content">

View File

@ -43,7 +43,7 @@
{% if section == "header" %}
{% set latest_post_id = thread.posts[-1].id %}
{% set unread_posts_text = " (" + (thread.unread_count | string) + (" unread post" | pluralize(num=thread.unread_count)) %}
<a class="accordion-title" href="{{ url_for("threads.thread", slug=latest_post_slug, after=latest_post_id, _anchor="post-" + (latest_post_id | string)) }}" title="Jump to latest post">{{thread.thread_title + unread_posts_text}}, latest at {{ timestamp(thread.newest_post_time) }})</a>
<a class="accordion-title" href="{{ url_for("threads.thread", slug=thread.thread_slug, after=latest_post_id, _anchor="post-" + (latest_post_id | string)) }}" title="Jump to latest post">{{thread.thread_title + unread_posts_text}}, latest at {{ timestamp(thread.newest_post_time) }})</a>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
<input type="hidden" name="subscribe" value="read">
<input type="submit" value="Mark thread as Read">

View File

@ -80,8 +80,8 @@
{% endif %}
</i></a>
</div>
<div class="post-content wider user-page-post-preview">
<div class="post-inner">{{ post.content | safe }}</div>
<div class="post-content user-page-post-preview">
<div class="post-inner wider">{{ post.content | safe }}</div>
</div>
</div>
{% endfor %}