favorites: show favlist when hovering over favcount.

Changes:

* Make it so you can click or hover over a post's favorite count to see
  the list of public favorites.
* Remove the "Show »" button next to the favorite count.
* Make the favorites list visible to all users. Before favorites were
  only visible to Gold users.
* Make the /favorites page show the list of all public favorites,
  instead of redirecting to the current user's favorites.
* Add /posts/:id/favorites endpoint.
* Add /users/:id/favorites endpoint.

This is for several reasons:

* To make viewing favorites work the same way as viewing upvotes.
* To make posts load faster for Gold users. Before, we loaded all the
  favorites when viewing a post, even when the user didn't look at them.
  This made pageloads slower for posts that had hundreds or thousands of
  favorites. Now we only load the favlist if the user hovers over the favcount.
* To make the favorite list visible to all users. Before, it wasn't
  visible to non-Gold users, because of the performance issue listed above.
* To make it more obvious that favorites are public by default. Before,
  since regular users could only see the favcount, they may have
  mistakenly believed other users couldn't see their favorites.
This commit is contained in:
evazion
2021-11-19 20:54:02 -06:00
parent c4ad50bbba
commit 3ae62d08eb
23 changed files with 229 additions and 78 deletions

View File

@@ -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

View File

@@ -0,0 +1,9 @@
<div class="favorites-tooltip thin-scrollbar">
<div class="post-favoriters">
<% favorites.each do |favorite| %>
<div class="post-favoriter truncate">
<%= favoriter_name(favorite) %>
</div>
<% end %>
</div>
</div>

View File

@@ -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;

View File

@@ -0,0 +1,13 @@
.favorites-tooltip {
font-size: var(--text-xs);
max-height: 240px;
.post-favoriter {
max-width: 160px;
}
}
span.post-favcount a {
color: var(--text-color);
&:hover { text-decoration: underline; }
}

View File

@@ -1,19 +1,16 @@
class FavoritesController < ApplicationController class FavoritesController < ApplicationController
respond_to :html, :xml, :json, :js respond_to :js, :json, :html, :xml
def index def index
authorize Favorite post_id = params[:post_id] || params[:search][:post_id]
if !request.format.html? user_id = params[:user_id] || params[:search][:user_id]
@favorites = Favorite.visible(CurrentUser.user).paginated_search(params) user_name = params[:search][:user_name]
respond_with(@favorites) @post = Post.find(post_id) if post_id
elsif params[:user_id].present? @user = User.find(user_id) if user_id
user = User.find(params[:user_id]) @user = User.find_by_name(user_name) if user_name
redirect_to posts_path(tags: "ordfav:#{user.name}", format: request.format.symbol)
elsif !CurrentUser.is_anonymous? @favorites = authorize Favorite.visible(CurrentUser.user).paginated_search(params, defaults: { post_id: @post&.id, user_id: @user&.id })
redirect_to posts_path(tags: "ordfav:#{CurrentUser.user.name}", format: request.format.symbol) respond_with(@favorites)
else
redirect_to posts_path(format: request.format.symbol)
end
end end
def create def create

View File

@@ -24,6 +24,10 @@ module ComponentsHelper
render PostVotesTooltipComponent.new(post: post, **options) render PostVotesTooltipComponent.new(post: post, **options)
end end
def render_favorites_tooltip(post, **options)
render FavoritesTooltipComponent.new(post: post, **options)
end
def render_post_navbar(post, **options) def render_post_navbar(post, **options)
render PostNavbarComponent.new(post: post, **options) render PostNavbarComponent.new(post: post, **options)
end end

View File

@@ -37,6 +37,7 @@ import Blacklist from "../src/javascripts/blacklists.js";
import CommentComponent from "../../components/comment_component/comment_component.js"; import CommentComponent from "../../components/comment_component/comment_component.js";
import CurrentUser from "../src/javascripts/current_user.js"; import CurrentUser from "../src/javascripts/current_user.js";
import Dtext from "../src/javascripts/dtext.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 IqdbQuery from "../src/javascripts/iqdb_queries.js";
import Note from "../src/javascripts/notes.js"; import Note from "../src/javascripts/notes.js";
import PopupMenuComponent from "../../components/popup_menu_component/popup_menu_component.js"; import PopupMenuComponent from "../../components/popup_menu_component/popup_menu_component.js";
@@ -59,6 +60,7 @@ Danbooru.Blacklist = Blacklist;
Danbooru.CommentComponent = CommentComponent; Danbooru.CommentComponent = CommentComponent;
Danbooru.CurrentUser = CurrentUser; Danbooru.CurrentUser = CurrentUser;
Danbooru.Dtext = Dtext; Danbooru.Dtext = Dtext;
Danbooru.FavoritesTooltipComponent = FavoritesTooltipComponent;
Danbooru.IqdbQuery = IqdbQuery; Danbooru.IqdbQuery = IqdbQuery;
Danbooru.Note = Note; Danbooru.Note = Note;
Danbooru.PopupMenuComponent = PopupMenuComponent; Danbooru.PopupMenuComponent = PopupMenuComponent;

