bring back reactions

This commit is contained in:
2026-06-07 23:01:58 +03:00
parent 5dfe477607
commit b63b6a1682
11 changed files with 248 additions and 35 deletions

View File

@@ -1,10 +1,21 @@
from flask import Blueprint, request from flask import Blueprint, request
from ..auth import is_logged_in, hard_login_required, get_active_user from ..auth import is_logged_in, hard_login_required, get_active_user
from ..lib.babycode import babycode_to_html from ..lib.babycode import babycode_to_html
from ..models import APIRateLimits from ..models import APIRateLimits, Posts, Threads, Reactions
from ..constants import REACTION_EMOJI
bp = Blueprint('api', __name__, url_prefix='/api/') bp = Blueprint('api', __name__, url_prefix='/api/')
@bp.before_request
def ensure_json():
if request.method == 'POST':
if not request.is_json:
return {'error': 'unsupported media type'}, 415
elif not request.content_length:
return {'error': 'body expected'}, 400
elif not isinstance(request.json, dict):
return {'error': 'body must be an object'}, 400
@bp.post('/babycode-preview/') @bp.post('/babycode-preview/')
@hard_login_required @hard_login_required
def babycode_preview(): def babycode_preview():
@@ -31,3 +42,48 @@ def whoami():
'username': user.username, 'username': user.username,
'display_name': user.display_name, 'display_name': user.display_name,
} }
@bp.post('/toggle-reaction/')
@hard_login_required
def toggle_reaction():
user = get_active_user()
emoji = request.json.get('reaction')
if emoji not in REACTION_EMOJI:
return {'error': f'invalid reaction string, given: {emoji}'}, 400
post_id = request.json.get('post', -1)
post = Posts.find({'id': post_id})
if not post:
return {'error': 'post not found'}, 404
thread = Threads.find({'id': post.thread_id})
if not user.can_post_to_thread_or_topic(thread):
return {'error': 'thread is locked'}, 403
reaction_obj = {
'user_id': int(user.id),
'post_id': int(post_id),
'reaction_text': emoji,
}
r = Reactions.find(reaction_obj)
if r:
# remove
r.delete()
return {'status': 'ok', 'added': False}
else:
# add
r = Reactions.create(reaction_obj)
return {'status': 'ok', 'added': True}
@bp.get('/thread-permission/<int:thread_id>')
def thread_permission(thread_id):
user = get_active_user()
if not user:
return {'can_post': False}
thread = Threads.find({'id': thread_id})
if not thread:
return {'can_post': False}
return {'can_post': user.can_post_to_thread_or_topic(thread)}

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, render_template, request, url_for from flask import Blueprint, render_template, request, url_for
from ..auth import get_active_user, is_logged_in, hard_login_required from ..auth import get_active_user, is_logged_in, hard_login_required
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, Threads, Posts, Badges, BadgeUploads from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, Threads, Posts, Badges, BadgeUploads, Reactions
from functools import wraps from functools import wraps
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/') bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
@@ -133,3 +133,7 @@ def badge_editor():
badges = Badges.get_for_user(user.id) badges = Badges.get_for_user(user.id)
badge_uploads = BadgeUploads.get_for_user(user.id) badge_uploads = BadgeUploads.get_for_user(user.id)
return render_template('hyper/badge_editor.html', badges=badges, badge_uploads=badge_uploads) return render_template('hyper/badge_editor.html', badges=badges, badge_uploads=badge_uploads)
@bp.get('/reactions/<int:post_id>')
def get_reaction_buttons(post_id):
return render_template('hyper/reaction_buttons.html', Reactions=Reactions, post_id=post_id)

View File

@@ -35,6 +35,13 @@ def ownership_or_mod_required(view_func):
return view_func(*args, **kwargs) return view_func(*args, **kwargs)
return wrapper return wrapper
@bp.get('/<int:post_id>/')
def post_by_id(post_id):
post = get_post_url(post_id, _anchor=True)
if not post:
abort(404)
return redirect(post)
@bp.get('/<int:post_id>/edit/') @bp.get('/<int:post_id>/edit/')
@login_required @login_required
@ownership_required @ownership_required

View File

@@ -658,7 +658,6 @@ def save_badges(username):
('id', 'NOT IN', ids), ('id', 'NOT IN', ids),
('user_id', '=', user.id), ('user_id', '=', user.id),
]) ])
print(list(map(lambda x: x.id, deleted_badges)))
with db.transaction(): with db.transaction():
for b in deleted_badges: for b in deleted_badges:

View File

