diff --git a/app/assets/javascripts/comments.js b/app/assets/javascripts/comments.js index 155883ff6..e998ca3d5 100644 --- a/app/assets/javascripts/comments.js +++ b/app/assets/javascripts/comments.js @@ -19,17 +19,13 @@ $(() => { return $tgt.closest('.js-comment-thread-wrapper')[0] ?? null; }; - $(document).on('click', '.post--comments-thread.is-inline a', async (evt) => { - if (evt.ctrlKey) { return; } - - evt.preventDefault(); - - const $tgt = $(evt.target); - const $threadId = $tgt.data('thread'); - const wrapper = getCommentThreadWrapper($tgt); - - openThread(wrapper, $threadId); - }); + /** + * @param {HTMLElement} wrapper + * @returns {boolean} + */ + const isInlineCommentThread = (wrapper) => { + return !!wrapper.querySelector('[data-inline=true]'); + }; /** * @param {HTMLElement} wrapper @@ -45,8 +41,24 @@ $(() => { window.hljs && hljs.highlightAll(); } + $(document).on('click', '.post--comments-thread.is-inline a', async (evt) => { + if (evt.ctrlKey) { + return; // TODO: do we need this early exit? + } + + evt.preventDefault(); + + const $tgt = $(evt.target); + const $threadId = $tgt.data('thread'); + const wrapper = getCommentThreadWrapper($tgt); + + openThread(wrapper, $threadId); + }); + $(document).on('click', '.js-show-deleted-comments', (ev) => { - if (ev.ctrlKey) { return; } // do we really need it? + if (ev.ctrlKey) { + return; + } // do we really need it? ev.preventDefault(); @@ -80,7 +92,9 @@ $(() => { } if (isDeleted) { - $container.append(``); + $container.append( + `` + ); $container.addClass('is-deleted'); } @@ -171,8 +185,7 @@ $(() => { if (isDelete) { $comment.addClass('deleted-content'); $tgt.removeClass('js-comment-delete').addClass('js-comment-undelete').val('undelete'); - } - else { + } else { $comment.removeClass('deleted-content'); $tgt.removeClass('js-comment-undelete').addClass('js-comment-delete').val('delete'); } @@ -189,7 +202,7 @@ $(() => { const $modal = $($tgt.data('modal')); const resp = await QPixel.fetch(`/comments/thread/${threadId}/followers`, { - headers: { 'Accept': 'text/html' } + headers: { Accept: 'text/html' } }); const data = await resp.text(); @@ -197,28 +210,82 @@ $(() => { $modal.find('.js-follower-display').html(data); }); - $(document).on('click', '[class*=js--lock-thread] form', async (evt) => { - evt.preventDefault(); + $(document).on('click', '.js-archive-thread', async (ev) => { + ev.preventDefault(); - const $tgt = $(evt.target); - const threadID = $tgt.data("thread"); + const $tgt = $(ev.target); + const threadID = $tgt.data('thread'); - const data = await QPixel.lockThread(threadID); + const data = await QPixel.archiveThread(threadID); QPixel.handleJSONResponse(data, () => { - window.location.reload(); + const wrapper = getCommentThreadWrapper($tgt); + const inline = isInlineCommentThread(wrapper); + openThread(wrapper, threadID, { inline }); }); }); - $(document).on('click', '.js--restrict-thread, .js--unrestrict-thread', async (evt) => { + $(document).on('click', '.js-delete-thread', async (ev) => { + ev.preventDefault(); + + const $tgt = $(ev.target); + const threadID = $tgt.data('thread'); + + const data = await QPixel.deleteThread(threadID); + + QPixel.handleJSONResponse(data, () => { + const wrapper = getCommentThreadWrapper($tgt); + const inline = isInlineCommentThread(wrapper); + openThread(wrapper, threadID, { inline }); + }); + }); + + $(document).on('click', '.js-follow-thread', async (ev) => { + ev.preventDefault(); + + const $tgt = $(ev.target); + const threadID = $tgt.data('thread'); + + const data = await QPixel.followThread(threadID); + + QPixel.handleJSONResponse(data, () => { + const wrapper = getCommentThreadWrapper($tgt); + const inline = isInlineCommentThread(wrapper); + openThread(wrapper, threadID, { inline }); + }); + }); + + $(document).on('click', '.js-lock-thread', async (ev) => { + ev.preventDefault(); + + const $tgt = $(ev.target); + const threadID = $tgt.data('thread'); + const form = $tgt.closest(`form[data-thread=${threadID}]`).get(0); + + if (form instanceof HTMLFormElement) { + const { value: duration } = form.elements['duration'] ?? {}; + + const data = await QPixel.lockThread(threadID, duration ? Math.round(+duration) : void 0); + + QPixel.handleJSONResponse(data, () => { + const wrapper = getCommentThreadWrapper($tgt); + const inline = isInlineCommentThread(wrapper); + openThread(wrapper, threadID, { inline }); + }); + } else { + QPixel.createNotification('danger', 'Failed to find thread to lock'); + } + }); + + // TODO: split into individual handlers once unrestrict_thread is split + $(document).on('click', '.js--unrestrict-thread', async (evt) => { evt.preventDefault(); const $tgt = $(evt.target); - const threadID = $tgt.data("thread"); - const action = $tgt.data("action"); - const route = $tgt.hasClass("js--restrict-thread") ? 'restrict' : 'unrestrict'; + const threadID = $tgt.data('thread'); + const action = $tgt.data('action'); - const resp = await QPixel.fetchJSON(`/comments/thread/${threadID}/${route}`, { type: action }); + const resp = await QPixel.fetchJSON(`/comments/thread/${threadID}/unrestrict`, { type: action }); const data = await resp.json(); @@ -261,7 +328,7 @@ $(() => { const $item = $(ev.target).hasClass('item') ? $(ev.target) : $(ev.target).parents('.item'); const id = $item.data('user-id'); $tgt[0].selectionStart = caretPos - posInWord; - $tgt[0].selectionEnd = (caretPos - posInWord) + currentWord.length; + $tgt[0].selectionEnd = caretPos - posInWord + currentWord.length; QPixel.replaceSelection($tgt, `@#${id}`); popup.destroy(); $tgt.focus(); @@ -282,17 +349,20 @@ $(() => { pingable[`${threadId}-${postId}`] = await resp.json(); } - const items = Object.entries(pingable[`${threadId}-${postId}`]).filter((e) => { - return e[0].toLowerCase().startsWith(currentWord.substr(1).toLowerCase()); - }).map((e) => { - const username = e[0].replace(//g, '>'); - const id = e[1]; - return itemTemplate.clone().html(`${username} #${id}`) - .attr('data-user-id', id); - }); + const items = Object.entries(pingable[`${threadId}-${postId}`]) + .filter((e) => { + return e[0].toLowerCase().startsWith(currentWord.substr(1).toLowerCase()); + }) + .map((e) => { + const username = e[0].replace(//g, '>'); + const id = e[1]; + return itemTemplate + .clone() + .html(`${username} #${id}`) + .attr('data-user-id', id); + }); QPixel.Popup.getPopup(items, $tgt[0], callback); - } - else { + } else { QPixel.Popup.destroyAll(); } } @@ -306,8 +376,7 @@ $(() => { if ($thread.is(':hidden')) { $thread.show(); $thread.find('.js-comment-field').trigger('focus'); - } - else { + } else { $thread.hide(); } }); @@ -322,8 +391,7 @@ $(() => { if ($reply.is(':hidden')) { $reply.show(); $reply.find('.js-comment-field').trigger('focus'); - } - else { + } else { $reply.hide(); } }); @@ -358,9 +426,7 @@ $(() => { const shouldFollow = action === 'follow'; - const data = shouldFollow ? - await QPixel.followComments(postId) : - await QPixel.unfollowComments(postId); + const data = shouldFollow ? await QPixel.followComments(postId) : await QPixel.unfollowComments(postId); QPixel.handleJSONResponse(data, () => { target.dataset.action = shouldFollow ? 'unfollow' : 'follow'; diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index d06026b39..b38b6e1a5 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -451,6 +451,38 @@ window.QPixel = { return QPixel.parseJSONResponse(resp, 'Failed to vote'); }, + archiveThread: async (id) => { + const resp = await QPixel.fetchJSON(`/comments/thread/${id}/archive`, {}, { + headers: { 'Accept': 'application/json' }, + }); + + return QPixel.parseJSONResponse(resp, 'Failed to archive thread'); + }, + + deleteThread: async (id) => { + const resp = await QPixel.fetchJSON(`/comments/thread/${id}/delete`, {}, { + headers: { 'Accept': 'application/json' }, + }); + + return QPixel.parseJSONResponse(resp, 'Failed to delete thread'); + }, + + followThread: async (id) => { + const resp = await QPixel.fetchJSON(`/comments/thread/${id}/follow`, {}, { + headers: { 'Accept': 'application/json' }, + }); + + return QPixel.parseJSONResponse(resp, 'Failed to follow thread'); + }, + + lockThread: async (id, duration) => { + const resp = await QPixel.fetchJSON(`/comments/thread/${id}/lock`, { + duration, + }); + + return QPixel.parseJSONResponse(resp, 'Failed to lock thread'); + }, + deleteComment: async (id) => { const resp = await QPixel.fetchJSON(`/comments/${id}/delete`, {}, { headers: { 'Accept': 'application/json' }, @@ -495,14 +527,6 @@ window.QPixel = { return QPixel.parseJSONResponse(resp, 'Failed to unfollow post comments'); }, - lockThread: async (id) => { - const resp = await QPixel.fetchJSON(`/comments/thread/${id}/restrict`, { - type: 'lock' - }); - - return QPixel.parseJSONResponse(resp, 'Failed to lock thread'); - }, - renameTag: async (categoryId, tagId, name) => { const resp = await QPixel.fetchJSON(`/categories/${categoryId}/tags/${tagId}/rename`, { name }, { headers: { 'Accept': 'application/json' } diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 659693ef6..4126185ba 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -5,15 +5,23 @@ class CommentsController < ApplicationController before_action :set_comment, only: [:update, :destroy, :undelete, :show] before_action :set_post, only: [:create_thread, :post_follow, :post_unfollow] - before_action :set_thread, - only: [:create, :thread, :thread_content, :thread_rename, :thread_restrict, :thread_unrestrict, - :thread_followers] - + before_action :set_thread, only: [:create, + :thread, + :thread_content, + :thread_rename, + :archive_thread, + :delete_thread, + :follow_thread, + :lock_thread, + :thread_unrestrict, + :thread_followers] before_action :check_post_access, only: [:create_thread, :create] before_action :check_privilege, only: [:update, :destroy, :undelete] before_action :check_create_access, only: [:create_thread, :create] before_action :check_reply_access, only: [:create] - before_action :check_restrict_access, only: [:thread_restrict] + before_action :check_archive_thread_access, only: [:archive_thread] + before_action :check_delete_thread_access, only: [:delete_thread] + before_action :check_lock_thread_access, only: [:lock_thread] before_action :check_thread_access, only: [:thread, :thread_content, :thread_followers] before_action :check_unrestrict_access, only: [:thread_unrestrict] before_action :check_if_target_post_locked, only: [:create, :post_follow] @@ -219,47 +227,69 @@ def thread_rename redirect_to comment_thread_path(@comment_thread.id) end - def thread_restrict - case params[:type] - when 'lock' - lu = nil - unless params[:duration].blank? - lu = params[:duration].to_i.days.from_now - end - @comment_thread.update(locked: true, locked_by: current_user, locked_until: lu) - when 'archive' - @comment_thread.update(archived: true, archived_by: current_user) - when 'delete' - @comment_thread.update(deleted: true, deleted_by: current_user) - when 'follow' - ThreadFollower.create comment_thread: @comment_thread, user: current_user - else - return not_found! + def archive_thread + status = @comment_thread.update(archived: true, archived_by: current_user) + restrict_thread_response(@comment_thread, status) + end + + def delete_thread + status = @comment_thread.update(deleted: true, deleted_by: current_user) + restrict_thread_response(@comment_thread, status) + end + + def follow_thread + status = ThreadFollower.create(comment_thread: @comment_thread, user: current_user) + restrict_thread_response(@comment_thread, status) + end + + def lock_thread + lu = nil + unless params[:duration].blank? + lu = params[:duration].to_i.days.from_now + end + + status = @comment_thread.update(locked: true, locked_by: current_user, locked_until: lu) + restrict_thread_response(@comment_thread, status) + end + + def unarchive_thread + status = @comment_thread.update(archived: false, archived_by: nil, ever_archived_before: true) + restrict_thread_response(@comment_thread, status) + end + + def undelete_thread + if @comment_thread.deleted_by.at_least_moderator? && !current_user.at_least_moderator? + render json: { status: 'error', message: I18n.t('comments.errors.mod_only_undelete') } + return end + status = @comment_thread.update(deleted: false, deleted_by: nil) + restrict_thread_response(@comment_thread, status) + end + + def unfollow_thread + status = ThreadFollower.find_by(comment_thread: @comment_thread, user: current_user)&.destroy + restrict_thread_response(@comment_thread, status) + end - render json: { status: 'success' } + def unlock_thread + status = @comment_thread.update(locked: false, locked_by: nil, locked_until: nil) + restrict_thread_response(@comment_thread, status) end def thread_unrestrict + # TODO: remove this wrapper action entirely (callbacks need to be moved, routes assigned, etc) case params[:type] when 'lock' - @comment_thread.update(locked: false, locked_by: nil, locked_until: nil) + unlock_thread when 'archive' - @comment_thread.update(archived: false, archived_by: nil, ever_archived_before: true) + unarchive_thread when 'delete' - if @comment_thread.deleted_by.at_least_moderator? && !current_user.at_least_moderator? - render json: { status: 'error', message: I18n.t('comments.errors.mod_only_undelete') } - return - end - @comment_thread.update(deleted: false, deleted_by: nil) + undelete_thread when 'follow' - tf = ThreadFollower.find_by(comment_thread: @comment_thread, user: current_user) - tf&.destroy + unfollow_thread else - return not_found! + not_found! end - - render json: { status: 'success' } end def post @@ -364,18 +394,20 @@ def check_reply_access end end - def check_restrict_access - case params[:type] - when 'lock' - not_found! unless current_user.can_lock?(@comment_thread) - when 'archive' - not_found! unless current_user.can_archive?(@comment_thread) - when 'delete' - not_found! unless current_user.can_delete?(@comment_thread) - end + def check_archive_thread_access + not_found! unless current_user.can_archive?(@comment_thread) + end + + def check_delete_thread_access + not_found! unless current_user.can_delete?(@comment_thread) + end + + def check_lock_thread_access + not_found! unless current_user.can_lock?(@comment_thread) end def check_unrestrict_access + # TODO: split into individual checks once unrestrict_thread is split case params[:type] when 'lock' not_found! unless current_user.can_unlock?(@comment_thread) @@ -403,6 +435,18 @@ def check_for_pings(thread, content) matches.flatten.select { |m| pingable.include?(m.to_i) }.map(&:to_i) end + # @param thread [CommentThread] thread to get response for + # @param status [Boolean] status of the restrict operation + def restrict_thread_response(thread, status) + if status + render json: { status: 'success', thread: thread } + else + render json: { status: 'failed', + message: thread.errors.full_messages.join(', ') }, + status: :bad_request + end + end + # @param pings [Array] list of pinged user ids def apply_pings(pings) pings.each do |p| diff --git a/app/models/ability.rb b/app/models/ability.rb index a3160b57d..b43648f59 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -1,5 +1,6 @@ class Ability < ApplicationRecord include CommunityRelated + include AbilitiesHelper validates :internal_id, uniqueness: { scope: [:community_id], case_sensitive: false } @@ -7,6 +8,45 @@ def manual? post_score_threshold.nil? && edit_score_threshold.nil? && flag_score_threshold.nil? end + # Gets the edit score percent for a given user + # @param user [User, nil] user to get the percent for + # @return [Integer] edit score percent + def edit_score_percent_for(user) + return 0 if edit_score_threshold.nil? || user.nil? + return 100 if edit_score_threshold.zero? + + linear_score = linearize_progress(user.community_user.edit_score) + linear_threshold = linearize_progress(edit_score_threshold) + + (linear_score / linear_threshold * 100).to_i + end + + # Gets the flag score percent for a given user + # @param user [User, nil] user to get the percent for + # @return [Integer] flag score percent + def flag_score_percent_for(user) + return 0 if flag_score_threshold.nil? || user.nil? + return 100 if flag_score_threshold.zero? + + linear_score = linearize_progress(user.community_user.flag_score) + linear_threshold = linearize_progress(flag_score_threshold) + + (linear_score / linear_threshold * 100).to_i + end + + # Gets the post score percent for a given user + # @param user [User, nil] user to get the percent for + # @return [Integer] post score percent + def post_score_percent_for(user) + return 0 if post_score_threshold.nil? || user.nil? + return 100 if post_score_threshold.zero? + + linear_score = linearize_progress(user.community_user.post_score) + linear_threshold = linearize_progress(post_score_threshold) + + (linear_score / linear_threshold * 100).to_i + end + def self.on_user(user) Ability.where(id: UserAbility.where(community_user: user.community_user).select(:ability_id).distinct) end diff --git a/app/models/post.rb b/app/models/post.rb index 29dc6e497..4a9ac09c5 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -187,6 +187,12 @@ def closeable? post_type.is_closeable end + # Is the post deleted by the owner? + # @return [Boolean] check result + def deleted_by_owner? + deleted_by&.same_as?(user) + end + # @return [Boolean] whether there is a suggested edit pending for this post def pending_suggested_edit? SuggestedEdit.where(post_id: id, active: true).any? diff --git a/app/views/abilities/show.html.erb b/app/views/abilities/show.html.erb index 23ccdfb27..a2b0f84dd 100644 --- a/app/views/abilities/show.html.erb +++ b/app/views/abilities/show.html.erb @@ -51,11 +51,11 @@
<% if @user.id == current_user&.id %>

You need to reach these thresholds to earn the ability:

- <% else %> + <% else %>

<%= @user.username %> needs to reach these thresholds to earn the ability:

- <% end %> + <% end %> <% unless @ability.post_score_threshold.nil? %> - <% post_score_percent = (linearize_progress(@user.community_user.post_score) / linearize_progress(@ability.post_score_threshold) * 100).to_i %> + <% post_score_percent = @ability.post_score_percent_for(@user) %>

Post score threshold

<% if post_score_percent < 100 %> @@ -74,7 +74,7 @@ <% end %> <% unless @ability.edit_score_threshold.nil? %> - <% edit_score_percent = (linearize_progress(@user.community_user.edit_score) / linearize_progress(@ability.edit_score_threshold) * 100).to_i %> + <% edit_score_percent = @ability.edit_score_percent_for(@user) %>

Edit score threshold

<% if edit_score_percent < 100 %> @@ -93,7 +93,7 @@ <% end %> <% unless @ability.flag_score_threshold.nil? %> - <% flag_score_percent = (linearize_progress(@user.community_user.flag_score) / linearize_progress(@ability.flag_score_threshold) * 100).to_i %> + <% flag_score_percent = @ability.flag_score_percent_for(@user) %>

Flag score threshold

<% if flag_score_percent < 100 %> @@ -124,21 +124,21 @@ <% end %>
<% if @your_ability&.is_suspended && (@user.id == current_user&.id || current_user&.at_least_moderator?) %> -
-
- <% if @your_ability.suspension_end.nil? %> -

Your use of the <%= @ability.name %> ability has been suspended.

- <% else %> -

Your use of the <%= @ability.name %> ability has been temporarily suspended (ends in <%= time_ago_in_words(@your_ability.suspension_end) %>).

- <% end %> -

<%= @your_ability.suspension_message %>

-

If you have any questions regarding the site rules, you can ask them in the Meta category of this site or on meta.codidact.com. If you have any questions about this suspension or would like to dispute it, use the Meta category or contact us. -

+
+
+ <% if @your_ability.suspension_end.nil? %> +

Your use of the <%= @ability.name %> ability has been suspended.

+ <% else %> +

Your use of the <%= @ability.name %> ability has been temporarily suspended (ends in <%= time_ago_in_words(@your_ability.suspension_end) %>).

+ <% end %> +

<%= @your_ability.suspension_message %>

+

If you have any questions regarding the site rules, you can ask them in the Meta category of this site or on meta.codidact.com. If you have any questions about this suspension or would like to dispute it, use the Meta category or contact us. +

+
+ <% end %>
- <% end %> - -<% end %> + <% end %> -
- <%= raw @ability.description %> -
+
+ <%= raw @ability.description %> +
diff --git a/app/views/comments/_lock_thread_modal.html.erb b/app/views/comments/_lock_thread_modal.html.erb index ef756bf6a..c911f3c03 100644 --- a/app/views/comments/_lock_thread_modal.html.erb +++ b/app/views/comments/_lock_thread_modal.html.erb @@ -1,12 +1,14 @@ <%# - Helper for rendering comment thread lock action modal + "Helper for rendering comment thread lock action modal - Variables: - thread : Comment thread to create the modal for -%> + Variables: + thread : Comment thread to create the modal for +"%> +<%# TODO: eager load instead %> +<% host = post.community.host %> + <% post.reaction_list.each do |rt, rr| %>