View File

@@ -30,7 +30,6 @@ Post.initialize_all = function() {
if ($("#c-posts").length && $("#a-show").length) { if ($("#c-posts").length && $("#a-show").length) {
this.initialize_links(); this.initialize_links();
this.initialize_post_relationship_previews(); this.initialize_post_relationship_previews();
this.initialize_favlist();
this.initialize_post_sections(); this.initialize_post_sections();
this.initialize_post_image_resize_links(); this.initialize_post_image_resize_links();
this.initialize_recommended(); 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) { Post.view_original = function(e = null) {
if (Utility.test_max_width(660)) { if (Utility.test_max_width(660)) {
// Do the default behavior (navigate to image) // Do the default behavior (navigate to image)

View File

@@ -170,10 +170,6 @@ div#c-posts {
} }
} }
#favlist {
word-wrap: break-word;
}
#recommended.loading-recommended-posts { #recommended.loading-recommended-posts {
pointer-events: none; pointer-events: none;
opacity: 0.5; opacity: 0.5;

View File

@@ -6,10 +6,16 @@ class Favorite < ApplicationRecord
after_create :upvote_post_on_create after_create :upvote_post_on_create
after_destroy :unvote_post_on_destroy 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) 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 end
def self.search(params) def self.search(params)

View File

@@ -50,7 +50,6 @@ class Post < ApplicationRecord
has_many :approvals, :class_name => "PostApproval", :dependent => :destroy has_many :approvals, :class_name => "PostApproval", :dependent => :destroy
has_many :disapprovals, :class_name => "PostDisapproval", :dependent => :destroy has_many :disapprovals, :class_name => "PostDisapproval", :dependent => :destroy
has_many :favorites, dependent: :destroy has_many :favorites, dependent: :destroy
has_many :favorited_users, through: :favorites, source: :user
has_many :replacements, class_name: "PostReplacement", :dependent => :destroy has_many :replacements, class_name: "PostReplacement", :dependent => :destroy
attr_accessor :old_tag_string, :old_parent_id, :old_source, :old_rating, :has_constraints, :disable_versioning attr_accessor :old_tag_string, :old_parent_id, :old_source, :old_rating, :has_constraints, :disable_versioning
@@ -667,13 +666,6 @@ class Post < ApplicationRecord
Favorite.exists?(post: self, user: user) Favorite.exists?(post: self, user: user)
end 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 def favorite_groups
FavoriteGroup.for_post(id) FavoriteGroup.for_post(id)
end end

View File

@@ -6,4 +6,8 @@ class FavoritePolicy < ApplicationPolicy
def destroy? def destroy?
record.user_id == user.id record.user_id == user.id
end end
def can_see_favoriter?
user.is_admin? || record.user == user || !record.user.enable_private_favorites?
end
end end

View File

@@ -59,10 +59,6 @@ class PostPolicy < ApplicationPolicy
user.is_gold? user.is_gold?
end end
def can_view_favlist?
user.is_gold?
end
# whether to show the + - links in the tag list. # whether to show the + - links in the tag list.
def show_extra_links? def show_extra_links?
user.is_gold? user.is_gold?

View File

@@ -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 %>

View File

@@ -4,19 +4,8 @@
$("#add-to-favorites, #add-fav-button, #remove-from-favorites, #remove-fav-button").toggle(); $("#add-to-favorites, #add-fav-button, #remove-from-favorites, #remove-fav-button").toggle();
$("#remove-fav-button").addClass("animate"); $("#remove-fav-button").addClass("animate");
$("span.post-votes[data-id=<%= @post.id %>]").replaceWith("<%= j render_post_votes @post, current_user: CurrentUser.user %>"); $("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"); $(".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] %>"); Danbooru.Utility.notice("<%= j flash[:notice] %>");
<% end %> <% end %>

View File

@@ -0,0 +1,3 @@
<% if @post.present? %>
<%= render_favorites_tooltip(@post, current_user: CurrentUser.user) %>
<% end %>

View File