@@ -140,6 +140,19 @@
<button autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" disabled title="This feature requires JavaScript to be enabled." data-concept-kind="{{kind}}" data-concept-id="{{id}}">{{icn_bookmark(24)}}{{text}}&hellip;</button> <button autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" disabled title="This feature requires JavaScript to be enabled." data-concept-kind="{{kind}}" data-concept-id="{{id}}">{{icn_bookmark(24)}}{{text}}&hellip;</button>
{%- endmacro %} {%- endmacro %}
{% macro reaction_buttons(post_id) -%}
{%- for reaction in Reactions.for_post(post_id) -%}
{% set reactors = Reactions.get_users(post_id, reaction.reaction_text) | map(attribute='username') | list %}
{% set reactors_trimmed = reactors[:10] %}
{% set reactors_str = reactors_trimmed | join (',\n') %}
{% if reactors | count > 10 %}
{% set reactors_str = reactors_str + '\n...and many others' %}
{% endif %}
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
<button autocomplete="off" type="button" title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}" data-emoji="{{reaction.reaction_text}}" data-s="toggleReaction" data-r="enableReactionButtons" disabled><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
{%- endmacro %}
{% macro full_post( {% macro full_post(
post, render_sig=true, is_latest=false, post, render_sig=true, is_latest=false,
show_toolbar=true, is_editing=false, thread=none, show_toolbar=true, is_editing=false, thread=none,
@@ -218,19 +231,10 @@
</div> </div>
<div class="plank even secondary-bg minimal no-shadow"> <div class="plank even secondary-bg minimal no-shadow">
{%- if show_reactions -%} {%- if show_reactions -%}
<span class="button-row"> <span class="button-row" data-r="replaceReactionButtons">
{%- for reaction in Reactions.for_post(post.id) -%} {{- reaction_buttons(post.id) -}}
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
{% set reactors_trimmed = reactors[:10] %}
{% set reactors_str = reactors_trimmed | join (',\n') %}
{% if reactors | count > 10 %}
{% set reactors_str = reactors_str + '\n...and many others' %}
{% endif %}
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
<button data-r="enhance" type="button" disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
</span> </span>
{%- if is_logged_in() and allow_reacting -%}<button autocomplete='off' data-r="enhance" disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%} {%- if is_logged_in() and allow_reacting -%}<button autocomplete='off' data-r="disableReactionMenuButton enableReactionMenuButton" disabled title="This feature requires JavaScript to be enabled." data-s="openReactionMenu">Add reaction</button>{%- endif -%}
{%- elif is_editing -%} {%- elif is_editing -%}
<input type="submit" value="Save"> <input type="submit" value="Save">
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a> <a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a>

View File

@@ -0,0 +1,2 @@
{%- from 'common/macros.html' import reaction_buttons with context -%}
{{- reaction_buttons(post_id) -}}

View File

@@ -65,7 +65,7 @@
{%- endcall -%} {%- endcall -%}
<main> <main>
{%- for post in posts -%} {%- for post in posts -%}
<article id="post-{{post.id}}" class="post plank"> <article id="post-{{post.id}}" class="post plank" data-postid="{{post.id}}">
{{full_post(post)}} {{full_post(post)}}
</article> </article>
{%- endfor -%} {%- endfor -%}
@@ -98,6 +98,13 @@
</div> </div>
</dialog> </dialog>
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%} {%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
<div class="plank even" id="reaction-popover" popover data-r="openReactionMenu closeReactionMenu">
{%- for emoji in REACTION_EMOJI -%}
<button class="minimal emoji-button" title=":{{emoji}}:" data-emoji="{{emoji}}" data-s="toggleReaction"><img src="/static/emoji/{{emoji}}.png"></button>
{%- endfor -%}
</div>
{%- endif -%}
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
<form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form" data-listen="submit" data-r="clearThreadDraft" data-s="clearThreadDraft"> <form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form" data-listen="submit" data-r="clearThreadDraft" data-s="clearThreadDraft">
<h2 class="info">Reply to "{{thread.title}}"</h2> <h2 class="info">Reply to "{{thread.title}}"</h2>
{{- babycode_editor_component() -}} {{- babycode_editor_component() -}}

View File

@@ -696,6 +696,39 @@ details.inner {
justify-content: center; justify-content: center;
} }
#reaction-popover {
position: absolute;
margin-block: var(--small-padding);
margin-inline: 0;
width: 300px;
--button-size: calc(var(--huge-padding) * 2);
.emoji-button {
min-width: var(--button-size);
min-height: var(--button-size);
img {
image-rendering: crisp-edges;
width: 30px;
}
}
}
#reaction-popover:popover-open {
--gap: var(--base-padding);
--max-columns: 4;
display: grid;
gap: var(--gap);
justify-items: center;
--grid-item-size: calc((100% - var(--gap) * var(--max-columns)) / var(--max-columns));
grid-template-columns: repeat(
auto-fit,
minmax(max(var(--button-size), var(--grid-item-size)), 1fr)
);
}
#bookmark-popover { #bookmark-popover {
position: absolute; position: absolute;
min-width: 400px; min-width: 400px;

