add reactions support to thread
This commit is contained in:
		@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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))]
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25
									
								
								app/db.py
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								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):
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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'}
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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():
 | 
			
		||||
 
 | 
			
		||||
@@ -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 %}
 | 
			
		||||
 
 | 
			
		||||
@@ -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">
 | 
			
		||||
 
 | 
			
		||||
@@ -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">
 | 
			
		||||
 
 | 
			
		||||
@@ -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 %}
 | 
			
		||||
 
 | 
			
		||||
@@ -62,26 +62,26 @@
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        const valid = isQuoteSelectionValid();
 | 
			
		||||
        if (isSelecting || !valid) {
 | 
			
		||||
          removePopover();
 | 
			
		||||
          removeQuotePopover();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const selection = document.getSelection();
 | 
			
		||||
        const selectionStr = selection.toString().trim();
 | 
			
		||||
        if (selection.isCollapsed || selectionStr === "") {
 | 
			
		||||
          removePopover();
 | 
			
		||||
          removeQuotePopover();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        showPopover();
 | 
			
		||||
        showQuotePopover();
 | 
			
		||||
      }, 50)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function removePopover() {
 | 
			
		||||
    function removeQuotePopover() {
 | 
			
		||||
      quotePopover?.hidePopover();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function createPopover() {
 | 
			
		||||
    function createQuotePopover() {
 | 
			
		||||
      quotePopover = document.createElement("div");
 | 
			
		||||
      quotePopover.popover = "auto";
 | 
			
		||||
      quotePopover.className = "quote-popover";
 | 
			
		||||
@@ -95,9 +95,9 @@
 | 
			
		||||
      return quoteButton;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function showPopover() {
 | 
			
		||||
    function showQuotePopover() {
 | 
			
		||||
      if (!quotePopover) {
 | 
			
		||||
        const quoteButton = createPopover();
 | 
			
		||||
        const quoteButton = createQuotePopover();
 | 
			
		||||
        quoteButton.addEventListener("click", () => {
 | 
			
		||||
          console.log("Quoting:", document.getSelection().toString());
 | 
			
		||||
          const postPermalink = quotedPostContainer.dataset.postPermalink;
 | 
			
		||||
@@ -111,7 +111,7 @@
 | 
			
		||||
          ta.focus();
 | 
			
		||||
 | 
			
		||||
          document.getSelection().empty();
 | 
			
		||||
          removePopover();
 | 
			
		||||
          removeQuotePopover();
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@@ -196,4 +196,174 @@
 | 
			
		||||
      .catch(error => console.log(error))
 | 
			
		||||
  }
 | 
			
		||||
  tryFetchUpdate();
 | 
			
		||||
 | 
			
		||||
  if (supportsPopover()){
 | 
			
		||||
    const reactionEmoji = document.getElementById("allowed-reaction-emoji").value.split(" ");
 | 
			
		||||
    let reactionPopover = null;
 | 
			
		||||
    let reactionTargetPostId = null;
 | 
			
		||||
 | 
			
		||||
    function tryAddReaction(emoji, postId = reactionTargetPostId) {
 | 
			
		||||
      const body = JSON.stringify({
 | 
			
		||||
        "emoji": emoji,
 | 
			
		||||
      });
 | 
			
		||||
      fetch(`/api/add-reaction/${postId}`, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
 | 
			
		||||
        .then(res => res.json())
 | 
			
		||||
        .then(json => {
 | 
			
		||||
          if (json.status === "added") {
 | 
			
		||||
            const post = document.getElementById(`post-${postId}`);
 | 
			
		||||
            const spans = Array.from(post.querySelectorAll(".reaction-count")).filter((span) => {
 | 
			
		||||
              return span.dataset.emoji === emoji
 | 
			
		||||
            });
 | 
			
		||||
            if (spans.length > 0) {
 | 
			
		||||
              const currentValue = spans[0].textContent;
 | 
			
		||||
              spans[0].textContent = `${parseInt(currentValue) + 1}`;
 | 
			
		||||
              const button = spans[0].closest(".reaction-button");
 | 
			
		||||
              button.classList.add("active");
 | 
			
		||||
            } else {
 | 
			
		||||
              const span = document.createElement("span");
 | 
			
		||||
              span.classList = "reaction-container";
 | 
			
		||||
              span.dataset.emoji = emoji;
 | 
			
		||||
 | 
			
		||||
              const button = document.createElement("button");
 | 
			
		||||
              button.type = "button";
 | 
			
		||||
              button.className = "reduced reaction-button active";
 | 
			
		||||
 | 
			
		||||
              button.addEventListener("click", () => {
 | 
			
		||||
                tryAddReaction(emoji, postId);
 | 
			
		||||
              })
 | 
			
		||||
 | 
			
		||||
              const img = document.createElement("img");
 | 
			
		||||
              img.src = `/static/emoji/${emoji}.png`;
 | 
			
		||||
 | 
			
		||||
              button.textContent = " x";
 | 
			
		||||
 | 
			
		||||
              const reactionCountSpan = document.createElement("span")
 | 
			
		||||
              reactionCountSpan.className = "reaction-count"
 | 
			
		||||
              reactionCountSpan.textContent = "1"
 | 
			
		||||
 | 
			
		||||
              button.insertAdjacentElement("afterbegin", img);
 | 
			
		||||
              button.appendChild(reactionCountSpan);
 | 
			
		||||
 | 
			
		||||
              span.appendChild(button);
 | 
			
		||||
 | 
			
		||||
              const post = document.getElementById(`post-${postId}`);
 | 
			
		||||
              post.querySelector(".post-reactions").insertBefore(span, post.querySelector(".add-reaction-button"));
 | 
			
		||||
            }
 | 
			
		||||
          } else if (json.error_code === 409) {
 | 
			
		||||
            console.log("reaction exists, gonna try and remove");
 | 
			
		||||
            tryRemoveReaction(emoji, postId);
 | 
			
		||||
          } else {
 | 
			
		||||
            console.warn(json)
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      .catch(error => console.error(error));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function tryRemoveReaction(emoji, postId = reactionTargetPostId) {
 | 
			
		||||
      const body = JSON.stringify({
 | 
			
		||||
        "emoji": emoji,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      fetch(`/api/remove-reaction/${postId}`, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
 | 
			
		||||
      .then(res => res.json())
 | 
			
		||||
      .then(json => {
 | 
			
		||||
        if (json.status === "removed") {
 | 
			
		||||
          const post = document.getElementById(`post-${postId}`);
 | 
			
		||||
          const spans = Array.from(post.querySelectorAll(".reaction-container")).filter((span) => {
 | 
			
		||||
            return span.dataset.emoji === emoji
 | 
			
		||||
          });
 | 
			
		||||
          if (spans.length > 0) {
 | 
			
		||||
            const reactionCountSpan = spans[0].querySelector(".reaction-count");
 | 
			
		||||
            const currentValue = parseInt(reactionCountSpan.textContent);
 | 
			
		||||
            if (currentValue - 1 === 0) {
 | 
			
		||||
              spans[0].remove();
 | 
			
		||||
            } else {
 | 
			
		||||
              reactionCountSpan.textContent = `${parseInt(currentValue) - 1}`;
 | 
			
		||||
              const button = reactionCountSpan.closest(".reaction-button");
 | 
			
		||||
              button.classList.remove("active");
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          console.warn(json)
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      .catch(error => console.error(error));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function createReactionPopover() {
 | 
			
		||||
      reactionPopover = document.createElement("div");
 | 
			
		||||
      reactionPopover.className = "reaction-popover";
 | 
			
		||||
      reactionPopover.popover = "auto";
 | 
			
		||||
 | 
			
		||||
      const inner = document.createElement("div");
 | 
			
		||||
      inner.className = "reaction-popover-inner";
 | 
			
		||||
 | 
			
		||||
      reactionPopover.appendChild(inner);
 | 
			
		||||
 | 
			
		||||
      for (let emoji of reactionEmoji) {
 | 
			
		||||
        const img = document.createElement("img");
 | 
			
		||||
        img.src = `/static/emoji/${emoji}.png`;
 | 
			
		||||
 | 
			
		||||
        const button = document.createElement("button");
 | 
			
		||||
        button.type = "button";
 | 
			
		||||
        button.className = "reduced";
 | 
			
		||||
        button.appendChild(img);
 | 
			
		||||
        button.addEventListener("click", () => {
 | 
			
		||||
          tryAddReaction(emoji);
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        button.dataset.emojiName = emoji;
 | 
			
		||||
 | 
			
		||||
        inner.appendChild(button);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      reactionPopover.addEventListener("beforetoggle", (e) => {
 | 
			
		||||
        if (e.newState === "closed") {
 | 
			
		||||
          reactionTargetPostId = null;
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      document.body.appendChild(reactionPopover);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function showReactionPopover() {
 | 
			
		||||
      if (!reactionPopover) {
 | 
			
		||||
        createReactionPopover();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!reactionPopover.matches(':popover-open')) {
 | 
			
		||||
        reactionPopover.showPopover();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (let button of document.querySelectorAll(".add-reaction-button")) {
 | 
			
		||||
      button.addEventListener("click", (e) => {
 | 
			
		||||
        showReactionPopover();
 | 
			
		||||
        reactionTargetPostId = e.target.dataset.postId;
 | 
			
		||||
 | 
			
		||||
        const rect = e.target.getBoundingClientRect();
 | 
			
		||||
        const popoverRect = reactionPopover.getBoundingClientRect();
 | 
			
		||||
        const scrollY = window.scrollY || window.pageYOffset;
 | 
			
		||||
 | 
			
		||||
        reactionPopover.style.setProperty("top", `${rect.top + scrollY + rect.height}px`)
 | 
			
		||||
        reactionPopover.style.setProperty("left", `${rect.left + rect.width/2 - popoverRect.width/2}px`)
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (let button of document.querySelectorAll(".reaction-button")) {
 | 
			
		||||
      button.addEventListener("click", () => {
 | 
			
		||||
        const reactionContainer = button.closest(".reaction-container")
 | 
			
		||||
        const emoji = reactionContainer.dataset.emoji;
 | 
			
		||||
        const postId = reactionContainer.dataset.postId;
 | 
			
		||||
        console.log(reactionContainer);
 | 
			
		||||
        tryAddReaction(emoji, postId);
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  } else {
 | 
			
		||||
    for (let button of document.querySelectorAll(".add-reaction-button")) {
 | 
			
		||||
      button.disabled = true;
 | 
			
		||||
      button.title = "Enable JS to add reactions."
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
.tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
 | 
			
		||||
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
 | 
			
		||||
  cursor: default;
 | 
			
		||||
  color: black;
 | 
			
		||||
  font-size: 0.9em;
 | 
			
		||||
@@ -119,16 +119,18 @@ body {
 | 
			
		||||
.post-content-container {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: 1fr;
 | 
			
		||||
  grid-template-rows: 70px 2.5fr;
 | 
			
		||||
  grid-template-rows: min-content 1fr min-content;
 | 
			
		||||
  gap: 0px 0px;
 | 
			
		||||
  grid-auto-flow: row;
 | 
			
		||||
  grid-template-areas: "post-info" "post-content";
 | 
			
		||||
  grid-template-areas: "post-info" "post-content" "post-reactions";
 | 
			
		||||
  grid-area: post-content-container;
 | 
			
		||||
  min-height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post-info {
 | 
			
		||||
  grid-area: post-info;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  min-height: 70px;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  padding: 5px 20px;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
@@ -138,19 +140,36 @@ body {
 | 
			
		||||
 | 
			
		||||
.post-content {
 | 
			
		||||
  grid-area: post-content;
 | 
			
		||||
  padding: 20px;
 | 
			
		||||
  margin-right: 25%;
 | 
			
		||||
  padding: 20px 20px 0 20px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  background-color: #c1ceb1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post-content.wider {
 | 
			
		||||
  margin-right: 12.5%;
 | 
			
		||||
.post-reactions {
 | 
			
		||||
  grid-area: post-reactions;
 | 
			
		||||
  min-height: 50px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  padding: 5px 20px;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  gap: 5px;
 | 
			
		||||
  background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
 | 
			
		||||
  border-top: 2px dotted gray;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post-inner {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  padding-right: 25%;
 | 
			
		||||
}
 | 
			
		||||
.post-inner.wider {
 | 
			
		||||
  padding-right: 12.5%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.signature-container {
 | 
			
		||||
  border-top: 2px dotted gray;
 | 
			
		||||
  padding: 10px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pre code {
 | 
			
		||||
@@ -797,3 +816,38 @@ ul, ol {
 | 
			
		||||
footer {
 | 
			
		||||
  border-top: 1px solid black;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.reaction-button.active {
 | 
			
		||||
  background-color: #beb1ce;
 | 
			
		||||
}
 | 
			
		||||
.reaction-button.active:hover {
 | 
			
		||||
  background-color: rgb(203, 192.6, 215.8);
 | 
			
		||||
}
 | 
			
		||||
.reaction-button.active:active {
 | 
			
		||||
  background-color: rgb(171.7642913386, 166.6881496063, 178.0118503937);
 | 
			
		||||
}
 | 
			
		||||
.reaction-button.active:disabled {
 | 
			
		||||
  background-color: rgb(210.445, 209.535, 211.565);
 | 
			
		||||
}
 | 
			
		||||
.reaction-button.active.reduced {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.reaction-popover {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.6901960784);
 | 
			
		||||
  padding: 5px 10px;
 | 
			
		||||
  width: 250px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.reaction-popover-inner {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  overflow: scroll;
 | 
			
		||||
  margin: auto;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -166,18 +166,22 @@ body {
 | 
			
		||||
.post-content-container {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: 1fr;
 | 
			
		||||
  grid-template-rows: 70px 2.5fr;
 | 
			
		||||
  grid-template-rows: min-content 1fr min-content;
 | 
			
		||||
  gap: 0px 0px;
 | 
			
		||||
  grid-auto-flow: row;
 | 
			
		||||
  grid-template-areas:
 | 
			
		||||
    "post-info"
 | 
			
		||||
    "post-content";
 | 
			
		||||
    "post-content"
 | 
			
		||||
    "post-reactions";
 | 
			
		||||
  grid-area: post-content-container;
 | 
			
		||||
 | 
			
		||||
  min-height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post-info {
 | 
			
		||||
  grid-area: post-info;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  min-height: 70px;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  padding: 5px 20px;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
@@ -187,19 +191,39 @@ body {
 | 
			
		||||
 | 
			
		||||
.post-content {
 | 
			
		||||
  grid-area: post-content;
 | 
			
		||||
  padding: 20px;
 | 
			
		||||
  margin-right: 25%;
 | 
			
		||||
  padding: 20px 20px 0 20px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  background-color: $accent_color;
 | 
			
		||||
 | 
			
		||||
  // min-height: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post-content.wider {
 | 
			
		||||
  margin-right: 12.5%;
 | 
			
		||||
.post-reactions {
 | 
			
		||||
  grid-area: post-reactions;
 | 
			
		||||
  min-height: 50px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  padding: 5px 20px;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  gap: 5px;
 | 
			
		||||
  background-color: $main_bg;
 | 
			
		||||
  border-top: 2px dotted gray;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post-inner {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  padding-right: 25%;
 | 
			
		||||
 | 
			
		||||
  &.wider {
 | 
			
		||||
    padding-right: 12.5%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.signature-container {
 | 
			
		||||
  border-top: 2px dotted gray;
 | 
			
		||||
  padding: 10px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pre code {
 | 
			
		||||
@@ -788,3 +812,25 @@ ul, ol {
 | 
			
		||||
footer {
 | 
			
		||||
  border-top: 1px solid black;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.reaction-button.active {
 | 
			
		||||
  @include button($button_color2);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.reaction-popover {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  background-color: #000000b0;
 | 
			
		||||
  padding: 5px 10px;
 | 
			
		||||
  width: 250px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.reaction-popover-inner {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  overflow: scroll;
 | 
			
		||||
  margin: auto;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user