diff --git a/.rubocop.yml b/.rubocop.yml index 0d6c90208..7aca81c75 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -27,6 +27,10 @@ Lint/NestedMethodDefinition: Exclude: - api/sinatra/**/* +Lint/MissingSuper: + Exclude: + - app/components/**/* + ### Metrics diff --git a/Gemfile b/Gemfile index 63e2ae23c..c85349e88 100644 --- a/Gemfile +++ b/Gemfile @@ -66,6 +66,8 @@ gem "fake_email_validator" # TLD validation gem "tldv", "~> 0.1.0" +gem "view_component" + gem "jwt", "~> 2.7" group :development do diff --git a/Gemfile.lock b/Gemfile.lock index c9dfd28f0..410d2741a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -485,6 +485,10 @@ GEM unf_ext (0.0.8) unicode-display_width (2.5.0) uniform_notifier (1.16.0) + view_component (3.6.0) + activesupport (>= 5.2.0, < 8.0) + concurrent-ruby (~> 1.0) + method_source (~> 1.0) warden (1.2.9) rack (>= 2.0.9) webpush (1.1.0) @@ -580,6 +584,7 @@ DEPENDENCIES tldv (~> 0.1.0) turbo-rails twitter-text + view_component BUNDLED WITH 2.4.21 diff --git a/app/components/avatar_component.html.haml b/app/components/avatar_component.html.haml new file mode 100644 index 000000000..379979c6d --- /dev/null +++ b/app/components/avatar_component.html.haml @@ -0,0 +1,4 @@ +%img{ class: avatar_classes, + alt: alt_text, + src: avatar_image, + loading: :lazy } diff --git a/app/components/avatar_component.rb b/app/components/avatar_component.rb new file mode 100644 index 000000000..c970576a7 --- /dev/null +++ b/app/components/avatar_component.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class AvatarComponent < ViewComponent::Base + ALLOWED_SIZES = %w[xs sm md lg xl xxl].freeze + + def initialize(user:, size:, classes: []) + @user = user + @size = size if ALLOWED_SIZES.include? size + @classes = classes + end + + private + + def size_to_version(size) + case size + when "xs", "sm" + :small + when "md", "lg" + :medium + when "xl", "xxl" + :large + end + end + + def alt_text = "@#{@user.screen_name}" + + def avatar_classes = @classes.unshift("avatar-#{@size}") + + def avatar_image = @user.profile_picture.url(size_to_version(@size)) +end diff --git a/app/views/answerbox/_comments.html.haml b/app/views/answerbox/_comments.html.haml index 62a1035c7..a03e4e103 100644 --- a/app/views/answerbox/_comments.html.haml +++ b/app/views/answerbox/_comments.html.haml @@ -8,7 +8,7 @@ .d-flex .flex-shrink-0 %a{ href: user_path(comment.user) } - %img.comment__user-avatar.avatar-sm{ src: comment.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: comment.user, size: "sm", classes: ["comment__user-avatar"]) .flex-grow-1 %h6.comment__user = user_screen_name comment.user diff --git a/app/views/answerbox/_header.html.haml b/app/views/answerbox/_header.html.haml index 30dd9738a..37f5b4064 100644 --- a/app/views/answerbox/_header.html.haml +++ b/app/views/answerbox/_header.html.haml @@ -3,7 +3,7 @@ - unless a.question.author_is_anonymous .flex-shrink-0 %a{ href: user_path(a.question.user) } - %img.answerbox__question-user-avatar.avatar-md{ src: a.question.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: a.question.user, size: "md", classes: ["answerbox__question-user-avatar"]) .flex-grow-1 %h6.text-muted.answerbox__question-user - if a.question.author_is_anonymous diff --git a/app/views/answerbox/_smiles.html.haml b/app/views/answerbox/_smiles.html.haml index 518c72ee2..89e905390 100644 --- a/app/views/answerbox/_smiles.html.haml +++ b/app/views/answerbox/_smiles.html.haml @@ -9,4 +9,4 @@ %a{ href: user_path(smile.user), title: user_screen_name(smile.user, url: false), data: { bs_toggle: :tooltip, bs_placement: :top, smile_id: smile.id } } - %img.avatar-xs{ src: smile.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: smile.user, size: "xs") diff --git a/app/views/application/_answerbox.html.haml b/app/views/application/_answerbox.html.haml index 4524f405d..470101057 100644 --- a/app/views/application/_answerbox.html.haml +++ b/app/views/application/_answerbox.html.haml @@ -14,7 +14,7 @@ .d-flex .flex-shrink-0 %a{ href: user_path(a.user) } - %img.answerbox__answer-user-avatar.avatar-sm{ src: a.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: a.user, size: "sm", classes: ["answerbox__answer-user-avatar"]) .flex-grow-1 %h6.answerbox__answer-user = raw t(".answered", hide: hidespan(t(".hide"), "d-none d-sm-inline"), user: user_screen_name(a.user)) diff --git a/app/views/discover/_userbox.html.haml b/app/views/discover/_userbox.html.haml index 8c322b50c..165ccc51a 100644 --- a/app/views/discover/_userbox.html.haml +++ b/app/views/discover/_userbox.html.haml @@ -3,7 +3,7 @@ .d-flex .flex-shrink-0 %a{ href: user_path(u) } - %img.avatar-md.me-2{ src: u.profile_picture.url(:medium) } + = render AvatarComponent.new(user: u, size: "md", classes: ["me-2"]) .flex-grow-1 %h6.answerbox__question-user - if u.profile.display_name.blank? diff --git a/app/views/inbox/_entry.html.haml b/app/views/inbox/_entry.html.haml index 1d37c7116..873d37954 100644 --- a/app/views/inbox/_entry.html.haml +++ b/app/views/inbox/_entry.html.haml @@ -4,7 +4,7 @@ - unless i.question.author_is_anonymous .flex-shrink-0 %a.pull-left{ href: user_path(i.question.user) } - %img.answerbox__question-user-avatar.avatar-md{ src: i.question.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: i.question.user, size: "md", classes: ["answerbox__question-user-avatar"]) .flex-grow-1 %h6.text-muted.answerbox__question-user - if i.question.author_is_anonymous diff --git a/app/views/moderation/_moderationbox.html.haml b/app/views/moderation/_moderationbox.html.haml index 721177afb..ef0c16fb2 100644 --- a/app/views/moderation/_moderationbox.html.haml +++ b/app/views/moderation/_moderationbox.html.haml @@ -1,6 +1,6 @@ .card.moderationbox{ data: { id: report.id } } .card-header - %img.avatar-sm{ src: report.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: report.user, size: "sm") = t(".reported_html", user: user_screen_name(report.user), content: report.type.sub("Reports::", ""), diff --git a/app/views/moderation/inbox/_header.html.haml b/app/views/moderation/inbox/_header.html.haml index eae01b4b8..cd57e8d8c 100644 --- a/app/views/moderation/inbox/_header.html.haml +++ b/app/views/moderation/inbox/_header.html.haml @@ -4,6 +4,6 @@ .d-flex .flex-shrink-0 %a{ href: user_path(user) } - %img.answerbox__question-user-avatar.avatar-md{ src: user.profile_picture.url(:medium) } + = render AvatarComponent.new(user:, size: "md", classes: ["answerbox__question-user-avatar"]) .flex-grow-1 = t(".title_html", screen_name: user.screen_name, user_id: user.id) diff --git a/app/views/navigation/_desktop.html.haml b/app/views/navigation/_desktop.html.haml index fc0cce386..267c59ffa 100644 --- a/app/views/navigation/_desktop.html.haml +++ b/app/views/navigation/_desktop.html.haml @@ -34,7 +34,7 @@ %i.fa.fa-pencil-square-o %li.nav-item.dropdown.profile--image-dropdown %a.nav-link.dropdown-toggle.p-sm-0{ href: "#", data: { bs_toggle: :dropdown } } - %img.avatar-md.d-none.d-sm-inline{ src: current_user.profile_picture.url(:small) } + = render AvatarComponent.new(user: current_user, size: "md", classes: ["d-none", "d-sm-inline"]) %span.d-inline.d-sm-none = current_user.screen_name %b.caret diff --git a/app/views/navigation/_mobile.html.haml b/app/views/navigation/_mobile.html.haml index 2b68f0811..010d592cf 100644 --- a/app/views/navigation/_mobile.html.haml +++ b/app/views/navigation/_mobile.html.haml @@ -12,5 +12,5 @@ badge: notification_count, badge_color: "primary", badge_attr: { id: "notification-mobile-count" }, icon_only: true %li.nav-item.profile--image-dropdown %a.nav-link{ href: '#', data: { bs_toggle: 'dropdown', bs_target: '#rs-mobile-nav-profile' }, aria: { controls: 'rs-mobile-nav-profile', expanded: 'false' } } - %img.avatar-md.d-inline{ src: current_user.profile_picture.url(:small) } + = render AvatarComponent.new(user: current_user, size: "md", classes: ["d-inline"]) = render 'navigation/dropdown/profile', size: "mobile" diff --git a/app/views/notifications/type/_answer.html.haml b/app/views/notifications/type/_answer.html.haml index 890fb394e..a85836ebb 100644 --- a/app/views/notifications/type/_answer.html.haml +++ b/app/views/notifications/type/_answer.html.haml @@ -3,7 +3,7 @@ %i.fa.fa-2x.fa-fw.fa-exclamation .flex-grow-1 .notification__heading - %img.avatar-xs{ src: notification.target.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: notification.target.user, size: "xs") = t(".heading_html", user: user_screen_name(notification.target.user), question: link_to(t(".link_text"), answer_path(username: notification.target.user.screen_name, id: notification.target.id), target: "_top"), diff --git a/app/views/notifications/type/_comment.html.haml b/app/views/notifications/type/_comment.html.haml index 56c3797c3..a3371df1f 100644 --- a/app/views/notifications/type/_comment.html.haml +++ b/app/views/notifications/type/_comment.html.haml @@ -3,7 +3,7 @@ %i.fa.fa-2x.fa-fw.fa-comments .flex-grow-1 .notification__heading - %img.avatar-xs{ src: notification.target.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: notification.target.user, size: "xs") - if notification.target.answer.user == current_user = t(".heading_html", user: user_screen_name(notification.target.user), diff --git a/app/views/notifications/type/_follow.html.haml b/app/views/notifications/type/_follow.html.haml index 0994b6de9..d142943fd 100644 --- a/app/views/notifications/type/_follow.html.haml +++ b/app/views/notifications/type/_follow.html.haml @@ -1,6 +1,6 @@ .d-flex.notification .flex-shrink-0.notification__icon - %img.avatar-sm{ src: notification.target.source.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: notification.target.source, size: "sm") .flex-grow-1 %h6.notification__user = user_screen_name notification.target.source diff --git a/app/views/notifications/type/_reaction.html.haml b/app/views/notifications/type/_reaction.html.haml index ae9259c11..0f0e68f8a 100644 --- a/app/views/notifications/type/_reaction.html.haml +++ b/app/views/notifications/type/_reaction.html.haml @@ -3,7 +3,7 @@ %i.fa.fa-2x.fa-fw.fa-smile-o .flex-grow-1 .notification__heading - %img.avatar-xs{ src: notification.target.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: notification.target.user, size: "xs") - if notification.target.parent_type == "Answer" = t(".heading_html", user: user_screen_name(notification.target.user), diff --git a/app/views/question/_question.html.haml b/app/views/question/_question.html.haml index 1128bb5f0..c25594f04 100644 --- a/app/views/question/_question.html.haml +++ b/app/views/question/_question.html.haml @@ -5,7 +5,7 @@ - unless question.author_is_anonymous .flex-shrink-0 %a{ href: unless hidden then user_path(question.user) end } - %img.answerbox__question-user-avatar.avatar-md{ src: question.user.profile_picture.url(:small) } + = render AvatarComponent.new(user: question.user, size: "md", classes: ["answerbox__question-user-avatar"]) .flex-grow-1 %h6.text-muted.answerbox__question-user - identifier = question.author_is_anonymous ? question.author_identifier : nil diff --git a/app/views/settings/blocks/index.html.haml b/app/views/settings/blocks/index.html.haml index 51e18a11b..e11aa2558 100644 --- a/app/views/settings/blocks/index.html.haml +++ b/app/views/settings/blocks/index.html.haml @@ -6,7 +6,7 @@ - @blocks.each do |block| %li.list-group-item .d-flex - %img.avatar-md.d-none.d-sm-inline.me-2{ src: block.target.profile_picture.url(:small) } + = render AvatarComponent.new(user: block.target, size: "md", classes: ["d-none", "d-sm-inline", "me-2"]) %div %p.mb-0= user_screen_name(block.target) %p.text-muted.mb-0= t(".blocked", time: time_ago_in_words(block.created_at)) diff --git a/app/views/settings/mutes/_user.html.haml b/app/views/settings/mutes/_user.html.haml index e669270ac..a2324bcce 100644 --- a/app/views/settings/mutes/_user.html.haml +++ b/app/views/settings/mutes/_user.html.haml @@ -1,5 +1,5 @@ .d-flex.mb-2 - %img.avatar-md.me-2{ src: user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user:, size: "md", classes: ["me-2"]) %p.align-self-center.m-0= user_screen_name(user, context_user: current_user) .ms-auto.d-inline-flex %button.btn.btn-default.align-self-center{ data: { action: :unmute, target: user.screen_name } } diff --git a/app/views/settings/profile/edit.html.haml b/app/views/settings/profile/edit.html.haml index 327215908..d2311e51d 100644 --- a/app/views/settings/profile/edit.html.haml +++ b/app/views/settings/profile/edit.html.haml @@ -5,7 +5,7 @@ %div{ data: { controller: "cropper", cropper_aspect_ratio_value: "1" } } .d-flex .flex-shrink-0 - %img.avatar-lg.me-3{ src: current_user.profile_picture.url(:medium) } + = render AvatarComponent.new(user: current_user, size: "lg", classes: ["me-3"]) .flex-grow-1 = f.file_field :profile_picture, accept: APP_CONFIG[:accepted_image_formats].join(","), data: { cropper_target: "input", action: "cropper#change" } diff --git a/app/views/shared/_question.html.haml b/app/views/shared/_question.html.haml index e1a4df80c..6ebd7d933 100644 --- a/app/views/shared/_question.html.haml +++ b/app/views/shared/_question.html.haml @@ -5,7 +5,7 @@ - if type == "discover" .flex-shrink-0 %a{ href: user_screen_name(q.user, link_only: true) } - %img.avatar-md.me-2{ src: q.user&.profile_picture&.url(:small), loading: :lazy } + = render AvatarComponent.new(user: q.user, size: "md", classes: ["me-2"]) .flex-grow-1 %h6.text-muted.answerbox__question-user - if type.nil? && q.direct diff --git a/app/views/tabs/_feed.html.haml b/app/views/tabs/_feed.html.haml index 778c1cea4..fc92d7a07 100644 --- a/app/views/tabs/_feed.html.haml +++ b/app/views/tabs/_feed.html.haml @@ -18,7 +18,7 @@ %p.px-4.pb-2 - list.members.each do |member| %a{ href: user_path(member.user), title: member.user.screen_name, data: { bs_toggle: :tooltip, bs_placement: :top } } - %img.avatar-xs{ src: member.user.profile_picture.url(:small), loading: :lazy } + = render AvatarComponent.new(user: member.user, size: "xs") - if !list && lists.empty? .p-3= t(".lists.notice_html") - lists.each do |list| diff --git a/spec/components/avatar_component_spec.rb b/spec/components/avatar_component_spec.rb new file mode 100644 index 000000000..922c52d0b --- /dev/null +++ b/spec/components/avatar_component_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AvatarComponent, type: :component do + let(:user) { FactoryBot.create(:user) } + + it "renders an avatar" do + expect( + render_inline(described_class.new(user:, size: "sm")).to_html, + ).to include( + "no_avatar.png", + ) + end + + it "gets the medium version of a profile picture if requested" do + expect( + render_inline(described_class.new(user:, size: "md")).to_html, + ).to include( + "medium/", + ) + end + + it "gets the large version of a profile picture if requested" do + expect( + render_inline(described_class.new(user:, size: "xl")).to_html, + ).to include( + "large/", + ) + end + + it "includes additionally passed classes" do + expect( + render_inline(described_class.new(user:, size: "md", classes: %w[first-class second-class])).to_html, + ).to include( + 'class="avatar-md first-class second-class"', + ) + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 11da5afa9..74cb6c76a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -11,6 +11,8 @@ require "rspec/its" require "devise" require "rspec-sidekiq" +require "view_component/test_helpers" +require "view_component/system_test_helpers" # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are @@ -68,6 +70,8 @@ config.include Devise::Test::ControllerHelpers, type: :controller config.include Devise::Test::ControllerHelpers, type: :helper config.include Devise::Test::ControllerHelpers, type: :view + config.include ViewComponent::TestHelpers, type: :component + config.include ViewComponent::SystemTestHelpers, type: :component end Shoulda::Matchers.configure do |config|