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 .routes.users import is_logged_in, get_active_user
|
||||||
from .constants import (
|
from .constants import (
|
||||||
PermissionLevel, permission_level_string,
|
PermissionLevel, permission_level_string,
|
||||||
InfoboxKind, InfoboxIcons, InfoboxHTMLClass
|
InfoboxKind, InfoboxIcons, InfoboxHTMLClass,
|
||||||
|
REACTION_EMOJI,
|
||||||
)
|
)
|
||||||
from .lib.babycode import babycode_to_html, EMOJI
|
from .lib.babycode import babycode_to_html, EMOJI
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -106,6 +107,7 @@ def create_app():
|
|||||||
"PermissionLevel": PermissionLevel,
|
"PermissionLevel": PermissionLevel,
|
||||||
"__commit": commit,
|
"__commit": commit,
|
||||||
"__emoji": EMOJI,
|
"__emoji": EMOJI,
|
||||||
|
"REACTION_EMOJI": REACTION_EMOJI,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
|
@ -15,6 +15,38 @@ PermissionLevelString = {
|
|||||||
PermissionLevel.ADMIN: 'Administrator',
|
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):
|
def permission_level_string(perm):
|
||||||
return PermissionLevelString[PermissionLevel(int(perm))]
|
return PermissionLevelString[PermissionLevel(int(perm))]
|
||||||
|
|
||||||
|
25
app/db.py
25
app/db.py
@ -89,6 +89,9 @@ class DB:
|
|||||||
self.table = table
|
self.table = table
|
||||||
self._where = [] # list of tuples
|
self._where = [] # list of tuples
|
||||||
self._select = "*"
|
self._select = "*"
|
||||||
|
self._group_by = ""
|
||||||
|
self._order_by = ""
|
||||||
|
self._order_asc = True
|
||||||
|
|
||||||
|
|
||||||
def _build_where(self):
|
def _build_where(self):
|
||||||
@ -104,6 +107,17 @@ class DB:
|
|||||||
return " WHERE " + " AND ".join(conditions), params
|
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 = "*"):
|
def select(self, columns = "*"):
|
||||||
self._select = columns
|
self._select = columns
|
||||||
return self
|
return self
|
||||||
@ -122,7 +136,16 @@ class DB:
|
|||||||
def build_select(self):
|
def build_select(self):
|
||||||
sql = f"SELECT {self._select} FROM {self.table}"
|
sql = f"SELECT {self._select} FROM {self.table}"
|
||||||
where_clause, params = self._build_where()
|
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):
|
def build_update(self, data):
|
||||||
|
@ -256,3 +256,28 @@ class APIRateLimits(Model):
|
|||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
return False
|
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 flask import Blueprint, request, url_for
|
||||||
from ..lib.babycode import babycode_to_html
|
from ..lib.babycode import babycode_to_html
|
||||||
|
from ..constants import REACTION_EMOJI
|
||||||
from .users import is_logged_in, get_active_user
|
from .users import is_logged_in, get_active_user
|
||||||
from ..models import APIRateLimits, Threads
|
from ..models import APIRateLimits, Threads, Reactions
|
||||||
from ..db import db
|
from ..db import db
|
||||||
|
|
||||||
bp = Blueprint("api", __name__, url_prefix="/api/")
|
bp = Blueprint("api", __name__, url_prefix="/api/")
|
||||||
@ -41,3 +42,57 @@ def babycode_preview():
|
|||||||
return {'error': 'markup field missing or invalid type'}, 400
|
return {'error': 'markup field missing or invalid type'}, 400
|
||||||
rendered = babycode_to_html(markup)
|
rendered = babycode_to_html(markup)
|
||||||
return {'html': rendered}
|
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 .users import login_required, mod_only, get_active_user, is_logged_in
|
||||||
from ..db import db
|
from ..db import db
|
||||||
from ..models import Threads, Topics, Posts, Subscriptions
|
from ..models import Threads, Topics, Posts, Subscriptions, Reactions
|
||||||
from ..constants import InfoboxKind
|
from ..constants import InfoboxKind
|
||||||
from .posts import create_post
|
from .posts import create_post
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
@ -61,6 +61,7 @@ def thread(slug):
|
|||||||
topic = topic,
|
topic = topic,
|
||||||
topics = other_topics,
|
topics = other_topics,
|
||||||
is_subscribed = is_subscribed,
|
is_subscribed = is_subscribed,
|
||||||
|
Reactions = Reactions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,6 +76,13 @@ SCHEMA = [
|
|||||||
"signature_rendered" TEXT NOT NULL DEFAULT ''
|
"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
|
# INDEXES
|
||||||
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)",
|
"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)",
|
"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 idx_topics_slug ON topics(slug)",
|
||||||
"CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)",
|
"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 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():
|
def create():
|
||||||
|
@ -89,13 +89,13 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endmacro %}
|
{% 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" %}
|
{% set postclass = "post" %}
|
||||||
{% if editing %}
|
{% if editing %}
|
||||||
{% set postclass = postclass + " editing" %}
|
{% set postclass = postclass + " editing" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set post_permalink = url_for("threads.thread", slug = post['thread_slug'], after = post['id'], _anchor = ("post-" + (post['id'] | string))) %}
|
{% 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">
|
||||||
<div class="usercard-inner">
|
<div class="usercard-inner">
|
||||||
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
|
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
|
||||||
@ -128,9 +128,11 @@
|
|||||||
|
|
||||||
{% set show_reply = true %}
|
{% 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 %}
|
{% 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 %}
|
{% set show_reply = false %}
|
||||||
{% elif editing %}
|
{% elif editing %}
|
||||||
{% set show_reply = false %}
|
{% 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>
|
<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'] %}
|
{% if render_sig and post['signature_rendered'] %}
|
||||||
<div class="signature-container">
|
<div class="signature-container">
|
||||||
<hr>
|
|
||||||
{{ post['signature_rendered'] | safe }}
|
{{ post['signature_rendered'] | safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -168,6 +169,37 @@
|
|||||||
{{ babycode_editor_form(cancel_url = post_permalink, prefill = post['original_markup'], ta_name = "new_content") }}
|
{{ babycode_editor_form(cancel_url = post_permalink, prefill = post['original_markup'], ta_name = "new_content") }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
@ -50,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% for post in posts %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -72,6 +72,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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) }}'>
|
<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 id="new-post-notification" class="new-concept-notification hidden">
|
||||||
<div class="new-notification-content">
|
<div class="new-notification-content">
|
||||||
|
@ -43,7 +43,7 @@
|
|||||||
{% if section == "header" %}
|
{% if section == "header" %}
|
||||||
{% set latest_post_id = thread.posts[-1].id %}
|
{% set latest_post_id = thread.posts[-1].id %}
|
||||||
{% set unread_posts_text = " (" + (thread.unread_count | string) + (" unread post" | pluralize(num=thread.unread_count)) %}
|
{% 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) }}">
|
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
|
||||||
<input type="hidden" name="subscribe" value="read">
|
<input type="hidden" name="subscribe" value="read">
|
||||||
<input type="submit" value="Mark thread as Read">
|
<input type="submit" value="Mark thread as Read">
|
||||||
|
@ -80,8 +80,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</i></a>
|
</i></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-content wider user-page-post-preview">
|
<div class="post-content user-page-post-preview">
|
||||||
<div class="post-inner">{{ post.content | safe }}</div>
|
<div class="post-inner wider">{{ post.content | safe }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -62,26 +62,26 @@
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const valid = isQuoteSelectionValid();
|
const valid = isQuoteSelectionValid();
|
||||||
if (isSelecting || !valid) {
|
if (isSelecting || !valid) {
|
||||||
removePopover();
|
removeQuotePopover();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
const selectionStr = selection.toString().trim();
|
const selectionStr = selection.toString().trim();
|
||||||
if (selection.isCollapsed || selectionStr === "") {
|
if (selection.isCollapsed || selectionStr === "") {
|
||||||
removePopover();
|
removeQuotePopover();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showPopover();
|
showQuotePopover();
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePopover() {
|
function removeQuotePopover() {
|
||||||
quotePopover?.hidePopover();
|
quotePopover?.hidePopover();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPopover() {
|
function createQuotePopover() {
|
||||||
quotePopover = document.createElement("div");
|
quotePopover = document.createElement("div");
|
||||||
quotePopover.popover = "auto";
|
quotePopover.popover = "auto";
|
||||||
quotePopover.className = "quote-popover";
|
quotePopover.className = "quote-popover";
|
||||||
@ -95,9 +95,9 @@
|
|||||||
return quoteButton;
|
return quoteButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPopover() {
|
function showQuotePopover() {
|
||||||
if (!quotePopover) {
|
if (!quotePopover) {
|
||||||
const quoteButton = createPopover();
|
const quoteButton = createQuotePopover();
|
||||||
quoteButton.addEventListener("click", () => {
|
quoteButton.addEventListener("click", () => {
|
||||||
console.log("Quoting:", document.getSelection().toString());
|
console.log("Quoting:", document.getSelection().toString());
|
||||||
const postPermalink = quotedPostContainer.dataset.postPermalink;
|
const postPermalink = quotedPostContainer.dataset.postPermalink;
|
||||||
@ -111,7 +111,7 @@
|
|||||||
ta.focus();
|
ta.focus();
|
||||||
|
|
||||||
document.getSelection().empty();
|
document.getSelection().empty();
|
||||||
removePopover();
|
removeQuotePopover();
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,4 +196,174 @@
|
|||||||
.catch(error => console.log(error))
|
.catch(error => console.log(error))
|
||||||
}
|
}
|
||||||
tryFetchUpdate();
|
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-weight: bold;
|
||||||
font-style: italic;
|
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;
|
cursor: default;
|
||||||
color: black;
|
color: black;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
@ -119,16 +119,18 @@ body {
|
|||||||
.post-content-container {
|
.post-content-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 70px 2.5fr;
|
grid-template-rows: min-content 1fr min-content;
|
||||||
gap: 0px 0px;
|
gap: 0px 0px;
|
||||||
grid-auto-flow: row;
|
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;
|
grid-area: post-content-container;
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-info {
|
.post-info {
|
||||||
grid-area: post-info;
|
grid-area: post-info;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-height: 70px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 5px 20px;
|
padding: 5px 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -138,19 +140,36 @@ body {
|
|||||||
|
|
||||||
.post-content {
|
.post-content {
|
||||||
grid-area: post-content;
|
grid-area: post-content;
|
||||||
padding: 20px;
|
padding: 20px 20px 0 20px;
|
||||||
margin-right: 25%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-color: #c1ceb1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-content.wider {
|
.post-reactions {
|
||||||
margin-right: 12.5%;
|
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 {
|
.post-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding-right: 25%;
|
||||||
|
}
|
||||||
|
.post-inner.wider {
|
||||||
|
padding-right: 12.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-container {
|
||||||
|
border-top: 2px dotted gray;
|
||||||
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre code {
|
pre code {
|
||||||
@ -797,3 +816,38 @@ ul, ol {
|
|||||||
footer {
|
footer {
|
||||||
border-top: 1px solid black;
|
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 {
|
.post-content-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 70px 2.5fr;
|
grid-template-rows: min-content 1fr min-content;
|
||||||
gap: 0px 0px;
|
gap: 0px 0px;
|
||||||
grid-auto-flow: row;
|
grid-auto-flow: row;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"post-info"
|
"post-info"
|
||||||
"post-content";
|
"post-content"
|
||||||
|
"post-reactions";
|
||||||
grid-area: post-content-container;
|
grid-area: post-content-container;
|
||||||
|
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-info {
|
.post-info {
|
||||||
grid-area: post-info;
|
grid-area: post-info;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-height: 70px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 5px 20px;
|
padding: 5px 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -187,19 +191,39 @@ body {
|
|||||||
|
|
||||||
.post-content {
|
.post-content {
|
||||||
grid-area: post-content;
|
grid-area: post-content;
|
||||||
padding: 20px;
|
padding: 20px 20px 0 20px;
|
||||||
margin-right: 25%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-color: $accent_color;
|
||||||
|
|
||||||
|
// min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-content.wider {
|
.post-reactions {
|
||||||
margin-right: 12.5%;
|
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 {
|
.post-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding-right: 25%;
|
||||||
|
|
||||||
|
&.wider {
|
||||||
|
padding-right: 12.5%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-container {
|
||||||
|
border-top: 2px dotted gray;
|
||||||
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre code {
|
pre code {
|
||||||
@ -788,3 +812,25 @@ ul, ol {
|
|||||||
footer {
|
footer {
|
||||||
border-top: 1px solid black;
|
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