@@ -0,0 +1,44 @@
<div id="c-favorites">
<div id="a-index">
<% if @post %>
<h1><%= link_to "Favorites", favorites_path %>/<%= link_to @post.dtext_shortlink, @post %></h1>
<% elsif @user %>
<h1><%= link_to "Favorites", favorites_path %>/<%= link_to_user @user %></h1>
<% else %>
<h1><%= link_to "Favorites", favorites_path %></h1>
<% 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}" }) %>
<div><%= time_ago_in_words_tagged(favorite.post.created_at) %></div>
<% 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 %>
<i>hidden</i>
<% end %>
<% end %>
<% end %>
<% end %>
<%= numbered_paginator(@favorites) %>
</div>
</div>

View File

@@ -102,6 +102,7 @@
<div id="post-tooltips"></div> <div id="post-tooltips"></div>
<div id="user-tooltips"></div> <div id="user-tooltips"></div>
<div id="post-votes-tooltips"></div> <div id="post-votes-tooltips"></div>
<div id="post-favorites-tooltips"></div>
<div id="popup-menus"></div> <div id="popup-menus"></div>
</div> </div>

View File

@@ -1,2 +0,0 @@
<%# post %>
<%= safe_join(post.visible_favorited_users(CurrentUser.user).map { |user| link_to_user(user) }, ", ") %>

View File

@@ -23,14 +23,12 @@
<li id="post-info-score"> <li id="post-info-score">
Score: <%= render_post_votes post, current_user: CurrentUser.user %> Score: <%= render_post_votes post, current_user: CurrentUser.user %>
</li> </li>
<li id="post-info-favorites">Favorites: <span id="favcount-for-post-<%= post.id %>"><%= post.fav_count %></span> <li id="post-info-favorites">
<% if policy(post).can_view_favlist? %> Favorites:
<%= link_to "Show »", "#", id: "show-favlist-link", style: ("display: none;" if post.fav_count == 0) %> <%= tag.span class: "post-favcount", "data-id": post.id do %>
<%= link_to "« Hide", "#", id: "hide-favlist-link", style: "display: none;" %> <%= link_to post.fav_count, post_favorites_path(post) %>
<div id="favlist" style="display: none;" class="ml-4"> <% end %>
<%= render "posts/partials/show/favorite_list", post: post %> </li>
</div>
<% end %></li>
<li id="post-info-status"> <li id="post-info-status">
Status: Status:
<% if post.is_pending? %> <% if post.is_pending? %>

View File

@@ -194,6 +194,7 @@ Rails.application.routes.draw do
# XXX Use `only: []` to avoid redefining post routes defined at top of file. # XXX Use `only: []` to avoid redefining post routes defined at top of file.
resources :posts, only: [] do resources :posts, only: [] do
resources :events, :only => [:index], :controller => "post_events" resources :events, :only => [:index], :controller => "post_events"
resources :favorites, only: [:index, :create, :destroy]
resources :replacements, :only => [:index, :new, :create], :controller => "post_replacements" resources :replacements, :only => [:index, :new, :create], :controller => "post_replacements"
resource :artist_commentary, only: [:show] do resource :artist_commentary, only: [:show] do
collection { put :create_or_update } collection { put :create_or_update }
@@ -252,6 +253,7 @@ Rails.application.routes.draw do
end end
end end
resources :users do resources :users do
resources :favorites, only: [:index, :create, :destroy]
resources :favorite_groups, controller: "favorite_groups", only: [:index], as: "favorite_groups" resources :favorite_groups, controller: "favorite_groups", only: [:index], as: "favorite_groups"
resource :email, only: [:show, :edit, :update] do resource :email, only: [:show, :edit, :update] do
get :verify get :verify

View File

@@ -10,25 +10,35 @@ class FavoritesControllerTest < ActionDispatch::IntegrationTest
end end
context "index action" do 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 should "render for json" do
get favorites_path, as: :json get favorites_path, as: :json
assert_response :success assert_response :success
end 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]=<name>" do
get favorites_path(search: { user_name: @user.name })
assert_response :success
end
end end
context "create action" do context "create action" do

View File

@@ -43,8 +43,8 @@ module Moderator
@parent.reload @parent.reload
@child.reload @child.reload
as(@admin) do as(@admin) do
assert_equal(users.map(&:id).sort, @parent.favorited_users.map(&:id).sort) assert_equal(users.map(&:id).sort, @parent.favorites.map(&:user_id).sort)
assert_equal([], @child.favorited_users.map(&:id)) assert_equal([], @child.favorites.map(&:user_id))
end end
end end
end end