diff --git a/app/__init__.py b/app/__init__.py index d611d3c..f51d7e9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/constants.py b/app/constants.py index b9efcce..c37f972 100644 --- a/app/constants.py +++ b/app/constants.py @@ -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))] diff --git a/app/db.py b/app/db.py index 1391e06..d377ac9 100644 --- a/app/db.py +++ b/app/db.py @@ -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): diff --git a/app/models.py b/app/models.py index 9a179b1..7d222d3 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/routes/api.py b/app/routes/api.py index 4f75d27..b8deb88 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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/') +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/') +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'} diff --git a/app/routes/threads.py b/app/routes/threads.py index 3b4a1f8..65ce44c 100644 --- a/app/routes/threads.py +++ b/app/routes/threads.py @@ -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, ) diff --git a/app/schema.py b/app/schema.py index 8e41fb7..ae1f1bf 100644 --- a/app/schema.py +++ b/app/schema.py @@ -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(): diff --git a/app/templates/common/macros.html b/app/templates/common/macros.html index ec94cd0..b6953b9 100644 --- a/app/templates/common/macros.html +++ b/app/templates/common/macros.html @@ -89,13 +89,13 @@ {% 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))) %} -
+
+ {% 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) -%} +
+ {% 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 %} + + + {% endfor %} + {% if can_react %} + + {% endif %} +
+ {% endif %}
{% endmacro %} diff --git a/app/templates/threads/thread.html b/app/templates/threads/thread.html index 7b327e6..b75a99b 100644 --- a/app/templates/threads/thread.html +++ b/app/templates/threads/thread.html @@ -50,7 +50,7 @@
{% 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 %} @@ -72,6 +72,7 @@ +