View File

@@ -14,28 +14,28 @@ async function getHTML(endpoint, options = {}) {
return { body: await res.text(), status: res.status }; return { body: await res.text(), status: res.status };
} }
export const b = { export const b = {};
bookmarksCollectionEndpoint: '/hyperapi/bookmarks/dropdown/',
bookmarkMenuState: {}, const BOOKMARKS_COLLECTION_ENDPOINT = '/hyperapi/bookmarks/dropdown/';
} let bookmarkMenuState = {};
export async function showBookmarkMenu(ev, sender, el) { export async function showBookmarkMenu(ev, sender, el) {
if (b.bookmarkMenuState.state === undefined) { if (bookmarkMenuState.state === undefined) {
el.addEventListener('toggle', e => { el.addEventListener('toggle', e => {
if (e.newState === 'closed') { if (e.newState === 'closed') {
b.bookmarkMenuState.state = 'closed'; bookmarkMenuState.state = 'closed';
} }
}); });
} }
// dismiss if open and last invoker is the same button that opened it // dismiss if open and last invoker is the same button that opened it
if (b.bookmarkMenuState.state === 'open' && b.bookmarkMenuState.invoker === sender) { if (bookmarkMenuState.state === 'open' && bookmarkMenuState.invoker === sender) {
el.hidePopover(); el.hidePopover();
return; return;
} }
b.bookmarkMenuState.invoker = sender; bookmarkMenuState.invoker = sender;
b.bookmarkMenuState.state = 'open'; bookmarkMenuState.state = 'open';
b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu'); b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu');
el.showPopover(); el.showPopover();
const bRect = sender.getBoundingClientRect(); const bRect = sender.getBoundingClientRect();
@@ -52,13 +52,13 @@ export async function showBookmarkMenu(ev, sender, el) {
} }
el.style.top = `${bRect.bottom + scrollY}px`; el.style.top = `${bRect.bottom + scrollY}px`;
b.bookmarkMenuState.kind = sender.dataset.conceptKind; bookmarkMenuState.kind = sender.dataset.conceptKind;
b.bookmarkMenuState.id = sender.dataset.conceptId; bookmarkMenuState.id = sender.dataset.conceptId;
const bookmarkCollections = await getHTML(b.bookmarksCollectionEndpoint, { const bookmarkCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
_query: { _query: {
concept_kind: b.bookmarkMenuState.kind, concept_kind: bookmarkMenuState.kind,
concept_id: b.bookmarkMenuState.id, concept_id: bookmarkMenuState.id,
} }
}); });
b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu'); b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu');
@@ -85,10 +85,10 @@ export async function bookmarkMenuSubmit(ev, _, el) {
return; return;
} }
const newCollections = await getHTML(b.bookmarksCollectionEndpoint, { const newCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
_query: { _query: {
concept_kind: b.bookmarkMenuState.kind, concept_kind: bookmarkMenuState.kind,
concept_id: b.bookmarkMenuState.id, concept_id: bookmarkMenuState.id,
saved: true, saved: true,
} }
}); });

View File

