diff --git a/app/routes/api.py b/app/routes/api.py index b6f1d39..712cfd0 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1,10 +1,21 @@ from flask import Blueprint, request from ..auth import is_logged_in, hard_login_required, get_active_user 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.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/') @hard_login_required def babycode_preview(): @@ -31,3 +42,48 @@ def whoami(): 'username': user.username, '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/') +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)} diff --git a/app/routes/hyperapi.py b/app/routes/hyperapi.py index a4dfc4a..1a03cdc 100644 --- a/app/routes/hyperapi.py +++ b/app/routes/hyperapi.py @@ -1,6 +1,6 @@ from flask import Blueprint, render_template, request, url_for 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 bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/') @@ -133,3 +133,7 @@ def badge_editor(): badges = Badges.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) + +@bp.get('/reactions/') +def get_reaction_buttons(post_id): + return render_template('hyper/reaction_buttons.html', Reactions=Reactions, post_id=post_id) diff --git a/app/routes/posts.py b/app/routes/posts.py index 706ffe3..8d7bce9 100644 --- a/app/routes/posts.py +++ b/app/routes/posts.py @@ -35,6 +35,13 @@ def ownership_or_mod_required(view_func): return view_func(*args, **kwargs) return wrapper +@bp.get('//') +def post_by_id(post_id): + post = get_post_url(post_id, _anchor=True) + if not post: + abort(404) + return redirect(post) + @bp.get('//edit/') @login_required @ownership_required diff --git a/app/routes/users.py b/app/routes/users.py index c885bcf..7aff6b7 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -658,7 +658,6 @@ def save_badges(username): ('id', 'NOT IN', ids), ('user_id', '=', user.id), ]) - print(list(map(lambda x: x.id, deleted_badges))) with db.transaction(): for b in deleted_badges: diff --git a/app/templates/common/macros.html b/app/templates/common/macros.html index d53f58d..4c5bdfd 100644 --- a/app/templates/common/macros.html +++ b/app/templates/common/macros.html @@ -140,6 +140,19 @@ {%- 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 %} + +{%- endfor -%} +{%- endmacro %} + {% macro full_post( post, render_sig=true, is_latest=false, show_toolbar=true, is_editing=false, thread=none, @@ -218,19 +231,10 @@
{%- if show_reactions -%} - - {%- 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 %} - - {%- endfor -%} + + {{- reaction_buttons(post.id) -}} - {%- if is_logged_in() and allow_reacting -%}{%- endif -%} + {%- if is_logged_in() and allow_reacting -%}{%- endif -%} {%- elif is_editing -%} Cancel diff --git a/app/templates/hyper/reaction_buttons.html b/app/templates/hyper/reaction_buttons.html new file mode 100644 index 0000000..78535ee --- /dev/null +++ b/app/templates/hyper/reaction_buttons.html @@ -0,0 +1,2 @@ +{%- from 'common/macros.html' import reaction_buttons with context -%} +{{- reaction_buttons(post_id) -}} diff --git a/app/templates/threads/thread.html b/app/templates/threads/thread.html index 9238a81..7349893 100644 --- a/app/templates/threads/thread.html +++ b/app/templates/threads/thread.html @@ -65,7 +65,7 @@ {%- endcall -%}
{%- for post in posts -%} -
+
{{full_post(post)}}
{%- endfor -%} @@ -98,6 +98,13 @@
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%} +
+ {%- for emoji in REACTION_EMOJI -%} + + {%- endfor -%} +
+{%- endif -%} +{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}

Reply to "{{thread.title}}"

{{- babycode_editor_component() -}} diff --git a/data/static/css/style.css b/data/static/css/style.css index 2b79bb9..61d905a 100644 --- a/data/static/css/style.css +++ b/data/static/css/style.css @@ -696,6 +696,39 @@ details.inner { 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 { position: absolute; min-width: 400px; diff --git a/data/static/js/bits/bookmark-menu.js b/data/static/js/bits/bookmark-menu.js index 56a7216..4c4bb46 100644 --- a/data/static/js/bits/bookmark-menu.js +++ b/data/static/js/bits/bookmark-menu.js @@ -14,28 +14,28 @@ async function getHTML(endpoint, options = {}) { return { body: await res.text(), status: res.status }; } -export const b = { - bookmarksCollectionEndpoint: '/hyperapi/bookmarks/dropdown/', - bookmarkMenuState: {}, -} +export const b = {}; + +const BOOKMARKS_COLLECTION_ENDPOINT = '/hyperapi/bookmarks/dropdown/'; +let bookmarkMenuState = {}; export async function showBookmarkMenu(ev, sender, el) { - if (b.bookmarkMenuState.state === undefined) { + if (bookmarkMenuState.state === undefined) { el.addEventListener('toggle', e => { if (e.newState === 'closed') { - b.bookmarkMenuState.state = 'closed'; + bookmarkMenuState.state = 'closed'; } }); } // 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(); return; } - b.bookmarkMenuState.invoker = sender; - b.bookmarkMenuState.state = 'open'; + bookmarkMenuState.invoker = sender; + bookmarkMenuState.state = 'open'; b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu'); el.showPopover(); const bRect = sender.getBoundingClientRect(); @@ -52,13 +52,13 @@ export async function showBookmarkMenu(ev, sender, el) { } el.style.top = `${bRect.bottom + scrollY}px`; - b.bookmarkMenuState.kind = sender.dataset.conceptKind; - b.bookmarkMenuState.id = sender.dataset.conceptId; + bookmarkMenuState.kind = sender.dataset.conceptKind; + bookmarkMenuState.id = sender.dataset.conceptId; - const bookmarkCollections = await getHTML(b.bookmarksCollectionEndpoint, { + const bookmarkCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, { _query: { - concept_kind: b.bookmarkMenuState.kind, - concept_id: b.bookmarkMenuState.id, + concept_kind: bookmarkMenuState.kind, + concept_id: bookmarkMenuState.id, } }); b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu'); @@ -85,10 +85,10 @@ export async function bookmarkMenuSubmit(ev, _, el) { return; } - const newCollections = await getHTML(b.bookmarksCollectionEndpoint, { + const newCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, { _query: { - concept_kind: b.bookmarkMenuState.kind, - concept_id: b.bookmarkMenuState.id, + concept_kind: bookmarkMenuState.kind, + concept_id: bookmarkMenuState.id, saved: true, } }); diff --git a/data/static/js/bits/thread.js b/data/static/js/bits/thread.js index f4374fe..e4847f9 100644 --- a/data/static/js/bits/thread.js +++ b/data/static/js/bits/thread.js @@ -4,11 +4,24 @@ export const b = { const POST_IMAGES_SELECTOR = 'img.post-image:not(aside img.post-image)' 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 currentIndex = 0; let currentUser = null; +let reactionMenuState = {}; + export function activatePostImages(_, __, ___) { const images = document.querySelectorAll(POST_IMAGES_SELECTOR); images.forEach(image => { @@ -68,6 +81,13 @@ export function lightboxPrevious(_, __, ___) { export async function getUserData(_, __, ___) { currentUser = await b.getData(WHOAMI_ENDPOINT); 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) { @@ -77,3 +97,83 @@ export function highlightMentions(_, __, el) { 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; +} diff --git a/data/static/js/bits/ui.js b/data/static/js/bits/ui.js index 999f2c2..0fff4e4 100644 --- a/data/static/js/bits/ui.js +++ b/data/static/js/bits/ui.js @@ -1,8 +1,9 @@ export const b = { - babycodePreviewEndpoint: '/api/babycode-preview/', init: 'babycodeEditorCharCountInit localizeTimestamps', } +const BABYCODE_PREVIEW_ENDPOINT = '/api/babycode-preview/'; + const getThreadId = () => { const scheme = window.location.pathname.split("/"); 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 { if (!f.ok) { console.error(f);