diff --git a/app/components/favorites_tooltip_component.rb b/app/components/favorites_tooltip_component.rb new file mode 100644 index 000000000..bd69821b8 --- /dev/null +++ b/app/components/favorites_tooltip_component.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# This component represents the tooltip that displays when you hover over a post's favorite count. +class FavoritesTooltipComponent < ApplicationComponent + attr_reader :post, :current_user + + def initialize(post:, current_user:) + super + @post = post + @current_user = current_user + end + + def favorites + post.favorites.includes(:user).order(id: :desc) + end + + def favoriter_name(favorite) + if policy(favorite).can_see_favoriter? + link_to_user(favorite.user) + else + tag.i("hidden") + end + end +end diff --git a/app/components/favorites_tooltip_component/favorites_tooltip_component.html.erb b/app/components/favorites_tooltip_component/favorites_tooltip_component.html.erb new file mode 100644 index 000000000..13c1f5b32 --- /dev/null +++ b/app/components/favorites_tooltip_component/favorites_tooltip_component.html.erb @@ -0,0 +1,9 @@ +
+
+ <% favorites.each do |favorite| %> +
+ <%= favoriter_name(favorite) %> +
+ <% end %> +
+
diff --git a/app/components/favorites_tooltip_component/favorites_tooltip_component.js b/app/components/favorites_tooltip_component/favorites_tooltip_component.js new file mode 100644 index 000000000..918f61786 --- /dev/null +++ b/app/components/favorites_tooltip_component/favorites_tooltip_component.js @@ -0,0 +1,65 @@ +import Utility from "../../javascript/src/javascripts/utility.js"; +import { delegate, hideAll } from 'tippy.js'; +import 'tippy.js/dist/tippy.css'; + +class FavoritesTooltipComponent { + // Trigger on the post favcount link. + static TARGET_SELECTOR = "span.post-favcount a"; + static SHOW_DELAY = 125; + static HIDE_DELAY = 125; + static DURATION = 250; + static instance = null; + + static initialize() { + if ($(FavoritesTooltipComponent.TARGET_SELECTOR).length === 0) { + return; + } + + FavoritesTooltipComponent.instance = delegate("body", { + allowHTML: true, + appendTo: document.querySelector("#post-favorites-tooltips"), + delay: [FavoritesTooltipComponent.SHOW_DELAY, FavoritesTooltipComponent.HIDE_DELAY], + duration: FavoritesTooltipComponent.DURATION, + interactive: true, + maxWidth: "none", + target: FavoritesTooltipComponent.TARGET_SELECTOR, + theme: "common-tooltip", + touch: false, + + onShow: FavoritesTooltipComponent.onShow, + onHide: FavoritesTooltipComponent.onHide, + }); + } + + static async onShow(instance) { + let $target = $(instance.reference); + let $tooltip = $(instance.popper); + let postId = $target.parents("[data-id]").data("id"); + + hideAll({ exclude: instance }); + + try { + $tooltip.addClass("tooltip-loading"); + + instance._request = $.get(`/posts/${postId}/favorites?variant=tooltip`); + let html = await instance._request; + instance.setContent(html); + + $tooltip.removeClass("tooltip-loading"); + } catch (error) { + if (error.status !== 0 && error.statusText !== "abort") { + Utility.error(`Error displaying favorites for post #${postId} (error: ${error.status} ${error.statusText})`); + } + } + } + + static async onHide(instance) { + if (instance._request?.state() === "pending") { + instance._request.abort(); + } + } +} + +$(document).ready(FavoritesTooltipComponent.initialize); + +export default FavoritesTooltipComponent; diff --git a/app/components/favorites_tooltip_component/favorites_tooltip_component.scss b/app/components/favorites_tooltip_component/favorites_tooltip_component.scss new file mode 100644 index 000000000..ccdc2b5a1 --- /dev/null +++ b/app/components/favorites_tooltip_component/favorites_tooltip_component.scss @@ -0,0 +1,8 @@ +.favorites-tooltip { + font-size: var(--text-xs); + max-height: 240px; + + .post-favoriter { + max-width: 160px; + } +} diff --git a/app/components/popup_menu_component/popup_menu_component.scss b/app/components/popup_menu_component/popup_menu_component.scss index dcc28222f..83ac457ec 100644 --- a/app/components/popup_menu_component/popup_menu_component.scss +++ b/app/components/popup_menu_component/popup_menu_component.scss @@ -28,7 +28,7 @@ div.popup-menu { li a { display: block; - padding: 0.125em 2em 0.125em 0; + padding: 0.125em 0 0.125em 0; .icon { width: 1rem; diff --git a/app/components/post_preview_component.rb b/app/components/post_preview_component.rb index 838cfc729..8c9e7369a 100644 --- a/app/components/post_preview_component.rb +++ b/app/components/post_preview_component.rb @@ -3,18 +3,19 @@ class PostPreviewComponent < ApplicationComponent with_collection_parameter :post - attr_reader :post, :tags, :show_deleted, :show_cropped, :link_target, :pool, :similarity, :recommended, :compact, :size, :current_user, :options + attr_reader :post, :tags, :show_deleted, :show_cropped, :link_target, :pool, :similarity, :recommended, :show_votes, :compact, :size, :current_user, :options - delegate :external_link_to, :time_ago_in_words_tagged, :duration_to_hhmmss, :empty_heart_icon, :sound_icon, to: :helpers + delegate :external_link_to, :time_ago_in_words_tagged, :duration_to_hhmmss, :render_post_votes, :empty_heart_icon, :sound_icon, to: :helpers delegate :image_width, :image_height, :file_ext, :file_size, :duration, :is_animated?, to: :media_asset delegate :media_asset, to: :post - def initialize(post:, tags: "", show_deleted: false, show_cropped: true, link_target: post, pool: nil, similarity: nil, recommended: nil, compact: nil, size: nil, current_user: CurrentUser.user, **options) + def initialize(post:, tags: "", show_deleted: false, show_cropped: true, show_votes: false, link_target: post, pool: nil, similarity: nil, recommended: nil, compact: nil, size: nil, current_user: CurrentUser.user, **options) super @post = post @tags = tags.presence @show_deleted = show_deleted @show_cropped = show_cropped + @show_votes = show_votes @link_target = link_target @pool = pool @similarity = similarity.round(1) if similarity.present? diff --git a/app/components/post_preview_component/post_preview_component.html.erb b/app/components/post_preview_component/post_preview_component.html.erb index da2c7f1b9..49d90e5c7 100644 --- a/app/components/post_preview_component/post_preview_component.html.erb +++ b/app/components/post_preview_component/post_preview_component.html.erb @@ -23,8 +23,7 @@

<%= link_to pool.pretty_name.truncate(80), pool %>

- <% end -%> - <% if similarity -%> + <% elsif similarity -%>

<% if post.source =~ %r!\Ahttps?://!i %> <%= external_link_to post.normalized_source, post.source_domain %> @@ -33,20 +32,19 @@ <%= time_ago_in_words_tagged(post.created_at, compact: true) %> <% end %>

- <% end %> - <% if size -%>

<%= link_to number_to_human_size(size), post.file_url %> (<%= post.image_width %>x<%= post.image_height %>)

- <% end -%> - <% if similarity -%>

<%= link_to "#{similarity}%", iqdb_queries_path(post_id: post.id) %> similarity

- <% end -%> - - <% if recommended -%> + <% elsif size -%> +

+ <%= link_to number_to_human_size(size), post.file_url %> + (<%= post.image_width %>x<%= post.image_height %>) +

+ <% elsif recommended -%> + <% elsif show_votes -%> +
+ <%= render_post_votes post, current_user: current_user %> +
<% end -%> <% end -%> diff --git a/app/components/post_preview_component/post_preview_component.scss b/app/components/post_preview_component/post_preview_component.scss index 28315bcf6..c31586e0f 100644 --- a/app/components/post_preview_component/post_preview_component.scss +++ b/app/components/post_preview_component/post_preview_component.scss @@ -1,13 +1,10 @@ @import "../../javascript/src/styles/base/000_vars.scss"; article.post-preview { - height: 154px; - width: 154px; - margin: 0 10px 10px 0; text-align: center; display: inline-block; position: relative; - vertical-align: top; + overflow: hidden; a { display: inline-block; @@ -120,38 +117,16 @@ body[data-current-user-can-approve-posts="true"] .post-preview { } } -@media screen and (max-width: 660px) { +@media screen and (min-width: 660px) { article.post-preview { - margin: 0; - text-align: center; - vertical-align: middle; - display: inline-block; - - a { - margin: 0 auto; - } - - img { - max-width: 33.3vw; - max-height: 33.3vw; - border: none !important; - } - } - - .user-disable-cropped-false { - article.post-preview { - width: 33.3%; - height: 33.3vw; - overflow: hidden; - } - - img { - width: 33.3vw; - height: 33.3vw; - - &.has-cropped-false { - object-fit: cover; - } - } + width: 154px; + margin: 0 10px 10px 0; + vertical-align: top; + } +} + +@media screen and (max-width: 660px) { + article.post-preview img { + border: none !important; } } diff --git a/app/components/post_votes_component.rb b/app/components/post_votes_component.rb index 5734425f3..295c5ea1a 100644 --- a/app/components/post_votes_component.rb +++ b/app/components/post_votes_component.rb @@ -15,7 +15,7 @@ class PostVotesComponent < ApplicationComponent end def current_vote - post.votes.find_by(user: current_user) + post.vote_by_current_user end def upvoted? diff --git a/app/components/post_votes_component/post_votes_component.html.erb b/app/components/post_votes_component/post_votes_component.html.erb index f9186994a..65dfdd071 100644 --- a/app/components/post_votes_component/post_votes_component.html.erb +++ b/app/components/post_votes_component/post_votes_component.html.erb @@ -1,19 +1,21 @@ - <% if can_vote? %> - <% if upvoted? %> - <%= link_to upvote_icon, post_post_votes_path(post_id: post.id), class: "post-upvote-link post-unvote-link active-link", method: :delete, remote: true %> - <% else %> - <%= link_to upvote_icon, post_post_votes_path(post_id: post.id, score: 1), class: "post-upvote-link inactive-link", method: :post, remote: true %> - <% end %> + <% if current_user.is_anonymous? %> + <%= link_to upvote_icon, login_path(url: request.fullpath), class: "post-upvote-link inactive-link" %> + <% elsif upvoted? %> + <%= link_to upvote_icon, post_post_votes_path(post_id: post.id), class: "post-upvote-link post-unvote-link active-link", method: :delete, remote: true %> + <% else %> + <%= link_to upvote_icon, post_post_votes_path(post_id: post.id, score: 1), class: "post-upvote-link inactive-link", method: :post, remote: true %> <% end %> - <%= post.score %> + + <%= link_to post.score, post_votes_path(search: { post_id: post.id }, variant: :compact) %> + - <% if can_vote? %> - <% if downvoted? %> - <%= link_to downvote_icon, post_post_votes_path(post_id: post.id), class: "post-downvote-link post-unvote-link active-link", method: :delete, remote: true %> - <% else %> - <%= link_to downvote_icon, post_post_votes_path(post_id: post.id, score: -1), class: "post-downvote-link inactive-link", method: :post, remote: true %> - <% end %> + <% if current_user.is_anonymous? %> + <%= link_to downvote_icon, login_path(url: request.fullpath), class: "post-downvote-link inactive-link" %> + <% elsif downvoted? %> + <%= link_to downvote_icon, post_post_votes_path(post_id: post.id), class: "post-downvote-link post-unvote-link active-link", method: :delete, remote: true %> + <% else %> + <%= link_to downvote_icon, post_post_votes_path(post_id: post.id, score: -1), class: "post-downvote-link inactive-link", method: :post, remote: true %> <% end %> diff --git a/app/components/post_votes_component/post_votes_component.scss b/app/components/post_votes_component/post_votes_component.scss index 4d2fd99ac..b5abde985 100644 --- a/app/components/post_votes_component/post_votes_component.scss +++ b/app/components/post_votes_component/post_votes_component.scss @@ -1,3 +1,5 @@ +@import "../../javascript/src/styles/base/000_vars.scss"; + .post-votes { // Fix it so that the vote buttons don't move when the score changes width. // XXX duplicated from app/components/comment_component/comment_component.scss @@ -6,5 +8,12 @@ text-align: center; min-width: 1.25em; white-space: nowrap; + vertical-align: middle; + } +} + +.posts-container { + .post-score a { + @include inactive-link; } } diff --git a/app/components/post_votes_tooltip_component.rb b/app/components/post_votes_tooltip_component.rb new file mode 100644 index 000000000..b8b1f11a4 --- /dev/null +++ b/app/components/post_votes_tooltip_component.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# This component represents the tooltip that displays when you hover over a post's score. +class PostVotesTooltipComponent < ApplicationComponent + attr_reader :post, :current_user + delegate :upvote_icon, :downvote_icon, to: :helpers + + def initialize(post:, current_user:) + super + @post = post + @current_user = current_user + end + + def votes + @post.votes.includes(:user).order(id: :desc) + end + + def vote_icon(vote) + vote.is_positive? ? upvote_icon : downvote_icon + end + + def upvote_ratio + return nil if votes.length == 0 + sprintf("(%.1f%%)", 100.0 * votes.select(&:is_positive?).length / votes.length) + end + + def voter_name(vote) + if policy(vote).can_see_voter? + link_to_user(vote.user, classes: "align-middle") + else + tag.i("hidden", class: "align-middle") + end + end +end diff --git a/app/components/post_votes_tooltip_component/post_votes_tooltip_component.html.erb b/app/components/post_votes_tooltip_component/post_votes_tooltip_component.html.erb new file mode 100644 index 000000000..a50e1e3e0 --- /dev/null +++ b/app/components/post_votes_tooltip_component/post_votes_tooltip_component.html.erb @@ -0,0 +1,13 @@ +
+
+ +<%= post.up_score %> / -<%= post.down_score.abs %> <%= upvote_ratio %> +
+ +
+ <% votes.each do |vote| %> +
+ <%= vote_icon(vote) %> <%= voter_name(vote) %> +
+ <% end %> +
+
diff --git a/app/components/post_votes_tooltip_component/post_votes_tooltip_component.js b/app/components/post_votes_tooltip_component/post_votes_tooltip_component.js new file mode 100644 index 000000000..d9223d9ab --- /dev/null +++ b/app/components/post_votes_tooltip_component/post_votes_tooltip_component.js @@ -0,0 +1,65 @@ +import Utility from "../../javascript/src/javascripts/utility.js"; +import { delegate, hideAll } from 'tippy.js'; +import 'tippy.js/dist/tippy.css'; + +class PostVotesTooltipComponent { + // Trigger on the post score link; see PostVotesComponent. + static TARGET_SELECTOR = "span.post-votes span.post-score a"; + static SHOW_DELAY = 125; + static HIDE_DELAY = 125; + static DURATION = 250; + static instance = null; + + static initialize() { + if ($(PostVotesTooltipComponent.TARGET_SELECTOR).length === 0) { + return; + } + + PostVotesTooltipComponent.instance = delegate("body", { + allowHTML: true, + appendTo: document.querySelector("#post-votes-tooltips"), + delay: [PostVotesTooltipComponent.SHOW_DELAY, PostVotesTooltipComponent.HIDE_DELAY], + duration: PostVotesTooltipComponent.DURATION, + interactive: true, + maxWidth: "none", + target: PostVotesTooltipComponent.TARGET_SELECTOR, + theme: "common-tooltip", + touch: false, + + onShow: PostVotesTooltipComponent.onShow, + onHide: PostVotesTooltipComponent.onHide, + }); + } + + static async onShow(instance) { + let $target = $(instance.reference); + let $tooltip = $(instance.popper); + let postId = $target.parents("[data-id]").data("id"); + + hideAll({ exclude: instance }); + + try { + $tooltip.addClass("tooltip-loading"); + + instance._request = $.get(`/post_votes?search[post_id]=${postId}`, { variant: "tooltip" }); + let html = await instance._request; + instance.setContent(html); + + $tooltip.removeClass("tooltip-loading"); + } catch (error) { + if (error.status !== 0 && error.statusText !== "abort") { + Utility.error(`Error displaying votes for post #${postId} (error: ${error.status} ${error.statusText})`); + } + } + } + + static async onHide(instance) { + if (instance._request?.state() === "pending") { + instance._request.abort(); + } + } +} + +$(document).ready(PostVotesTooltipComponent.initialize); + +export default PostVotesTooltipComponent; diff --git a/app/components/post_votes_tooltip_component/post_votes_tooltip_component.scss b/app/components/post_votes_tooltip_component/post_votes_tooltip_component.scss new file mode 100644 index 000000000..2cc839b5d --- /dev/null +++ b/app/components/post_votes_tooltip_component/post_votes_tooltip_component.scss @@ -0,0 +1,16 @@ +.post-votes-tooltip { + font-size: var(--text-xs); + max-height: 240px; + + .upvote-icon { + color: var(--post-upvote-color); + } + + .downvote-icon { + color: var(--post-downvote-color); + } + + .post-voter { + max-width: 160px; + } +} diff --git a/app/components/tag_list_component.rb b/app/components/tag_list_component.rb index 9c5f3abd3..03a88f977 100644 --- a/app/components/tag_list_component.rb +++ b/app/components/tag_list_component.rb @@ -1,15 +1,16 @@ # frozen_string_literal: true class TagListComponent < ApplicationComponent - attr_reader :tags, :current_query, :show_extra_links + attr_reader :tags, :current_query, :show_extra_links, :search_params delegate :humanized_number, to: :helpers - def initialize(tags: [], current_query: nil, show_extra_links: false) + def initialize(tags: [], current_query: nil, show_extra_links: false, search_params: {}) super @tags = tags @current_query = current_query @show_extra_links = show_extra_links + @search_params = search_params end def self.tags_from_names(tag_names) diff --git a/app/components/tag_list_component/tag_list_component.html+search.erb b/app/components/tag_list_component/tag_list_component.html+search.erb index 72888f6e5..1f648db46 100644 --- a/app/components/tag_list_component/tag_list_component.html+search.erb +++ b/app/components/tag_list_component/tag_list_component.html+search.erb @@ -11,11 +11,11 @@ <% end %> <% if show_extra_links && current_query.present? %> - <%= link_to "+", posts_path(tags: "#{current_query} #{t.name}"), class: "search-inc-tag" %> - <%= link_to "-", posts_path(tags: "#{current_query} -#{t.name}"), class: "search-exl-tag" %> + <%= link_to "+", posts_path(tags: "#{current_query} #{t.name}", **search_params), class: "search-inc-tag" %> + <%= link_to "-", posts_path(tags: "#{current_query} -#{t.name}", **search_params), class: "search-exl-tag" %> <% end %> - <%= link_to t.pretty_name, posts_path(tags: t.name), class: "search-tag" %> + <%= link_to t.pretty_name, posts_path(tags: t.name, **search_params), class: "search-tag" %> <%= tag.span humanized_number(t.post_count), class: "post-count", title: t.post_count %> <% end %> diff --git a/app/controllers/favorite_groups_controller.rb b/app/controllers/favorite_groups_controller.rb index 3f3b02f9e..f221e97e5 100644 --- a/app/controllers/favorite_groups_controller.rb +++ b/app/controllers/favorite_groups_controller.rb @@ -19,7 +19,7 @@ class FavoriteGroupsController < ApplicationController end def new - @favorite_group = authorize FavoriteGroup.new + @favorite_group = authorize FavoriteGroup.new(creator: CurrentUser.user) respond_with(@favorite_group) end diff --git a/app/controllers/favorites_controller.rb b/app/controllers/favorites_controller.rb index cd740ca72..40378df79 100644 --- a/app/controllers/favorites_controller.rb +++ b/app/controllers/favorites_controller.rb @@ -1,19 +1,16 @@ class FavoritesController < ApplicationController - respond_to :html, :xml, :json, :js + respond_to :js, :json, :html, :xml def index - authorize Favorite - if !request.format.html? - @favorites = Favorite.visible(CurrentUser.user).paginated_search(params) - respond_with(@favorites) - elsif params[:user_id].present? - user = User.find(params[:user_id]) - redirect_to posts_path(tags: "ordfav:#{user.name}", format: request.format.symbol) - elsif !CurrentUser.is_anonymous? - redirect_to posts_path(tags: "ordfav:#{CurrentUser.user.name}", format: request.format.symbol) - else - redirect_to posts_path(format: request.format.symbol) - end + post_id = params[:post_id] || params[:search][:post_id] + user_id = params[:user_id] || params[:search][:user_id] + user_name = params[:search][:user_name] + @post = Post.find(post_id) if post_id + @user = User.find(user_id) if user_id + @user = User.find_by_name(user_name) if user_name + + @favorites = authorize Favorite.visible(CurrentUser.user).paginated_search(params, defaults: { post_id: @post&.id, user_id: @user&.id }) + respond_with(@favorites) end def create diff --git a/app/controllers/post_votes_controller.rb b/app/controllers/post_votes_controller.rb index c21c1d102..24484deaa 100644 --- a/app/controllers/post_votes_controller.rb +++ b/app/controllers/post_votes_controller.rb @@ -4,6 +4,7 @@ class PostVotesController < ApplicationController def index @post_votes = authorize PostVote.visible(CurrentUser.user).paginated_search(params, count_pages: true) @post_votes = @post_votes.includes(:user, post: [:uploader, :media_asset]) if request.format.html? + @post = Post.find(params.dig(:search, :post_id)) if params.dig(:search, :post_id).present? respond_with(@post_votes) end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 12c212dce..7ed34a00e 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -10,7 +10,7 @@ class PostsController < ApplicationController end else tag_query = params[:tags] || params.dig(:post, :tags) - @post_set = PostSets::Post.new(tag_query, params[:page], params[:limit], random: params[:random], format: params[:format]) + @post_set = PostSets::Post.new(tag_query, params[:page], params[:limit], random: params[:random], format: params[:format], view: params[:view]) @posts = authorize @post_set.posts, policy_class: PostPolicy @post_set.log! respond_with(@posts) do |format| diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d8a27e2d0..f4261c06a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -193,10 +193,10 @@ module ApplicationHelper to_sentence(links, **options) end - def link_to_user(user, text = nil) + def link_to_user(user, text = nil, classes: nil, **options) return "anonymous" if user.blank? - user_class = "user user-#{user.level_string.downcase}" + user_class = "user user-#{user.level_string.downcase} #{classes}" user_class += " user-post-approver" if user.can_approve_posts? user_class += " user-post-uploader" if user.can_upload_free? user_class += " user-banned" if user.is_banned? diff --git a/app/helpers/components_helper.rb b/app/helpers/components_helper.rb index 9191c17b9..ade8022d7 100644 --- a/app/helpers/components_helper.rb +++ b/app/helpers/components_helper.rb @@ -20,6 +20,14 @@ module ComponentsHelper render PostVotesComponent.new(post: post, **options) end + def render_post_votes_tooltip(post, **options) + render PostVotesTooltipComponent.new(post: post, **options) + end + + def render_favorites_tooltip(post, **options) + render FavoritesTooltipComponent.new(post: post, **options) + end + def render_post_navbar(post, **options) render PostNavbarComponent.new(post: post, **options) end diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 7daf6f1cd..acfb2096a 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -37,12 +37,14 @@ import Blacklist from "../src/javascripts/blacklists.js"; import CommentComponent from "../../components/comment_component/comment_component.js"; import CurrentUser from "../src/javascripts/current_user.js"; import Dtext from "../src/javascripts/dtext.js"; +import FavoritesTooltipComponent from "../../components/favorites_tooltip_component/favorites_tooltip_component.js"; import IqdbQuery from "../src/javascripts/iqdb_queries.js"; import Note from "../src/javascripts/notes.js"; import PopupMenuComponent from "../../components/popup_menu_component/popup_menu_component.js"; import Post from "../src/javascripts/posts.js"; import PostModeMenu from "../src/javascripts/post_mode_menu.js"; import PostTooltip from "../src/javascripts/post_tooltips.js"; +import PostVotesTooltipComponent from "../../components/post_votes_tooltip_component/post_votes_tooltip_component.js"; import RelatedTag from "../src/javascripts/related_tag.js"; import Shortcuts from "../src/javascripts/shortcuts.js"; import TagCounter from "../src/javascripts/tag_counter.js"; @@ -58,12 +60,14 @@ Danbooru.Blacklist = Blacklist; Danbooru.CommentComponent = CommentComponent; Danbooru.CurrentUser = CurrentUser; Danbooru.Dtext = Dtext; +Danbooru.FavoritesTooltipComponent = FavoritesTooltipComponent; Danbooru.IqdbQuery = IqdbQuery; Danbooru.Note = Note; Danbooru.PopupMenuComponent = PopupMenuComponent; Danbooru.Post = Post; Danbooru.PostModeMenu = PostModeMenu; Danbooru.PostTooltip = PostTooltip; +Danbooru.PostVotesTooltipComponent = PostVotesTooltipComponent; Danbooru.RelatedTag = RelatedTag; Danbooru.Shortcuts = Shortcuts; Danbooru.TagCounter = TagCounter; diff --git a/app/javascript/src/javascripts/posts.js b/app/javascript/src/javascripts/posts.js index dc658c2a2..85fc8f070 100644 --- a/app/javascript/src/javascripts/posts.js +++ b/app/javascript/src/javascripts/posts.js @@ -30,7 +30,6 @@ Post.initialize_all = function() { if ($("#c-posts").length && $("#a-show").length) { this.initialize_links(); this.initialize_post_relationship_previews(); - this.initialize_favlist(); this.initialize_post_sections(); this.initialize_post_image_resize_links(); this.initialize_recommended(); @@ -242,13 +241,6 @@ Post.toggle_relationship_preview = function(preview, preview_link) { } } -Post.initialize_favlist = function() { - $("#show-favlist-link, #hide-favlist-link").on("click.danbooru", function(e) { - $("#favlist, #show-favlist-link, #hide-favlist-link").toggle(); - e.preventDefault(); - }); -} - Post.view_original = function(e = null) { if (Utility.test_max_width(660)) { // Do the default behavior (navigate to image) diff --git a/app/javascript/src/styles/base/020_base.scss b/app/javascript/src/styles/base/020_base.scss index ae17ce8c0..1672b74d4 100644 --- a/app/javascript/src/styles/base/020_base.scss +++ b/app/javascript/src/styles/base/020_base.scss @@ -98,7 +98,6 @@ menu { > li { margin: 0; - padding: 0 0.2em; display: inline; } } diff --git a/app/javascript/src/styles/base/040_colors.css b/app/javascript/src/styles/base/040_colors.css index 8aca7d161..ea7860aa3 100644 --- a/app/javascript/src/styles/base/040_colors.css +++ b/app/javascript/src/styles/base/040_colors.css @@ -218,6 +218,9 @@ html { --post-artist-commentary-container-background: var(--grey-0); --post-artist-commentary-container-border-color: var(--grey-1); + --post-upvote-color: var(--link-color); + --post-downvote-color: var(--red-5); + --note-body-background: #FFE; --note-body-text-color: var(--black); --note-body-border-color: var(--black); @@ -418,6 +421,9 @@ body[data-current-user-theme="dark"] { --post-artist-commentary-container-background: var(--grey-8); --post-artist-commentary-container-border-color: var(--grey-7); + --post-upvote-color: var(--link-color); + --post-downvote-color: var(--red-4); + --unsaved-note-box-border-color: var(--red-5); --movable-note-box-border-color: var(--blue-5); --note-preview-border-color: var(--red-5); diff --git a/app/javascript/src/styles/common/utilities.scss b/app/javascript/src/styles/common/utilities.scss index 2b5a2f4e7..fd09c2cc2 100644 --- a/app/javascript/src/styles/common/utilities.scss +++ b/app/javascript/src/styles/common/utilities.scss @@ -22,6 +22,14 @@ $spacer: 0.25rem; /* 4px */ .text-xs { font-size: var(--text-xs); } .text-sm { font-size: var(--text-sm); } +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.whitespace-nowrap { white-space: nowrap; } + .leading-none { line-height: 1; } .absolute { position: absolute; } @@ -39,6 +47,7 @@ $spacer: 0.25rem; /* 4px */ .mx-0\.5 { margin-left: 0.5 * $spacer; margin-right: 0.5 * $spacer; } .mx-2 { margin-left: 2 * $spacer; margin-right: 2 * $spacer; } +.mt-1 { margin-top: 1 * $spacer; } .mt-2 { margin-top: 2 * $spacer; } .mt-4 { margin-top: 4 * $spacer; } .mt-8 { margin-top: 8 * $spacer; } @@ -52,6 +61,9 @@ $spacer: 0.25rem; /* 4px */ .p-0\.5 { padding: 0.5 * $spacer; } .p-4 { padding: 4 * $spacer; } +.pr-2 { padding-right: 2 * $spacer; } +.pr-4 { padding-right: 4 * $spacer; } + .w-1\/4 { width: 25%; } .w-full { width: 100%; } @@ -73,6 +85,62 @@ $spacer: 0.25rem; /* 4px */ .items-center { align-items: center; } .justify-center { justify-content: center; } +.float-right { float: right; } + +.thin-scrollbar { + overflow-x: hidden; + overflow-y: auto; + padding-right: 2 * $spacer; + overscroll-behavior: contain; // https://caniuse.com/css-overscroll-behavior + + // Firefox only + // https://caniuse.com/?search=scrollbar-width + // https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 5px; + height: 5px; + } + + &::-webkit-scrollbar-button { + width: 0; + height: 0; + } + + &::-webkit-scrollbar-thumb { + background: var(--post-tooltip-scrollbar-background); + border: none; + border-radius: 0; + } + + &::-webkit-scrollbar-thumb:hover { + background: var(--post-tooltip-scrollbar-thumb-color); + } + + &::-webkit-scrollbar-thumb:active { + background: var(--post-tooltip-scrollbar-thumb-color); + } + + &::-webkit-scrollbar-track { + background: var(--post-tooltip-scrollbar-track-background); + border: none; + border-radius: 0; + } + + &::-webkit-scrollbar-track:hover { + background: var(--post-tooltip-scrollbar-track-background); + } + + &::-webkit-scrollbar-track:active { + background: var(--post-tooltip-scrollbar-track-background); + } + + &::-webkit-scrollbar-corner { + background: transparent; + } +} + @media screen and (min-width: 660px) { .md\:inline-block { display: inline-block; } .md\:flex { display: flex; } diff --git a/app/javascript/src/styles/specific/post_tooltips.scss b/app/javascript/src/styles/specific/post_tooltips.scss index 7d947c54c..c23ddfcc3 100644 --- a/app/javascript/src/styles/specific/post_tooltips.scss +++ b/app/javascript/src/styles/specific/post_tooltips.scss @@ -1,50 +1,6 @@ $tooltip-line-height: 16px; $tooltip-body-height: $tooltip-line-height * 4; // 4 lines high. -@mixin thin-scrollbar { - &::-webkit-scrollbar { - width: 5px; - height: 5px; - } - - &::-webkit-scrollbar-button { - width: 0; - height: 0; - } - - &::-webkit-scrollbar-thumb { - background: var(--post-tooltip-scrollbar-background); - border: none; - border-radius: 0; - } - - &::-webkit-scrollbar-thumb:hover { - background: var(--post-tooltip-scrollbar-thumb-color); - } - - &::-webkit-scrollbar-thumb:active { - background: var(--post-tooltip-scrollbar-thumb-color); - } - - &::-webkit-scrollbar-track { - background: var(--post-tooltip-scrollbar-track-background); - border: none; - border-radius: 0; - } - - &::-webkit-scrollbar-track:hover { - background: var(--post-tooltip-scrollbar-track-background); - } - - &::-webkit-scrollbar-track:active { - background: var(--post-tooltip-scrollbar-track-background); - } - - &::-webkit-scrollbar-corner { - background: transparent; - } -} - .tippy-box[data-theme~="post-tooltip"] { min-width: 20em; max-width: 40em !important; @@ -59,7 +15,6 @@ $tooltip-body-height: $tooltip-line-height * 4; // 4 lines high. } .post-tooltip-body { - @include thin-scrollbar; max-height: $tooltip-body-height; overflow-y: auto; display: flex; diff --git a/app/javascript/src/styles/specific/posts.scss b/app/javascript/src/styles/specific/posts.scss index a8eb4a795..460f1de71 100644 --- a/app/javascript/src/styles/specific/posts.scss +++ b/app/javascript/src/styles/specific/posts.scss @@ -135,8 +135,6 @@ div#c-posts { font-size: var(--text-lg); li { - padding: 0 1em 0 0; - &.active { font-weight: bold; } @@ -172,10 +170,6 @@ div#c-posts { } } - #favlist { - word-wrap: break-word; - } - #recommended.loading-recommended-posts { pointer-events: none; opacity: 0.5; diff --git a/app/javascript/src/styles/specific/z_responsive.scss b/app/javascript/src/styles/specific/z_responsive.scss index 80da5b38f..4bb6cc934 100644 --- a/app/javascript/src/styles/specific/z_responsive.scss +++ b/app/javascript/src/styles/specific/z_responsive.scss @@ -41,11 +41,13 @@ } } - #posts #posts-container { - width: 100%; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-start; + .posts-container { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.25rem; + + &.user-disable-cropped-false article.post-preview img.has-cropped-true { + object-fit: none; + } } } diff --git a/app/logical/concerns/has_bit_flags.rb b/app/logical/concerns/has_bit_flags.rb index c05b6d54a..9364c042f 100644 --- a/app/logical/concerns/has_bit_flags.rb +++ b/app/logical/concerns/has_bit_flags.rb @@ -6,6 +6,7 @@ module HasBitFlags def has_bit_flags(attributes, field: :bit_flags) attributes.each.with_index do |attribute, i| bit_flag = 1 << i + field_was = "#{field}_was" define_method(attribute) do send(field) & bit_flag > 0 @@ -15,6 +16,14 @@ module HasBitFlags send(field) & bit_flag > 0 end + define_method("#{attribute}_was") do + send(field_was) & bit_flag > 0 + end + + define_method("#{attribute}_was?") do + send(field_was) & bit_flag > 0 + end + define_method("#{attribute}=") do |val| if val.to_s =~ /[t1y]/ send("#{field}=", send(field) | bit_flag) diff --git a/app/logical/post_sets/post.rb b/app/logical/post_sets/post.rb index e292ed1dd..34f576e93 100644 --- a/app/logical/post_sets/post.rb +++ b/app/logical/post_sets/post.rb @@ -7,10 +7,10 @@ module PostSets MAX_PER_PAGE = 200 MAX_SIDEBAR_TAGS = 25 - attr_reader :page, :random, :format, :tag_string, :query, :normalized_query + attr_reader :page, :random, :format, :tag_string, :query, :normalized_query, :view delegate :post_count, to: :normalized_query - def initialize(tags, page = 1, per_page = nil, user: CurrentUser.user, random: false, format: "html") + def initialize(tags, page = 1, per_page = nil, user: CurrentUser.user, random: false, format: "html", view: "simple") @query = PostQueryBuilder.new(tags, user, tag_limit: user.tag_query_limit, safe_mode: CurrentUser.safe_mode?, hide_deleted_posts: user.hide_deleted_posts?) @normalized_query = query.normalized_query @tag_string = tags @@ -18,6 +18,7 @@ module PostSets @per_page = per_page @random = random.to_s.truthy? @format = format.to_s + @view = view.presence || "simple" end def humanized_tag_string @@ -107,13 +108,14 @@ module PostSets if is_random? get_random_posts.paginate(page, search_count: false, limit: per_page, max_limit: max_per_page).load else - normalized_query.paginated_posts(page, includes: :media_asset, count: post_count, search_count: !post_count.nil?, limit: per_page, max_limit: max_per_page).load + normalized_query.paginated_posts(page, includes: includes, count: post_count, search_count: !post_count.nil?, limit: per_page, max_limit: max_per_page).load end end end def hide_from_crawler? return true if current_page > 50 + return true if show_votes? return true if artist.present? && artist.is_banned? return false if query.is_empty_search? || query.is_simple_tag? || query.is_metatag?(:order, :rank) true @@ -139,6 +141,18 @@ module PostSets end end + def show_votes? + view == "score" + end + + def includes + if show_votes? + [:media_asset, :vote_by_current_user] + else + [:media_asset] + end + end + def search_stats { query: normalized_query.to_s, diff --git a/app/models/favorite.rb b/app/models/favorite.rb index aa1c63aa0..6f8d472c2 100644 --- a/app/models/favorite.rb +++ b/app/models/favorite.rb @@ -6,10 +6,16 @@ class Favorite < ApplicationRecord after_create :upvote_post_on_create after_destroy :unvote_post_on_destroy - scope :public_favorites, -> { where(user: User.bit_prefs_match(:enable_private_favorites, false)) } + scope :public_favorites, -> { where(user: User.has_public_favorites) } def self.visible(user) - user.is_admin? ? all : where(user: user).or(public_favorites) + if user.is_admin? + all + elsif user.is_anonymous? + public_favorites + else + where(user: user).or(public_favorites) + end end def self.search(params) diff --git a/app/models/favorite_group.rb b/app/models/favorite_group.rb index 2cbaacc05..eedde972d 100644 --- a/app/models/favorite_group.rb +++ b/app/models/favorite_group.rb @@ -10,6 +10,7 @@ class FavoriteGroup < ApplicationRecord validate :creator_can_create_favorite_groups, :on => :create validate :validate_number_of_posts validate :validate_posts + validate :validate_can_enable_privacy array_attribute :post_ids, parse: /\d+/, cast: :to_i @@ -83,6 +84,12 @@ class FavoriteGroup < ApplicationRecord end end + def validate_can_enable_privacy + if is_public_change == [true, false] && !Pundit.policy!(creator, self).can_enable_privacy? + errors.add(:base, "Can't enable privacy without a Gold account") + end + end + def self.normalize_name(name) name.gsub(/[[:space:]]+/, "_") end @@ -112,7 +119,7 @@ class FavoriteGroup < ApplicationRecord end def pretty_name - name.tr("_", " ") + name&.tr("_", " ") end def posts @@ -166,6 +173,18 @@ class FavoriteGroup < ApplicationRecord post_ids.include?(post_id) end + def is_private=(value) + self.is_public = !ActiveModel::Type::Boolean.new.cast(value) + end + + def is_private + !is_public? + end + + def is_private? + !is_public? + end + def self.available_includes [:creator] end diff --git a/app/models/post.rb b/app/models/post.rb index 7d803790c..4ac77452d 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -40,6 +40,7 @@ class Post < ApplicationRecord has_one :upload, :dependent => :destroy has_one :artist_commentary, :dependent => :destroy has_one :pixiv_ugoira_frame_data, class_name: "PixivUgoiraFrameData", foreign_key: :md5, primary_key: :md5 + has_one :vote_by_current_user, -> { where(user_id: CurrentUser.id) }, class_name: "PostVote" # XXX using current user here is wrong has_many :flags, :class_name => "PostFlag", :dependent => :destroy has_many :appeals, :class_name => "PostAppeal", :dependent => :destroy has_many :votes, :class_name => "PostVote", :dependent => :destroy @@ -49,7 +50,6 @@ class Post < ApplicationRecord has_many :approvals, :class_name => "PostApproval", :dependent => :destroy has_many :disapprovals, :class_name => "PostDisapproval", :dependent => :destroy has_many :favorites, dependent: :destroy - has_many :favorited_users, through: :favorites, source: :user has_many :replacements, class_name: "PostReplacement", :dependent => :destroy attr_accessor :old_tag_string, :old_parent_id, :old_source, :old_rating, :has_constraints, :disable_versioning @@ -548,9 +548,11 @@ class Post < ApplicationRecord pool&.add!(self) when /^fav:(.+)$/i + raise User::PrivilegeError unless Pundit.policy!(CurrentUser.user, Favorite).create? Favorite.create(post: self, user: CurrentUser.user) when /^-fav:(.+)$/i + raise User::PrivilegeError unless Pundit.policy!(CurrentUser.user, Favorite).create? Favorite.destroy_by(post: self, user: CurrentUser.user) when /^(up|down)vote:(.+)$/i @@ -666,13 +668,6 @@ class Post < ApplicationRecord Favorite.exists?(post: self, user: user) end - # Users who publicly favorited this post, ordered by time of favorite. - def visible_favorited_users(viewer) - favorited_users.order("favorites.id DESC").select do |fav_user| - Pundit.policy!(viewer, fav_user).can_see_favorites? - end - end - def favorite_groups FavoriteGroup.for_post(id) end diff --git a/app/models/post_vote.rb b/app/models/post_vote.rb index 4a571344c..777e55427 100644 --- a/app/models/post_vote.rb +++ b/app/models/post_vote.rb @@ -10,13 +10,15 @@ class PostVote < ApplicationRecord scope :positive, -> { where("post_votes.score > 0") } scope :negative, -> { where("post_votes.score < 0") } + scope :public_votes, -> { positive.where(user: User.has_public_favorites) } def self.visible(user) - user.is_admin? ? all : where(user: user) + user.is_admin? ? all : where(user: user).or(public_votes) end def self.search(params) q = search_attributes(params, :id, :created_at, :updated_at, :score, :user, :post) + q.apply_default_order(params) end diff --git a/app/models/user.rb b/app/models/user.rb index 0281fbe59..74b21d966 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -103,6 +103,7 @@ class User < ApplicationRecord validates :per_page, inclusion: { in: (1..PostSets::Post::MAX_PER_PAGE) } validates :password, confirmation: true validates :comment_threshold, inclusion: { in: (-100..5) } + validate :validate_enable_private_favorites, on: :update before_validation :normalize_blacklisted_tags before_create :promote_to_owner_if_first_user has_many :artist_versions, foreign_key: :updater_id @@ -152,6 +153,8 @@ class User < ApplicationRecord scope :admins, -> { where(level: Levels::ADMIN) } scope :has_blacklisted_tag, ->(name) { where_regex(:blacklisted_tags, "(^| )[~-]?#{Regexp.escape(name)}( |$)", flags: "ni") } + scope :has_private_favorites, -> { bit_prefs_match(:enable_private_favorites, true) } + scope :has_public_favorites, -> { bit_prefs_match(:enable_private_favorites, false) } module BanMethods def unban! @@ -190,6 +193,14 @@ class User < ApplicationRecord end end + concerning :ValidationMethods do + def validate_enable_private_favorites + if enable_private_favorites_was == false && enable_private_favorites == true && !Pundit.policy!(self, self).can_enable_private_favorites? + errors.add(:base, "Can't enable privacy mode without a Gold account") + end + end + end + concerning :AuthenticationMethods do def password=(new_password) @password = new_password diff --git a/app/policies/favorite_group_policy.rb b/app/policies/favorite_group_policy.rb index 05cefc298..8926a1f8b 100644 --- a/app/policies/favorite_group_policy.rb +++ b/app/policies/favorite_group_policy.rb @@ -15,7 +15,11 @@ class FavoriteGroupPolicy < ApplicationPolicy update? end + def can_enable_privacy? + record.creator.is_gold? + end + def permitted_attributes - [:name, :post_ids_string, :is_public, :post_ids, { post_ids: [] }] + [:name, :post_ids_string, :is_public, :is_private, :post_ids, { post_ids: [] }] end end diff --git a/app/policies/favorite_policy.rb b/app/policies/favorite_policy.rb index b604f1ea1..4c3a5aef2 100644 --- a/app/policies/favorite_policy.rb +++ b/app/policies/favorite_policy.rb @@ -1,9 +1,13 @@ class FavoritePolicy < ApplicationPolicy def create? - !user.is_anonymous? + unbanned? && user.is_member? end def destroy? record.user_id == user.id end + + def can_see_favoriter? + user.is_admin? || record.user == user || !record.user.enable_private_favorites? + end end diff --git a/app/policies/post_policy.rb b/app/policies/post_policy.rb index 5612e1ce5..ae2592e42 100644 --- a/app/policies/post_policy.rb +++ b/app/policies/post_policy.rb @@ -59,10 +59,6 @@ class PostPolicy < ApplicationPolicy user.is_gold? end - def can_view_favlist? - user.is_gold? - end - # whether to show the + - links in the tag list. def show_extra_links? user.is_gold? diff --git a/app/policies/post_vote_policy.rb b/app/policies/post_vote_policy.rb index 3b385d517..730cbe46c 100644 --- a/app/policies/post_vote_policy.rb +++ b/app/policies/post_vote_policy.rb @@ -1,6 +1,6 @@ class PostVotePolicy < ApplicationPolicy def create? - unbanned? && user.is_gold? + unbanned? && user.is_member? end def destroy? @@ -8,6 +8,16 @@ class PostVotePolicy < ApplicationPolicy end def show? - user.is_admin? || record.user == user + user.is_admin? || record.user == user || (record.is_positive? && !record.user.enable_private_favorites?) + end + + def can_see_voter? + show? + end + + def api_attributes + attributes = super + attributes -= [:user_id] unless can_see_voter? + attributes end end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index ebf97d350..75ae4deb5 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -31,6 +31,10 @@ class UserPolicy < ApplicationPolicy user.is_admin? || record.id == user.id || !record.enable_private_favorites? end + def can_enable_private_favorites? + user.is_gold? + end + def permitted_attributes_for_create [:name, :password, :password_confirmation, { email_address_attributes: [:address] }] end diff --git a/app/views/favorite_groups/_form.html.erb b/app/views/favorite_groups/_form.html.erb new file mode 100644 index 000000000..ae4069218 --- /dev/null +++ b/app/views/favorite_groups/_form.html.erb @@ -0,0 +1,12 @@ +<%= edit_form_for(@favorite_group) do |f| %> + <%= f.input :name, as: :string, required: true, input_html: { value: @favorite_group.pretty_name } %> + <%= f.input :post_ids_string, label: "Posts", as: :text %> + <% if policy(@favorite_group).can_enable_privacy? %> + <%= f.input :is_private, label: "Private", as: :boolean, hint: "Don't allow others to view this favgroup." %> + <% elsif @favorite_group.is_private? %> + <%= f.input :is_private, label: "Private", as: :boolean, hint: "Don't allow others to view this favgroup. Warning: if you disable this, you can't re-enable it without ".html_safe + link_to("upgrading to Danbooru Gold", new_user_upgrade_path) + ". (".html_safe + link_to_wiki("learn more", "help:privacy_mode") + ")".html_safe %> + <% else %> + <%= f.input :is_private, label: "Private", as: :boolean, hint: link_to("Upgrade to Danbooru Gold to enable private favgroups", new_user_upgrade_path), input_html: { disabled: true } %> + <% end %> + <%= f.submit "Submit" %> +<% end %> diff --git a/app/views/favorite_groups/edit.html.erb b/app/views/favorite_groups/edit.html.erb index 29d7369eb..c74ea5149 100644 --- a/app/views/favorite_groups/edit.html.erb +++ b/app/views/favorite_groups/edit.html.erb @@ -2,12 +2,7 @@

Edit Favorite Group: <%= @favorite_group.pretty_name %>

- <%= edit_form_for(@favorite_group) do |f| %> - <%= f.input :name, :as => :string, :input_html => { :value => @favorite_group.pretty_name } %> - <%= f.input :post_ids_string, label: "Posts", as: :text %> - <%= f.input :is_public %> - <%= f.button :submit, "Submit" %> - <% end %> + <%= render "form" %>
diff --git a/app/views/favorite_groups/new.html.erb b/app/views/favorite_groups/new.html.erb index b3245edaa..f0ca86e04 100644 --- a/app/views/favorite_groups/new.html.erb +++ b/app/views/favorite_groups/new.html.erb @@ -2,12 +2,7 @@

New Favorite Group

- <%= edit_form_for(@favorite_group) do |f| %> - <%= f.input :name, as: :string, required: true %> - <%= f.input :post_ids_string, label: "Posts", as: :text %> - <%= f.input :is_public, label: "Public" %> - <%= f.submit "Submit" %> - <% end %> + <%= render "form" %>
diff --git a/app/views/favorites/_search.html.erb b/app/views/favorites/_search.html.erb new file mode 100644 index 000000000..1519c13ec --- /dev/null +++ b/app/views/favorites/_search.html.erb @@ -0,0 +1,6 @@ +<%= search_form_for(favorites_path) do |f| %> + <%= f.input :user_name, label: "Favoriter", input_html: { value: @user&.name, "data-autocomplete": "user" } %> + <%= f.input :post_id, label: "Post", input_html: { value: @post&.id } %> + <%= f.input :post_tags_match, label: "Tags", input_html: { value: params[:search][:post_tags_match], "data-autocomplete": "tag-query" } %> + <%= f.submit "Search" %> +<% end %> diff --git a/app/views/favorites/_update.js.erb b/app/views/favorites/_update.js.erb index b87eabc06..afd469fb8 100644 --- a/app/views/favorites/_update.js.erb +++ b/app/views/favorites/_update.js.erb @@ -4,19 +4,8 @@ $("#add-to-favorites, #add-fav-button, #remove-from-favorites, #remove-fav-button").toggle(); $("#remove-fav-button").addClass("animate"); $("span.post-votes[data-id=<%= @post.id %>]").replaceWith("<%= j render_post_votes @post, current_user: CurrentUser.user %>"); - $("#favcount-for-post-<%= @post.id %>").text(<%= @post.fav_count %>); + $("span.post-favcount[data-id=<%= @post.id %>]").html("<%= j link_to @post.fav_count, favorites_path(post_id: @post.id, variant: :compact) %>"); $(".fav-buttons").toggleClass("fav-buttons-false").toggleClass("fav-buttons-true"); - <% if policy(@post).can_view_favlist? %> - var fav_count = <%= @post.fav_count %>; - $("#favlist").html("<%= j render "posts/partials/show/favorite_list", post: @post %>"); - - if (fav_count === 0) { - $("#show-favlist-link, #hide-favlist-link, #favlist").hide(); - } else if (!$("#favlist").is(":visible")) { - $("#show-favlist-link").show(); - } - <% end %> - Danbooru.Utility.notice("<%= j flash[:notice] %>"); <% end %> diff --git a/app/views/favorites/index.html+tooltip.erb b/app/views/favorites/index.html+tooltip.erb new file mode 100644 index 000000000..288e797d7 --- /dev/null +++ b/app/views/favorites/index.html+tooltip.erb @@ -0,0 +1,3 @@ +<% if @post.present? %> + <%= render_favorites_tooltip(@post, current_user: CurrentUser.user) %> +<% end %> diff --git a/app/views/favorites/index.html.erb b/app/views/favorites/index.html.erb new file mode 100644 index 000000000..d25e903b4 --- /dev/null +++ b/app/views/favorites/index.html.erb @@ -0,0 +1,44 @@ +
+
+ <% if @post %> +

<%= link_to "Favorites", favorites_path %>/<%= link_to @post.dtext_shortlink, @post %>

+ <% elsif @user %> +

<%= link_to "Favorites", favorites_path %>/<%= link_to_user @user %>

+ <% else %> +

<%= link_to "Favorites", favorites_path %>

+ <% end %> + + <%= render "search" %> + + <%= table_for @favorites.includes(:user, post: [:uploader, :media_asset]), class: "striped autofit" do |t| %> + <% if @post.nil? %> + <% t.column "Post" do |favorite| %> + <%= post_preview(favorite.post, show_deleted: true) %> + <% end %> + + <% t.column "Tags", td: {class: "col-expand"} do |favorite| %> + <%= render_inline_tag_list(favorite.post) %> + <% end %> + + <% t.column "Uploader" do |favorite| %> + <%= link_to_user favorite.post.uploader %> + <%= link_to "»", favorites_path(search: { post_tags_match: "user:#{favorite.post.uploader.name}" }) %> +
<%= time_ago_in_words_tagged(favorite.post.created_at) %>
+ <% end %> + <% end %> + + <% if @user.nil? %> + <% t.column "Favoriter" do |favorite| %> + <% if policy(favorite).can_see_favoriter? %> + <%= link_to_user favorite.user %> + <%= link_to "»", favorites_path(search: { user_name: favorite.user.name }) %> + <% else %> + hidden + <% end %> + <% end %> + <% end %> + <% end %> + + <%= numbered_paginator(@favorites) %> +
+
diff --git a/app/views/layouts/default.html.erb b/app/views/layouts/default.html.erb index d33f6bd77..3dee6ac89 100644 --- a/app/views/layouts/default.html.erb +++ b/app/views/layouts/default.html.erb @@ -101,6 +101,8 @@
+
+
diff --git a/app/views/post_votes/_search.html.erb b/app/views/post_votes/_search.html.erb new file mode 100644 index 000000000..c9f4071e0 --- /dev/null +++ b/app/views/post_votes/_search.html.erb @@ -0,0 +1,7 @@ +<%= search_form_for(post_votes_path) do |f| %> + <%= f.input :user_name, label: "Voter", input_html: { value: params[:search][:user_name], "data-autocomplete": "user" } %> + <%= f.input :post_id, label: "Post", input_html: { value: params[:search][:post_id] } %> + <%= f.input :post_tags_match, label: "Tags", input_html: { value: params[:search][:post_tags_match], "data-autocomplete": "tag-query" } %> + <%= f.input :score, collection: [["+3", "3"], ["+1", "1"], ["-1", "-1"], ["-3", "-3"]], include_blank: true, selected: params[:search][:score] %> + <%= f.submit "Search" %> +<% end %> diff --git a/app/views/post_votes/index.html+compact.erb b/app/views/post_votes/index.html+compact.erb new file mode 100644 index 000000000..cd861d79e --- /dev/null +++ b/app/views/post_votes/index.html+compact.erb @@ -0,0 +1,24 @@ +<%= render "posts/partials/common/secondary_links" %> + +
+
+ <%= render "search" %> + + <%= table_for @post_votes, class: "striped autofit" do |t| %> + <% t.column "Score" do |vote| %> + <%= link_to sprintf("%+d", vote.score), post_votes_path(search: { score: vote.score }) %> + <% end %> + + <% t.column "Voter" do |vote| %> + <%= link_to_user vote.user %> + <%= link_to "»", post_votes_path(search: { user_name: vote.user.name }) %> + <% end %> + + <% t.column "Created" do |vote| %> + <%= time_ago_in_words_tagged(vote.created_at) %> + <% end %> + <% end %> + + <%= numbered_paginator(@post_votes) %> +
+
diff --git a/app/views/post_votes/index.html+tooltip.erb b/app/views/post_votes/index.html+tooltip.erb new file mode 100644 index 000000000..b351de668 --- /dev/null +++ b/app/views/post_votes/index.html+tooltip.erb @@ -0,0 +1 @@ +<%= render_post_votes_tooltip(@post, current_user: CurrentUser.user) %> diff --git a/app/views/post_votes/index.html.erb b/app/views/post_votes/index.html.erb index 609ef2f7b..096c683d3 100644 --- a/app/views/post_votes/index.html.erb +++ b/app/views/post_votes/index.html.erb @@ -1,12 +1,6 @@
- <%= search_form_for(post_votes_path) do |f| %> - <%= f.input :user_name, label: "Voter", input_html: { value: params[:search][:user_name], "data-autocomplete": "user" } %> - <%= f.input :post_id, label: "Post", input_html: { value: params[:search][:post_id] } %> - <%= f.input :post_tags_match, label: "Tags", input_html: { value: params[:search][:post_tags_match], "data-autocomplete": "tag-query" } %> - <%= f.input :score, collection: [["+3", "3"], ["+1", "1"], ["-1", "-1"], ["-3", "-3"]], include_blank: true, selected: params[:search][:score] %> - <%= f.submit "Search" %> - <% end %> + <%= render "search" %> <%= table_for @post_votes, class: "striped autofit" do |t| %> <% t.column "Post" do |vote| %> @@ -24,8 +18,12 @@
<%= time_ago_in_words_tagged(vote.post.created_at) %>
<% end %> <% t.column "Voter" do |vote| %> - <%= link_to_user vote.user %> - <%= link_to "»", post_votes_path(search: { user_name: vote.user.name }) %> + <% if policy(vote).can_see_voter? %> + <%= link_to_user vote.user %> + <%= link_to "»", post_votes_path(search: { user_name: vote.user.name }) %> + <% else %> + hidden + <% end %>
<%= time_ago_in_words_tagged(vote.created_at) %>
<% end %> <% t.column column: "control" do |vote| %> diff --git a/app/views/posts/index.html.erb b/app/views/posts/index.html.erb index ce0673544..c2d3e3701 100644 --- a/app/views/posts/index.html.erb +++ b/app/views/posts/index.html.erb @@ -7,7 +7,7 @@

Tags

- <%= render_search_tag_list(@post_set.related_tags, current_query: params[:tags], show_extra_links: policy(Post).show_extra_links?) %> + <%= render_search_tag_list(@post_set.related_tags, current_query: params[:tags], show_extra_links: policy(Post).show_extra_links?, search_params: { view: params[:view] }) %>
<%= render "posts/partials/index/options" %> @@ -16,7 +16,7 @@ <% end %> <% content_for(:content) do %> - +
  • Posts
  • <% if @post_set.artist.present? %> @@ -31,6 +31,18 @@ <% end %> +
  • + <%= render PopupMenuComponent.new do |menu| %> + <% menu.item do %> + <% if params[:view] == "score" %> + <%= link_to "Hide scores", posts_path(tags: params[:tags], page: params[:page], limit: params[:limit], view: nil), rel: "nofollow" %> + <% else %> + <%= link_to "Show scores", posts_path(tags: params[:tags], page: params[:page], limit: params[:limit], view: "score"), rel: "nofollow" %> + <% end %> + <% end %> + <% end %> +
  • +
    diff --git a/app/views/posts/partials/common/_search.html.erb b/app/views/posts/partials/common/_search.html.erb index e1077018d..26cb3547b 100644 --- a/app/views/posts/partials/common/_search.html.erb +++ b/app/views/posts/partials/common/_search.html.erb @@ -6,6 +6,9 @@ <% if params[:random] %> <%= hidden_field_tag :random, params[:random] %> <% end %> + <% if params[:view] %> + <%= hidden_field_tag :view, params[:view] %> + <% end %> <%= text_field_tag("tags", tags, :id => tags_dom_id, :class => "flex-auto", :"data-shortcut" => "q", :"data-autocomplete" => "tag-query") %> <% end %> diff --git a/app/views/posts/partials/index/_posts.html.erb b/app/views/posts/partials/index/_posts.html.erb index 26c911433..5bbf4ecbc 100644 --- a/app/views/posts/partials/index/_posts.html.erb +++ b/app/views/posts/partials/index/_posts.html.erb @@ -1,9 +1,9 @@ -
    -
    +
    +
    <% if post_set.shown_posts.empty? %> <%= render "post_sets/blank" %> <% else %> - <%= post_previews_html(post_set.posts, show_deleted: post_set.show_deleted?, show_cropped: true, tags: post_set.tag_string) %> + <%= post_previews_html(post_set.posts, show_deleted: post_set.show_deleted?, show_cropped: true, tags: post_set.tag_string, show_votes: post_set.show_votes?) %> <% end %>
    diff --git a/app/views/posts/partials/index/_seo_meta_tags.html.erb b/app/views/posts/partials/index/_seo_meta_tags.html.erb index 66755c3e0..382f13e4b 100644 --- a/app/views/posts/partials/index/_seo_meta_tags.html.erb +++ b/app/views/posts/partials/index/_seo_meta_tags.html.erb @@ -12,6 +12,8 @@ <% if params[:tags].blank? && @post_set.current_page == 1 %> <% canonical_url root_url(host: Danbooru.config.hostname) %> +<% else %> + <% canonical_url posts_url(host: Danbooru.config.hostname, tags: params[:tags], page: params[:page], limit: params[:limit]) %> <% end %> <% noindex if @post_set.hide_from_crawler? %> diff --git a/app/views/posts/partials/show/_favorite_list.html.erb b/app/views/posts/partials/show/_favorite_list.html.erb deleted file mode 100644 index 863a24a7a..000000000 --- a/app/views/posts/partials/show/_favorite_list.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%# post %> -<%= safe_join(post.visible_favorited_users(CurrentUser.user).map { |user| link_to_user(user) }, ", ") %> diff --git a/app/views/posts/partials/show/_information.html.erb b/app/views/posts/partials/show/_information.html.erb index a389d4be2..91f1c4e60 100644 --- a/app/views/posts/partials/show/_information.html.erb +++ b/app/views/posts/partials/show/_information.html.erb @@ -23,14 +23,12 @@
  • Score: <%= render_post_votes post, current_user: CurrentUser.user %>
  • -
  • Favorites: <%= post.fav_count %> - <% if policy(post).can_view_favlist? %> - <%= link_to "Show »", "#", id: "show-favlist-link", style: ("display: none;" if post.fav_count == 0) %> - <%= link_to "« Hide", "#", id: "hide-favlist-link", style: "display: none;" %> - - <% end %>
  • +
  • + Favorites: + <%= tag.span class: "post-favcount", "data-id": post.id do %> + <%= link_to post.fav_count, post_favorites_path(post) %> + <% end %> +
  • Status: <% if post.is_pending? %> diff --git a/app/views/posts/show.html+tooltip.erb b/app/views/posts/show.html+tooltip.erb index ca24f442e..bbaa83ad8 100644 --- a/app/views/posts/show.html+tooltip.erb +++ b/app/views/posts/show.html+tooltip.erb @@ -40,7 +40,7 @@ <% end %>
  • -
    "> +
    ">
    <% if params[:preview].truthy? %> <%= post_preview(@post, show_deleted: true, compact: true) %> diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index f72ed032a..7e88565bb 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -70,6 +70,12 @@ 2,000 5,000 + + Private Favorites + no + yes + yes + Favorite Groups 3 diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 557927ea3..0d7487658 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -63,7 +63,13 @@ <%= f.input :hide_deleted_posts, :as => :select, :label => "Deleted post filter", :hint => "Remove deleted posts from search results", :include_blank => false, :collection => [["Yes", "true"], ["No", "false"]] %> <%= f.input :show_deleted_children, :as => :select, :label => "Show deleted children", :hint => "Show thumbnail borders on parent posts even if the children are deleted", :include_blank => false, :collection => [["Yes", "true"], ["No", "false"]] %> <%= f.input :disable_categorized_saved_searches, :hint => "Don't show dialog box when creating a new saved search", :as => :select, :collection => [["No", "false"], ["Yes", "true"]], :include_blank => false %> - <%= f.input :enable_private_favorites, :as => :select, :hint => "Make your favorites private", :collection => [["No", "false"], ["Yes", "true"]], :include_blank => false %> + <% if policy(@user).can_enable_private_favorites? %> + <%= f.input :enable_private_favorites, label: "Private favorites and votes", as: :select, hint: "Make your favorites and upvotes private", collection: { "No" => false, "Yes" => true }, include_blank: false %> + <% elsif @user.enable_private_favorites? %> + <%= f.input :enable_private_favorites, label: "Private favorites and votes", as: :select, hint: "Make your favorites and upvotes private. (Warning: if you disable this, you can't re-enable it without ".html_safe + link_to("upgrading to a Gold account", new_user_upgrade_path) + " first. (".html_safe + link_to_wiki("learn more", "help:privacy_mode") + ")".html_safe, collection: { "No" => false, "Yes" => true }, include_blank: false %> + <% else %> + <%= f.input :enable_private_favorites, label: "Private favorites and votes", as: :select, hint: link_to("Upgrade to Danbooru Gold to enable private favorites and upvotes", new_user_upgrade_path), collection: { "No" => false, "Yes" => true }, include_blank: false %> + <% end %> <%= f.input :disable_tagged_filenames, :as => :select, :hint => "Don't include tags in image filenames", :collection => [["No", "false"], ["Yes", "true"]], :include_blank => false %> <%= f.input :disable_mobile_gestures, :as => :select, :hint => "Disable swipe left / swipe right gestures on mobile", :collection => [["No", "false"], ["Yes", "true"]], :include_blank => false %> <%= f.input :disable_post_tooltips, :as => :select, :hint => "Disable advanced tooltips when hovering over thumbnails", :collection => [["No", "false"], ["Yes", "true"]], :include_blank => false %> diff --git a/config/routes.rb b/config/routes.rb index b7e2287df..7d104b16e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -194,6 +194,7 @@ Rails.application.routes.draw do # XXX Use `only: []` to avoid redefining post routes defined at top of file. resources :posts, only: [] do resources :events, :only => [:index], :controller => "post_events" + resources :favorites, only: [:index, :create, :destroy] resources :replacements, :only => [:index, :new, :create], :controller => "post_replacements" resource :artist_commentary, only: [:show] do collection { put :create_or_update } @@ -252,6 +253,7 @@ Rails.application.routes.draw do end end resources :users do + resources :favorites, only: [:index, :create, :destroy] resources :favorite_groups, controller: "favorite_groups", only: [:index], as: "favorite_groups" resource :email, only: [:show, :edit, :update] do get :verify diff --git a/test/components/post_navbar_component_test.rb b/test/components/post_navbar_component_test.rb index a27024ac0..60a37c0ec 100644 --- a/test/components/post_navbar_component_test.rb +++ b/test/components/post_navbar_component_test.rb @@ -7,7 +7,7 @@ class PostNavbarComponentTest < ViewComponent::TestCase setup do @post = create(:post) - @user = create(:user) + @user = create(:gold_user) end context "The PostNavbarComponent" do @@ -46,9 +46,8 @@ class PostNavbarComponentTest < ViewComponent::TestCase context "for a post with favgroups" do setup do as(@user) do - @favgroup1 = create(:favorite_group, creator: @user, is_public: true) - @favgroup2 = create(:favorite_group, creator: @user, is_public: false) - @post.update(tag_string: "favgroup:#{@favgroup1.id} favgroup:#{@favgroup2.id}") + @favgroup1 = create(:favorite_group, creator: @user, post_ids: [@post.id]) + @favgroup2 = create(:private_favorite_group, creator: @user, post_ids: [@post.id]) end end diff --git a/test/components/post_votes_component_test.rb b/test/components/post_votes_component_test.rb index df30aff6d..7a9118d56 100644 --- a/test/components/post_votes_component_test.rb +++ b/test/components/post_votes_component_test.rb @@ -11,12 +11,12 @@ class PostVotesComponentTest < ViewComponent::TestCase end context "for a user who can't vote" do - should "not show the vote buttons" do + should "show the vote buttons" do render_post_votes(@post, current_user: User.anonymous) assert_css(".post-score") - assert_no_css(".post-upvote-link") - assert_no_css(".post-downvote-link") + assert_css(".post-upvote-link.inactive-link") + assert_css(".post-downvote-link.inactive-link") end end @@ -34,8 +34,8 @@ class PostVotesComponentTest < ViewComponent::TestCase context "for a downvoted post" do should "highlight the downvote button as active" do - @post.vote!(-1, @user) - render_post_votes(@post, current_user: @user) + create(:post_vote, post: @post, user: @user, score: -1) + as(@user) { render_post_votes(@post, current_user: @user) } assert_css(".post-upvote-link.inactive-link") assert_css(".post-downvote-link.active-link") @@ -44,8 +44,8 @@ class PostVotesComponentTest < ViewComponent::TestCase context "for an upvoted post" do should "highlight the upvote button as active" do - @post.vote!(1, @user) - render_post_votes(@post, current_user: @user) + create(:post_vote, post: @post, user: @user, score: 1) + as(@user) { render_post_votes(@post, current_user: @user) } assert_css(".post-upvote-link.active-link") assert_css(".post-downvote-link.inactive-link") diff --git a/test/factories/favorite.rb b/test/factories/favorite.rb index 441c6f4a9..40ef97b84 100644 --- a/test/factories/favorite.rb +++ b/test/factories/favorite.rb @@ -2,5 +2,9 @@ FactoryBot.define do factory(:favorite) do user post + + factory(:private_favorite) do + user factory: :gold_user, enable_private_favorites: true + end end end diff --git a/test/factories/favorite_group.rb b/test/factories/favorite_group.rb index 73104b22a..f03473a07 100644 --- a/test/factories/favorite_group.rb +++ b/test/factories/favorite_group.rb @@ -2,5 +2,10 @@ FactoryBot.define do factory :favorite_group do creator name { SecureRandom.uuid } + + factory :private_favorite_group do + creator factory: :gold_user + is_public { false } + end end end diff --git a/test/factories/user.rb b/test/factories/user.rb index dc6cf0942..a9bbe1c77 100644 --- a/test/factories/user.rb +++ b/test/factories/user.rb @@ -8,6 +8,7 @@ FactoryBot.define do factory(:banned_user) do transient { ban_duration {3} } is_banned {true} + active_ban factory: :ban end factory(:restricted_user) do diff --git a/test/functional/favorite_groups_controller_test.rb b/test/functional/favorite_groups_controller_test.rb index f9d0d32bb..bba7a91ce 100644 --- a/test/functional/favorite_groups_controller_test.rb +++ b/test/functional/favorite_groups_controller_test.rb @@ -10,7 +10,7 @@ class FavoriteGroupsControllerTest < ActionDispatch::IntegrationTest context "index action" do setup do @mod_favgroup = create(:favorite_group, name: "monochrome", creator: build(:moderator_user, name: "fumimi")) - @private_favgroup = create(:favorite_group, creator: @user, is_public: false) + @private_favgroup = create(:private_favorite_group) end should "render" do @@ -28,7 +28,7 @@ class FavoriteGroupsControllerTest < ActionDispatch::IntegrationTest context "for private favorite groups as the creator" do setup do - CurrentUser.user = @user + CurrentUser.user = @private_favgroup.creator end should respond_to_search(is_public: "false").with { @private_favgroup } @@ -42,13 +42,13 @@ class FavoriteGroupsControllerTest < ActionDispatch::IntegrationTest end should "show private favgroups to the creator" do - @favgroup.update!(is_public: false) + @favgroup.update_columns(is_public: false) get_auth favorite_group_path(@favgroup), @user assert_response :success end should "not show private favgroups to other users" do - @favgroup = create(:favorite_group, is_public: false) + @favgroup = create(:private_favorite_group) get_auth favorite_group_path(@favgroup), create(:user) assert_response 403 end @@ -91,6 +91,13 @@ class FavoriteGroupsControllerTest < ActionDispatch::IntegrationTest assert_response 403 assert_not_equal("foo", @favgroup.reload.name) end + + should "not allow non-Gold users to enable private favgroups" do + put_auth favorite_group_path(@favgroup), @user, params: { favorite_group: { is_private: true } } + + assert_response :success + assert_equal(false, @favgroup.is_private?) + end end context "destroy action" do diff --git a/test/functional/favorites_controller_test.rb b/test/functional/favorites_controller_test.rb index 61e94cffe..936e69e48 100644 --- a/test/functional/favorites_controller_test.rb +++ b/test/functional/favorites_controller_test.rb @@ -10,25 +10,35 @@ class FavoritesControllerTest < ActionDispatch::IntegrationTest end context "index action" do - should "redirect the user_id param to an ordfav: search" do - get favorites_path(user_id: @user.id) - assert_redirected_to posts_path(tags: "ordfav:#{@user.name}", format: "html") - end - - should "redirect members to an ordfav: search" do - get_auth favorites_path, @user - assert_redirected_to posts_path(tags: "ordfav:#{@user.name}", format: "html") - end - - should "redirect anonymous users to the posts index" do - get favorites_path - assert_redirected_to posts_path(format: "html") - end - should "render for json" do get favorites_path, as: :json assert_response :success end + + should "render for html" do + get favorites_path + assert_response :success + end + + should "render for /favorites?variant=tooltip" do + get post_favorites_path(@post, variant: "tooltip") + assert_response :success + end + + should "render for /users/:id/favorites" do + get user_favorites_path(@user) + assert_response :success + end + + should "render for /posts/:id/favorites" do + get post_favorites_path(@faved_post) + assert_response :success + end + + should "render for /favorites?search[user_name]=" do + get favorites_path(search: { user_name: @user.name }) + assert_response :success + end end context "create action" do @@ -48,12 +58,21 @@ class FavoritesControllerTest < ActionDispatch::IntegrationTest end end - should "allow banned users to create favorites" do + should "not allow banned users to create favorites" do @banned_user = create(:banned_user) - assert_difference [-> { @post.favorites.count }, -> { @post.reload.fav_count }, -> { @banned_user.reload.favorite_count }], 1 do + assert_difference [-> { @post.favorites.count }, -> { @post.reload.fav_count }, -> { @banned_user.reload.favorite_count }], 0 do post_auth favorites_path(post_id: @post.id), @banned_user, as: :javascript - assert_response :redirect + assert_response 403 + end + end + + should "not allow restricted users to create favorites" do + @restricted_user = create(:restricted_user) + + assert_difference [-> { @post.favorites.count }, -> { @post.reload.fav_count }, -> { @restricted_user.reload.favorite_count }], 0 do + post_auth favorites_path(post_id: @post.id), @restricted_user, as: :javascript + assert_response 403 end end diff --git a/test/functional/moderator/post/posts_controller_test.rb b/test/functional/moderator/post/posts_controller_test.rb index 7b13dc163..6aff77f78 100644 --- a/test/functional/moderator/post/posts_controller_test.rb +++ b/test/functional/moderator/post/posts_controller_test.rb @@ -43,8 +43,8 @@ module Moderator @parent.reload @child.reload as(@admin) do - assert_equal(users.map(&:id).sort, @parent.favorited_users.map(&:id).sort) - assert_equal([], @child.favorited_users.map(&:id)) + assert_equal(users.map(&:id).sort, @parent.favorites.map(&:user_id).sort) + assert_equal([], @child.favorites.map(&:user_id)) end end end diff --git a/test/functional/post_votes_controller_test.rb b/test/functional/post_votes_controller_test.rb index ffe2f92e8..ff5c47605 100644 --- a/test/functional/post_votes_controller_test.rb +++ b/test/functional/post_votes_controller_test.rb @@ -9,59 +9,167 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest context "index action" do setup do - @admin = create(:admin_user) - as(@user) { @post_vote = create(:post_vote, post: @post, user: @user) } - as(@admin) { @admin_vote = create(:post_vote, post: @post, user: @admin) } - @unrelated_vote = create(:post_vote) + @user = create(:user, enable_private_favorites: true) + @upvote = create(:post_vote, user: @user, score: 1) + @downvote = create(:post_vote, user: @user, score: -1) end should "render" do - get_auth post_votes_path, @user + get post_votes_path + assert_response :success + end + + should "render for a compact view" do + get post_votes_path(variant: "compact") + assert_response :success + end + + should "render for a tooltip" do + get post_votes_path(search: { post_id: @upvote.post_id }, variant: "tooltip") assert_response :success end context "as a user" do - setup do - CurrentUser.user = @user + should "show the user all their own votes" do + get_auth post_votes_path, @user + assert_response :success + assert_select "tbody tr", 2 + + get_auth post_votes_path(search: { user_id: @user.id }), @user + assert_response :success + assert_select "tbody tr", 2 + + get_auth post_votes_path(search: { user_name: @user.name }), @user + assert_response :success + assert_select "tbody tr", 2 end - should respond_to_search({}).with { @post_vote } + should "not show private upvotes to other users" do + get_auth post_votes_path, create(:user) + assert_response :success + assert_select "tbody tr", 0 + + get_auth post_votes_path(search: { user_id: @user.id }), create(:user) + assert_response :success + assert_select "tbody tr", 0 + + get_auth post_votes_path(search: { user_name: @user.name }), create(:user) + assert_response :success + assert_select "tbody tr", 0 + end + + should "not show downvotes to other users" do + @user.update!(enable_private_favorites: false) + + get_auth post_votes_path, create(:user) + assert_response :success + assert_select "tbody tr[data-score=1]", 1 + assert_select "tbody tr[data-score=-1]", 0 + + get_auth post_votes_path(search: { user_id: @user.id }), create(:user) + assert_response :success + assert_select "tbody tr[data-score=1]", 1 + assert_select "tbody tr[data-score=-1]", 0 + + get_auth post_votes_path(search: { user_name: @user.name }), create(:user) + assert_response :success + assert_select "tbody tr[data-score=1]", 1 + assert_select "tbody tr[data-score=-1]", 0 + end end - context "as a moderator" do - setup do - CurrentUser.user = @admin - end + context "as an admin" do + should "show all votes by other users" do + @admin = create(:admin_user) - should respond_to_search({}).with { [@unrelated_vote, @admin_vote, @post_vote] } - should respond_to_search(score: 1).with { [@unrelated_vote, @admin_vote, @post_vote].select{ |v| v.score == 1 } } + get_auth post_votes_path, @admin + assert_response :success + assert_select "tbody tr", 2 - context "using includes" do - should respond_to_search(post_tags_match: "dragon").with { [@admin_vote, @post_vote] } - should respond_to_search(user_name: "meiling").with { @post_vote } - should respond_to_search(user: {level: User::Levels::ADMIN}).with { @admin_vote } + get_auth post_votes_path(search: { user_id: @user.id }), @admin + assert_response :success + assert_select "tbody tr", 2 + + get_auth post_votes_path(search: { user_name: @user.name }), @admin + assert_response :success + assert_select "tbody tr", 2 + + get_auth post_votes_path(search: { user: { level: @user.level }}), @admin + assert_response :success + assert_select "tbody tr", 2 end end end context "show action" do - setup do - @post_vote = create(:post_vote, post: @post, user: @user) + context "for a public upvote" do + setup do + @user = create(:user, enable_private_favorites: false) + @post_vote = create(:post_vote, user: @user, score: 1) + end + + should "show the voter to everyone" do + get post_vote_path(@post_vote), as: :json + + assert_response :success + assert_equal(@user.id, response.parsed_body["user_id"]) + end end - should "show the vote to the voter" do - get_auth post_vote_path(@post_vote), @user, as: :json - assert_response :success + context "for a private upvote" do + setup do + @user = create(:user, enable_private_favorites: true) + @post_vote = create(:post_vote, user: @user, score: 1) + end + + should "show the voter to themselves" do + get_auth post_vote_path(@post_vote), @user, as: :json + + assert_response :success + assert_equal(@user.id, response.parsed_body["user_id"]) + end + + should "show the voter to admins" do + get_auth post_vote_path(@post_vote), create(:admin_user), as: :json + + assert_response :success + assert_equal(@user.id, response.parsed_body["user_id"]) + end + + should "not show the voter to other users" do + get post_vote_path(@post_vote), as: :json + + assert_response 403 + assert_nil(response.parsed_body["user_id"]) + end end - should "show the vote to admins" do - get_auth post_vote_path(@post_vote), create(:admin_user), as: :json - assert_response :success - end + context "for a downvote" do + setup do + @user = create(:user, enable_private_favorites: false) + @post_vote = create(:post_vote, user: @user, score: -1) + end - should "not show the vote to other users" do - get_auth post_vote_path(@post_vote), create(:user), as: :json - assert_response 403 + should "show the voter to themselves" do + get_auth post_vote_path(@post_vote), @user, as: :json + + assert_response :success + assert_equal(@user.id, response.parsed_body["user_id"]) + end + + should "show the voter to admins" do + get_auth post_vote_path(@post_vote), create(:admin_user), as: :json + + assert_response :success + assert_equal(@user.id, response.parsed_body["user_id"]) + end + + should "not show the voter to other users" do + get post_vote_path(@post_vote), as: :json + + assert_response 403 + assert_nil(response.parsed_body["user_id"]) + end end end @@ -87,13 +195,20 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest assert_equal(0, @post.reload.score) end - should "not allow members to vote" do - post_auth post_post_votes_path(post_id: @post.id), create(:user), params: { score: 1, format: "js" } + should "not allow restricted users to vote" do + post_auth post_post_votes_path(post_id: @post.id), create(:restricted_user), params: { score: 1, format: "js"} assert_response 403 assert_equal(0, @post.reload.score) end + should "allow members to vote" do + post_auth post_post_votes_path(post_id: @post.id), create(:user), params: { score: 1, format: "js" } + + assert_response :success + assert_equal(1, @post.reload.score) + end + should "not allow invalid scores" do post_auth post_post_votes_path(post_id: @post.id), @user, params: { score: 3, format: "js" } diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index e71d27c12..8053f0ddb 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -420,6 +420,34 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal("xyz", @user.favorite_tags) end + context "for a Member-level user" do + should "allow disabling the private favorites option" do + @user = create(:user, enable_private_favorites: true) + put_auth user_path(@user), @user, params: { user: { enable_private_favorites: false }} + + assert_redirected_to edit_user_path(@user) + assert_equal(false, @user.reload.enable_private_favorites) + end + + should "not allow enabling the private favorites option" do + @user = create(:user, enable_private_favorites: false) + put_auth user_path(@user), @user, params: { user: { enable_private_favorites: true }} + + assert_redirected_to edit_user_path(@user) + assert_equal(false, @user.reload.enable_private_favorites) + end + end + + context "for a Gold-level user" do + should "allow enabling the private favorites option" do + @user = create(:gold_user, enable_private_favorites: false) + put_auth user_path(@user), @user, params: { user: { enable_private_favorites: true }} + + assert_redirected_to edit_user_path(@user) + assert_equal(true, @user.reload.enable_private_favorites) + end + end + context "changing the level" do should "not work" do @owner = create(:owner_user) diff --git a/test/unit/favorite_test.rb b/test/unit/favorite_test.rb index 9c0bed69b..8273dc8af 100644 --- a/test/unit/favorite_test.rb +++ b/test/unit/favorite_test.rb @@ -11,10 +11,13 @@ class FavoriteTest < ActiveSupport::TestCase context "Favorites: " do context "removing a favorite" do should "update the post and user favorite counts" do + @user1 = create(:restricted_user) fav = Favorite.create!(post: @p1, user: @user1) assert_equal(1, @user1.reload.favorite_count) assert_equal(1, @p1.reload.fav_count) + assert_equal(0, @p1.reload.score) + refute(PostVote.positive.exists?(post: @p1, user: @user)) Favorite.destroy_by(post: @p1, user: @user1) @@ -42,14 +45,8 @@ class FavoriteTest < ActiveSupport::TestCase end context "adding a favorite" do - should "update the post and user favorite counts" do - Favorite.create!(post: @p1, user: @user1) - - assert_equal(1, @user1.reload.favorite_count) - assert_equal(1, @p1.reload.fav_count) - end - should "not upvote the post if the user can't vote" do + @user1 = create(:restricted_user) Favorite.create!(post: @p1, user: @user1) assert_equal(1, @user1.reload.favorite_count) @@ -89,7 +86,7 @@ class FavoriteTest < ActiveSupport::TestCase assert_equal(["You have already favorited this post"], @f2.errors.full_messages) assert_equal(1, @user1.reload.favorite_count) assert_equal(1, @p1.reload.fav_count) - assert_equal(0, @p1.reload.score) + assert_equal(1, @p1.reload.score) end end end diff --git a/test/unit/post_query_builder_test.rb b/test/unit/post_query_builder_test.rb index 5bd7fcb10..a0706f543 100644 --- a/test/unit/post_query_builder_test.rb +++ b/test/unit/post_query_builder_test.rb @@ -390,7 +390,7 @@ class PostQueryBuilderTest < ActiveSupport::TestCase favgroup1 = create(:favorite_group, creator: CurrentUser.user, post_ids: [post1.id]) favgroup2 = create(:favorite_group, creator: CurrentUser.user, post_ids: [post2.id]) - favgroup3 = create(:favorite_group, creator: create(:user), post_ids: [post3.id], is_public: false) + favgroup3 = create(:private_favorite_group, post_ids: [post3.id]) assert_tag_match([post1], "favgroup:#{favgroup1.id}") assert_tag_match([post2], "favgroup:#{favgroup2.name}") @@ -421,7 +421,7 @@ class PostQueryBuilderTest < ActiveSupport::TestCase post3 = create(:post) favgroup1 = create(:favorite_group, creator: CurrentUser.user, post_ids: [post1.id, post2.id]) - favgroup2 = create(:favorite_group, creator: create(:user), post_ids: [post2.id, post3.id], is_public: false) + favgroup2 = create(:private_favorite_group, post_ids: [post2.id, post3.id]) assert_tag_match([post1, post2], "ordfavgroup:#{favgroup1.id}") assert_tag_match([post1, post2], "ordfavgroup:#{favgroup1.name}") @@ -973,6 +973,75 @@ class PostQueryBuilderTest < ActiveSupport::TestCase assert_tag_match(all - [e], "-rating:e") end + context "for the upvote: metatag" do + setup do + @user = create(:gold_user) + @upvote = create(:post_vote, user: @user, score: 1) + @downvote = create(:post_vote, user: @user, score: -1) + end + + should "show public upvotes to all users" do + as(User.anonymous) do + assert_tag_match([@upvote.post], "upvote:#{@user.name}") + assert_tag_match([@downvote.post], "-upvote:#{@user.name}") + end + end + + should "not show private upvotes to other users" do + @user.update!(enable_private_favorites: true) + + as(User.anonymous) do + assert_tag_match([], "upvote:#{@user.name}") + assert_tag_match([@downvote.post, @upvote.post], "-upvote:#{@user.name}") + end + end + + should "show private upvotes to admins" do + @user.update!(enable_private_favorites: true) + + as(create(:admin_user)) do + assert_tag_match([@upvote.post], "upvote:#{@user.name}") + assert_tag_match([@downvote.post], "-upvote:#{@user.name}") + end + end + + should "show private upvotes to the voter themselves" do + as(@user) do + assert_tag_match([@upvote.post], "upvote:#{@user.name}") + assert_tag_match([@downvote.post], "-upvote:#{@user.name}") + end + end + end + + context "for the downvote: metatag" do + setup do + @user = create(:user, enable_private_favorites: true) + @upvote = create(:post_vote, user: @user, score: 1) + @downvote = create(:post_vote, user: @user, score: -1) + end + + should "not show downvotes to other users" do + as(User.anonymous) do + assert_tag_match([], "downvote:#{@user.name}") + assert_tag_match([@downvote.post, @upvote.post], "-downvote:#{@user.name}") + end + end + + should "show downvotes to admins" do + as(create(:admin_user)) do + assert_tag_match([@downvote.post], "downvote:#{@user.name}") + assert_tag_match([@upvote.post], "-downvote:#{@user.name}") + end + end + + should "show downvotes to the voter themselves" do + as(@user) do + assert_tag_match([@downvote.post], "downvote:#{@user.name}") + assert_tag_match([@upvote.post], "-downvote:#{@user.name}") + end + end + end + should "return posts for a upvote:, downvote: metatag" do CurrentUser.scoped(create(:mod_user)) do upvoted = create(:post, tag_string: "upvote:self") @@ -1342,8 +1411,7 @@ class PostQueryBuilderTest < ActiveSupport::TestCase end should "return the correct favorite count for a fav: search for a user with private favorites" do - fav = create(:favorite) - fav.user.update!(favorite_count: 1, enable_private_favorites: true) + fav = create(:private_favorite) assert_fast_count(0, "fav:#{fav.user.name}") assert_fast_count(0, "ordfav:#{fav.user.name}") diff --git a/test/unit/post_test.rb b/test/unit/post_test.rb index a4f0a6f53..2721c52a9 100644 --- a/test/unit/post_test.rb +++ b/test/unit/post_test.rb @@ -699,10 +699,34 @@ class PostTest < ActiveSupport::TestCase context "for a fav" do should "add/remove the current user to the post's favorite listing" do @post.update(tag_string: "aaa fav:self") + assert_equal(1, @post.reload.score) assert_equal(1, @post.favorites.where(user: @user).count) + assert_equal(1, @post.votes.positive.where(user: @user).count) @post.update(tag_string: "aaa -fav:self") + assert_equal(0, @post.reload.score) assert_equal(0, @post.favorites.count) + assert_equal(0, @post.votes.positive.where(user: @user).count) + end + + should "not allow banned users to fav" do + assert_raises(User::PrivilegeError) do + as(create(:banned_user)) { @post.update(tag_string: "aaa fav:self") } + end + + assert_raises(User::PrivilegeError) do + as(create(:banned_user)) { @post.update(tag_string: "aaa -fav:self") } + end + end + + should "not allow restricted users to fav" do + assert_raises(User::PrivilegeError) do + as(create(:restricted_user)) { @post.update(tag_string: "aaa fav:self") } + end + + assert_raises(User::PrivilegeError) do + as(create(:restricted_user)) { @post.update(tag_string: "aaa -fav:self") } + end end should "not fail when the fav: metatag is used twice" do @@ -870,20 +894,20 @@ class PostTest < ActiveSupport::TestCase context "upvote:self or downvote:self" do context "by a member" do - should "not upvote the post" do - assert_no_difference("PostVote.count") do + should "upvote the post" do + assert_difference("PostVote.count") do @post.update(tag_string: "upvote:self") end - assert_equal(0, @post.reload.score) + assert_equal(1, @post.reload.score) end - should "not downvote the post" do - assert_no_difference("PostVote.count") do + should "downvote the post" do + assert_difference("PostVote.count") do @post.update(tag_string: "downvote:self") end - assert_equal(0, @post.reload.score) + assert_equal(-1, @post.reload.score) end end @@ -1476,7 +1500,8 @@ class PostTest < ActiveSupport::TestCase should "create a vote for each user who can vote" do assert(@parent.votes.where(user: @gold1).exists?) - assert_equal(1, @parent.score) + assert(@parent.votes.where(user: @user1).exists?) + assert_equal(2, @parent.score) end end end @@ -1523,13 +1548,13 @@ class PostTest < ActiveSupport::TestCase end context "Voting:" do - should "not allow members to vote" do + should "allow members to vote" do user = create(:user) post = create(:post) assert_nothing_raised { post.vote!(1, user) } - assert_equal(0, post.votes.count) - assert_equal(0, post.reload.score) + assert_equal(1, post.votes.count) + assert_equal(1, post.reload.score) end should "not allow duplicate votes" do