@@ -4,11 +4,24 @@ export const b = {
const POST_IMAGES_SELECTOR = 'img.post-image:not(aside img.post-image)' const POST_IMAGES_SELECTOR = 'img.post-image:not(aside img.post-image)'
const WHOAMI_ENDPOINT = '/api/whoami/' const WHOAMI_ENDPOINT = '/api/whoami/'
const THREAD_PERM_ENDPOINT = '/api/thread-permission/'
const TOGGLE_REACTION_ENDPOINT = '/api/toggle-reaction/'
const REPLACE_REACTIONS_ENDPOINT = '/hyperapi/reactions/'
const getThreadId = () => {
const scheme = window.location.pathname.split("/");
if (scheme[1] !== 'threads' || scheme[2] === 'new') {
return -1;
}
return parseInt(scheme[2]);
}
let images = []; let images = [];
let currentIndex = 0; let currentIndex = 0;
let currentUser = null; let currentUser = null;
let reactionMenuState = {};
export function activatePostImages(_, __, ___) { export function activatePostImages(_, __, ___) {
const images = document.querySelectorAll(POST_IMAGES_SELECTOR); const images = document.querySelectorAll(POST_IMAGES_SELECTOR);
images.forEach(image => { images.forEach(image => {
@@ -68,6 +81,13 @@ export function lightboxPrevious(_, __, ___) {
export async function getUserData(_, __, ___) { export async function getUserData(_, __, ___) {
currentUser = await b.getData(WHOAMI_ENDPOINT); currentUser = await b.getData(WHOAMI_ENDPOINT);
b.trigger('highlightMentions'); b.trigger('highlightMentions');
const d = (await b.getData(`${THREAD_PERM_ENDPOINT}${getThreadId()}`)).can_post;
if (d) {
b.trigger('enableReactionMenuButton');
b.trigger('enableReactionButtons');
} else {
b.trigger('disableReactionMenuButton');
}
} }
export function highlightMentions(_, __, el) { export function highlightMentions(_, __, el) {
@@ -77,3 +97,83 @@ export function highlightMentions(_, __, el) {
el.classList.add('me'); el.classList.add('me');
} }
} }
export function openReactionMenu(ev, sender, el) {
if (!el) return;
if (reactionMenuState.state === undefined) {
el.addEventListener('toggle', e => {
if (e.newState === 'closed') {
reactionMenuState.state = 'closed';
}
});
}
if (reactionMenuState.state === 'open' && reactionMenuState.invoker === sender) {
el.hidePopover();
return;
}
// TODO: [el, sender].prop(key) searches for ancestors with attr [data-${key}] if current element does not have `dataset[key]` but dataset transforms key names whereas css does not
reactionMenuState.post = sender.prop('postid');
reactionMenuState.invoker = sender;
reactionMenuState.state = 'open';
el.showPopover();
const bRect = sender.getBoundingClientRect();
const scrollY = window.scrollY;
el.style.left = `${bRect.left}px`;
el.style.top = `${bRect.bottom + scrollY}px`;
}
export function closeReactionMenu(_, __, el) {
el.hidePopover();
}
export async function toggleReaction(_, sender, __) {
const emoji = sender.dataset.emoji;
const post = sender.prop('postid') ? sender.prop('postid') : reactionMenuState.post;
const res = await fetch(TOGGLE_REACTION_ENDPOINT, {
method: 'POST',
body: JSON.stringify({ reaction: emoji, post: post }),
headers: {
'content-type': 'application/json',
},
});
if (res.status !== 200) {
return;
}
b.send({ postId: post }, 'replaceReactionButtons');
}
export async function replaceReactionButtons(payload, __, el) {
if (payload.postId !== el.prop('postid')) return;
const res = await fetch(`${REPLACE_REACTIONS_ENDPOINT}${payload.postId}`);
if (res.status !== 200) {
return;
}
const body = await res.text();
const p = new DOMParser();
const e = p.parseFromString(body, 'text/html').body;
el.replaceChildren(...e.children);
el.childNodes.forEach(b => {
if (!b instanceof HTMLButtonElement) return;
b.disabled = false;
})
}
export function disableReactionMenuButton(_, __, el) {
el.title = 'You do not have permission to add reactions to this post.';
}
export function enableReactionMenuButton(_, __, el) {
el.disabled = false;
el.title = '';
}
export function enableReactionButtons(_, __, el) {
el.disabled = false;
}

View File

@@ -1,8 +1,9 @@
export const b = { export const b = {
babycodePreviewEndpoint: '/api/babycode-preview/',
init: 'babycodeEditorCharCountInit localizeTimestamps', init: 'babycodeEditorCharCountInit localizeTimestamps',
} }
const BABYCODE_PREVIEW_ENDPOINT = '/api/babycode-preview/';
const getThreadId = () => { const getThreadId = () => {
const scheme = window.location.pathname.split("/"); const scheme = window.location.pathname.split("/");
if (scheme[1] !== 'threads' || scheme[2] === 'new') { if (scheme[1] !== 'threads' || scheme[2] === 'new') {
@@ -158,7 +159,7 @@ export async function babycodePreview(payload, _, el) {
}), }),
} }
const f = await fetch(b.babycodePreviewEndpoint, options); const f = await fetch(BABYCODE_PREVIEW_ENDPOINT, options);
try { try {
if (!f.ok) { if (!f.ok) {
console.error(f); console.error(f);