diff --git a/app/controllers/favorites_controller.rb b/app/controllers/favorites_controller.rb index a68f00c337697..4d51317713681 100644 --- a/app/controllers/favorites_controller.rb +++ b/app/controllers/favorites_controller.rb @@ -21,7 +21,7 @@ def create current_user.reload render json: { success: true, - remaining_allowance: current_user.favorite_allowance + remaining_allowance: current_user.favorite_allowance_for_client }, status: :ok else render_favorite_error(result.error) diff --git a/app/controllers/leadership_dashboards_controller.rb b/app/controllers/leadership_dashboards_controller.rb index 7c96724f7c8e7..8a01a46c9f440 100644 --- a/app/controllers/leadership_dashboards_controller.rb +++ b/app/controllers/leadership_dashboards_controller.rb @@ -9,7 +9,8 @@ def show return head :not_found unless current_user.community_leader? @section = SECTIONS.include?(params[:section]) ? params[:section] : "community" - @favorite_allowance = current_user.favorite_allowance + @unlimited_favorites = current_user.unlimited_favorites? + @favorite_allowance = current_user.favorite_allowance_for_client if @section == "yours" @favorited = Favorites::Fetch.call(user: current_user, page: params[:page]) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index e12572c2b0ae8..dcb97d2b2bbc8 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -500,16 +500,26 @@ def assign_co_authors def assign_user_comments comment_count = helpers.comment_count(params[:view]) @comments = [] - return unless user_signed_in? && @user.comments_count.positive? + @user_profile_comments_count = 0 + return unless @user.comments_count.positive? - @comments = @user.comments.good_quality.where(deleted: false) - .joins("INNER JOIN articles ON articles.id = comments.commentable_id AND comments.commentable_type = 'Article'") - .merge(Article.from_subforem) + @user_profile_comments = user_profile_comments + @user_profile_comments_count = @user_profile_comments.count + return unless user_signed_in? + + @comments = @user_profile_comments .order(created_at: :desc) .includes(commentable: [:podcast]) .limit(comment_count) end + def user_profile_comments + @user.comments.good_quality.where(deleted: false) + .joins("INNER JOIN articles ON articles.id = comments.commentable_id AND comments.commentable_type = 'Article'") + .merge(Article.from_subforem) + .merge(Article.published) + end + def assign_user_stories @pinned_stories = Article.published.from_subforem.full_posts.where(id: @user.profile_pins.select(:pinnable_id)) .limited_column_select diff --git a/app/helpers/favorites_helper.rb b/app/helpers/favorites_helper.rb index c1cf4a31d15ca..3cf3b739c3b95 100644 --- a/app/helpers/favorites_helper.rb +++ b/app/helpers/favorites_helper.rb @@ -90,6 +90,7 @@ def favorite_control_tag(favoritable, variant: :article) modal_remaining_zero: t("favorites.modal.remaining_zero"), modal_remaining_one: t("favorites.modal.remaining_one"), modal_remaining_other: t("favorites.modal.remaining_other"), + modal_remaining_unlimited: t("favorites.modal.remaining_unlimited"), modal_close: t("favorites.modal.close"), modal_exhausted_title: t("favorites.modal_exhausted.title"), modal_exhausted_body: t("favorites.modal_exhausted.body"), diff --git a/app/javascript/favoriteControl/FavoriteControl.jsx b/app/javascript/favoriteControl/FavoriteControl.jsx index b73ad77b19db8..8423c9c9a2c4b 100644 --- a/app/javascript/favoriteControl/FavoriteControl.jsx +++ b/app/javascript/favoriteControl/FavoriteControl.jsx @@ -97,6 +97,7 @@ export const FavoriteControl = ({ modalRemainingZero = 'You have no more gems left to give out today.', modalRemainingOne = 'You have 1 more gem left to give out today.', modalRemainingOther = 'You have %{count} more gems left to give out today.', + modalRemainingUnlimited = 'You have unlimited gems to give out.', modalClose = 'Got it', modalExhaustedTitle = 'Out of Gems', modalExhaustedBody = 'You have no more gems left to give out today. Your allowance will refresh soon!', @@ -122,14 +123,17 @@ export const FavoriteControl = ({ return null; } - let remainingText = modalRemainingOther.replace( - '%{count}', - String(remainingAllowance ?? 0), - ); + // A null allowance means the curator is not metered. + let remainingText = modalRemainingUnlimited; if (remainingAllowance === 1) { remainingText = modalRemainingOne; } else if (remainingAllowance === 0) { remainingText = modalRemainingZero; + } else if (remainingAllowance != null) { + remainingText = modalRemainingOther.replace( + '%{count}', + String(remainingAllowance), + ); } return ( @@ -283,10 +287,11 @@ export const FavoriteControl = ({ if (response.ok) { const data = await response.json().catch(() => ({})); const remaining = - data.remaining_allowance ?? - (currentUser?.favorite_allowance != null - ? Math.max(0, currentUser.favorite_allowance - 1) - : 0); + data.remaining_allowance !== undefined + ? data.remaining_allowance + : currentUser?.favorite_allowance != null + ? Math.max(0, currentUser.favorite_allowance - 1) + : null; setFavorited(true); setFavoritedById(userId); setRemainingAllowance(remaining); diff --git a/app/javascript/favoriteControl/__tests__/FavoriteControl.test.jsx b/app/javascript/favoriteControl/__tests__/FavoriteControl.test.jsx index c288748d148b8..d91c185626e73 100644 --- a/app/javascript/favoriteControl/__tests__/FavoriteControl.test.jsx +++ b/app/javascript/favoriteControl/__tests__/FavoriteControl.test.jsx @@ -147,6 +147,46 @@ describe('', () => { expect(await findByText('You have no more gems left to give out today.')).toBeInTheDocument(); }); + it('shows an unlimited allowance when the API reports a null remaining allowance', async () => { + makeFavorite.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, remaining_allowance: null }), + }); + const { getByLabelText, findByText } = renderControl({ + currentUser: { + id: CURRENT_USER_ID, + favorite_allowance: null, + community_leader: true, + }, + }); + + fireEvent.click(getByLabelText('Pick as gem')); + + expect( + await findByText('You have unlimited gems to give out.'), + ).toBeInTheDocument(); + }); + + it('lets an unmetered curator favorite instead of showing the out of gems modal', async () => { + makeFavorite.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, remaining_allowance: null }), + }); + const { getByLabelText, findByLabelText, queryByText } = renderControl({ + currentUser: { + id: CURRENT_USER_ID, + favorite_allowance: null, + community_leader: true, + }, + }); + + fireEvent.click(getByLabelText('Pick as gem')); + + expect(makeFavorite).toHaveBeenCalled(); + expect(await findByLabelText('Picked as gem by you')).toBeInTheDocument(); + expect(queryByText('Out of Gems')).toBeNull(); + }); + it('shows the out of gems modal when a community leader with 0 gems attempts to favorite', async () => { const { getByLabelText, getByText, findByText, queryByText } = renderControl({ currentUser: { id: CURRENT_USER_ID, favorite_allowance: 0, community_leader: true }, diff --git a/app/javascript/packs/favoriteControls.jsx b/app/javascript/packs/favoriteControls.jsx index 93fe77f9352e6..9925187af990e 100644 --- a/app/javascript/packs/favoriteControls.jsx +++ b/app/javascript/packs/favoriteControls.jsx @@ -27,6 +27,7 @@ function initializeFavoriteControls(currentUser) { modalRemainingZero, modalRemainingOne, modalRemainingOther, + modalRemainingUnlimited, modalClose, modalExhaustedTitle, modalExhaustedBody, @@ -50,6 +51,7 @@ function initializeFavoriteControls(currentUser) { modalRemainingZero={modalRemainingZero} modalRemainingOne={modalRemainingOne} modalRemainingOther={modalRemainingOther} + modalRemainingUnlimited={modalRemainingUnlimited} modalClose={modalClose} modalExhaustedTitle={modalExhaustedTitle} modalExhaustedBody={modalExhaustedBody} diff --git a/app/lib/black_box.rb b/app/lib/black_box.rb index a7fb2b1d93cee..a2953f707e83f 100644 --- a/app/lib/black_box.rb +++ b/app/lib/black_box.rb @@ -12,7 +12,7 @@ def article_hotness_score(article) today_bonus = usable_date > 26.hours.ago ? 795 : 0 two_day_bonus = usable_date > 48.hours.ago ? 830 : 0 four_day_bonus = usable_date > 96.hours.ago ? 930 : 0 - featured_bonus = article.featured ? 200 : 0 + curation_bonus = calculate_curation_bonus(article) if usable_date < 4.days.ago reaction_points /= 2 # Older posts should fade end @@ -27,7 +27,7 @@ def article_hotness_score(article) ( article_hotness + reaction_points + recency_bonus + super_recent_bonus + - super_super_recent_bonus + today_bonus + two_day_bonus + four_day_bonus + featured_bonus + super_super_recent_bonus + today_bonus + two_day_bonus + four_day_bonus + curation_bonus ) end @@ -40,6 +40,12 @@ def comment_quality_score(comment) private + def calculate_curation_bonus(article) + featured_bonus = article.featured ? 200 : 0 + favorited_bonus = article.respond_to?(:favorited?) && article.favorited? ? 200 : 0 + featured_bonus + favorited_bonus + end + def calculate_bonus_score(body_markdown) size_bonus = body_markdown.size > 200 ? 2 : 0 code_bonus = body_markdown.include?("`") ? 1 : 0 diff --git a/app/mailers/survey_mailer.rb b/app/mailers/survey_mailer.rb index bf0ae508a6d23..4827249e7c77a 100644 --- a/app/mailers/survey_mailer.rb +++ b/app/mailers/survey_mailer.rb @@ -13,6 +13,9 @@ def pulse_survey elsif @survey.fun? subject = "A quick, fun survey from #{@community_name}!" survey_type = "fun" + elsif @survey.beta_testing? + subject = "You've been randomly selected for a beta testing survey" + survey_type = "beta_testing" else subject = "You've been randomly selected for a #{@community_name} Pulse Survey" survey_type = "pulse" @@ -28,7 +31,7 @@ def pulse_survey "extra_email_context_paragraph" => @survey.extra_email_context_paragraph.presence, "extra_email_context_paragraph_html" => @extra_email_context_html, "extra_email_context_html" => @extra_email_context_html, - "subject" => subject, + "subject" => subject }, ) diff --git a/app/models/article.rb b/app/models/article.rb index 1546253f53158..8871deb4bf4cb 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -572,12 +572,11 @@ def self.bust_cached_admin_published_with(tag_name, subforem_id: nil) end } - # @note This includes the `featured` scope, which may or may not be - # something we expose going forward. However, it was - # something used in two of the three queries we had that - # included the where `score > Settings::UserExperience.home_feed_minimum_score` + # @note This includes the `featured` and `favorited` scopes, which allows + # featured and community favorite (gemmed) articles to bypass the + # home feed minimum score threshold. scope :with_at_least_home_feed_minimum_score, lambda { - featured.or( + featured.or(favorited).or( where(score: Settings::UserExperience.home_feed_minimum_score..), ) } @@ -1458,7 +1457,8 @@ def update_notification_subscriptions def before_destroy_actions bust_cache(destroying: true) - article_ids = user.article_ids.dup + user&.touch(:last_article_at) + article_ids = user ? user.article_ids.dup : [] if organization organization.touch(:last_article_at) article_ids.concat organization.article_ids @@ -1739,7 +1739,7 @@ def title_to_slug def touch_actor_latest_article_updated_at(destroying: false) return unless destroying || saved_changes.keys.intersection(%w[title cached_tag_list published archived]).present? - user.touch(:latest_article_updated_at) + user&.touch(:latest_article_updated_at) organization&.touch(:latest_article_updated_at) end diff --git a/app/models/async_info.rb b/app/models/async_info.rb index cbc733985dbf9..97e7219c9a63d 100644 --- a/app/models/async_info.rb +++ b/app/models/async_info.rb @@ -46,7 +46,7 @@ def to_hash display_announcements: user.display_announcements, trusted: user.trusted?, community_leader: user.community_leader?, - favorite_allowance: user.favorite_allowance, + favorite_allowance: user.favorite_allowance_for_client, moderator_for_tags: user.moderator_for_tags, moderator_for_subforems: user.moderator_for_subforems, config_body_class: user.config_body_class, diff --git a/app/models/survey.rb b/app/models/survey.rb index 7a5e024f2cc71..fbb2d44269ee9 100644 --- a/app/models/survey.rb +++ b/app/models/survey.rb @@ -13,7 +13,7 @@ class Survey < ApplicationRecord has_many :poll_votes, through: :polls has_many :survey_completions, dependent: :destroy - enum :type_of, { community_pulse: 0, industry: 1, fun: 2 } + enum :type_of, { community_pulse: 0, industry: 1, fun: 2, beta_testing: 3 } accepts_nested_attributes_for :polls, allow_destroy: true validates_associated :polls diff --git a/app/models/user.rb b/app/models/user.rb index b882fe9729371..59e0b47ead267 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -643,7 +643,16 @@ def authorizer # ############################################################################## + # Admins who curate alongside the community are not metered, so they can keep + # picking gems past the community leader allowance. + # + # @return [Boolean] + def unlimited_favorites? + any_admin? && community_leader? + end + def favorite_base_allowance + return Float::INFINITY if unlimited_favorites? return Settings::UserExperience.community_leader_l2_favorite_allowance if community_leader_level_2? return Settings::UserExperience.community_leader_l1_favorite_allowance if community_leader_level_1? @@ -652,8 +661,9 @@ def favorite_base_allowance # How many favorites the user can make. # - # @return [Integer] + # @return [Integer, Float] Float::INFINITY when the user is not metered def favorite_allowance + return Float::INFINITY if unlimited_favorites? return earned_favorites_count unless community_leader? # Community leader allowances refresh over the configured period @@ -664,6 +674,15 @@ def favorite_allowance favorite_base_allowance - spent_this_period end + # The remaining allowance as exposed to the client, where `nil` signals an + # unlimited allowance so the front end can skip the counter. + # + # @return [Integer, nil] + def favorite_allowance_for_client + allowance = favorite_allowance + allowance.finite? ? allowance : nil + end + # The name of the tags moderated by the user. # # @note This caches a relatively expensive query diff --git a/app/services/favorites/create.rb b/app/services/favorites/create.rb index da2e0e6257713..ffc12c8f73c6c 100644 --- a/app/services/favorites/create.rb +++ b/app/services/favorites/create.rb @@ -71,6 +71,8 @@ def claim_favoritable # made, and updates it for regular users. # Returns true on valid and successful spend, or false otherwise. def can_afford_claim? + # Unmetered curators never spend from an allowance, so skip the row lock. + return true if user.unlimited_favorites? return spend_earned_favorite == 1 unless user.community_leader? # For community leaders, lock to serialize the allowance check for the diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 44c42298a52af..6d88e0cff76f3 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -75,6 +75,51 @@ + <% end %> diff --git a/app/views/leadership_dashboards/_sidebar.html.erb b/app/views/leadership_dashboards/_sidebar.html.erb index 0cb5281f1cc3f..5ad042bdfe229 100644 --- a/app/views/leadership_dashboards/_sidebar.html.erb +++ b/app/views/leadership_dashboards/_sidebar.html.erb @@ -8,10 +8,14 @@

<%= t("views.leadership.icon.leader_alt") %>

- <%= t("views.leadership.allowance.remaining_html", count: @favorite_allowance).html_safe %> + <% if @unlimited_favorites %> + <%= t("views.leadership.allowance.unlimited_html").html_safe %> + <% else %> + <%= t("views.leadership.allowance.remaining_html", count: @favorite_allowance).html_safe %> + <% end %>

- <%= t("views.leadership.allowance.replenish_note") %> + <%= @unlimited_favorites ? t("views.leadership.allowance.unlimited_note") : t("views.leadership.allowance.replenish_note") %>

diff --git a/app/views/mailers/survey_mailer/pulse_survey.html.erb b/app/views/mailers/survey_mailer/pulse_survey.html.erb index 5c6f955683b9d..1b3733f8b1ad2 100644 --- a/app/views/mailers/survey_mailer/pulse_survey.html.erb +++ b/app/views/mailers/survey_mailer/pulse_survey.html.erb @@ -4,6 +4,8 @@

We invite you to participate in a <%= @community_name %> Industry Survey.

<% elsif @survey.fun? %>

We invite you to participate in a quick, fun survey from <%= @community_name %>.

+<% elsif @survey.beta_testing? %> +

You have been randomly selected to participate in a beta testing survey.

<% else %>

You have been randomly selected to participate in a <%= @community_name %> Pulse Survey.

<% end %> @@ -19,6 +21,8 @@ "Take the #{@community_name} Industry Survey" elsif @survey.fun? "Take this quick #{@community_name} Survey" + elsif @survey.beta_testing? + "Take the Beta Testing Survey" else "Take the #{@community_name} Pulse Survey" end %> diff --git a/app/views/users/_comments_section.html.erb b/app/views/users/_comments_section.html.erb index d1e63954efc4d..f78755db9b99d 100644 --- a/app/views/users/_comments_section.html.erb +++ b/app/views/users/_comments_section.html.erb @@ -3,7 +3,7 @@ <% if params[:view] == "comments" %>

- <% if high_number_of_comments?(@user.comments_count) %> + <% if high_number_of_comments?(@user_profile_comments_count || @user.comments_count) %> <%= t("views.users.comments.last.other") %> <% else %> <%= t("views.users.comments.all.other") %> @@ -40,13 +40,13 @@ <% end %> <% end %> - <% if params[:view] != "comments" && high_number_of_comments?(@user.comments_count) %> + <% if params[:view] != "comments" && high_number_of_comments?(@user_profile_comments_count || @user.comments_count) %> - <% elsif params[:view] != "comments" && view_all_comments?(@user.comments_count) %> + <% elsif params[:view] != "comments" && view_all_comments?(@user_profile_comments_count || @user.comments_count) %>
<%= t("views.users.comments.view_all.other") %> diff --git a/app/views/users/_main_feed.html.erb b/app/views/users/_main_feed.html.erb index c1f3e52633d73..7ce07b37b1a6a 100644 --- a/app/views/users/_main_feed.html.erb +++ b/app/views/users/_main_feed.html.erb @@ -13,9 +13,9 @@ <% end %> <% if @stories.present? %> <%= render "articles/single_story", story: @stories.first, featured: false %> - <%= render user_comments_section if @user.comments_count.positive? %> + <%= render user_comments_section if user_signed_in? ? @comments.present? : @user.comments_count.positive? %> <%= render partial: "articles/single_story", collection: @stories[1, @stories.size], as: :story, locals: { featured: false } %>
<% else %> - <%= render user_comments_section if @user.comments_count.positive? %> + <%= render user_comments_section if user_signed_in? ? @comments.present? : @user.comments_count.positive? %> <% end %> diff --git a/app/views/users/_sidebar.html.erb b/app/views/users/_sidebar.html.erb index 77fb2976e2845..2ee3110340a46 100644 --- a/app/views/users/_sidebar.html.erb +++ b/app/views/users/_sidebar.html.erb @@ -49,7 +49,7 @@ <% end %> <% end %> - <% cache "user-profile-sidebar-stats-#{@user.id}-#{@user.last_article_at.respond_to?(:rfc3339) ? @user.last_article_at.rfc3339 : @user.last_article_at.to_s}-#{@user.last_comment_at.respond_to?(:rfc3339) ? @user.last_comment_at.rfc3339 : @user.last_comment_at.to_s}-#{@user.following_tags_count}-#{@user.formatted_last_followed_at}-#{@user.articles.published.from_subforem.size}", expires_in: 10.days do %> + <% cache "user-profile-sidebar-stats-#{@user.id}-#{@user.last_article_at.respond_to?(:rfc3339) ? @user.last_article_at.rfc3339 : @user.last_article_at.to_s}-#{@user.last_comment_at.respond_to?(:rfc3339) ? @user.last_comment_at.rfc3339 : @user.last_comment_at.to_s}-#{@user.following_tags_count}-#{@user.formatted_last_followed_at}-#{@user.articles.published.from_subforem.size}-#{@user_profile_comments_count}", expires_in: 10.days do %>
<%= crayons_icon_tag(:post, class: "mr-3 color-base-50", title: t("views.users.side.post.icon")) %> @@ -57,7 +57,7 @@
<%= crayons_icon_tag(:comment, class: "mr-3 color-base-50", title: t("views.users.side.comment.icon")) %> - <%= t "views.users.side.comment.text", count: @user.comments.where(deleted: false).size %> + <%= t "views.users.side.comment.text", count: @user_profile_comments_count || 0 %>
<%= crayons_icon_tag(:tag, class: "mr-3 color-base-50", title: t("views.users.side.tag.icon")) %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 90f49363b085f..be97bf02809e1 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -164,7 +164,7 @@ <% else %> <%= render "users/comments_locked_cta" %> <% end %> - <% elsif @stories.any? || @user.comments_count.positive? || @pinned_stories.any? %> + <% elsif @stories.any? || @comments.present? || @pinned_stories.any? || (!user_signed_in? && @user.comments_count.positive?) %> <%= render "users/main_feed" %> <% end %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index c776923dc1484..5ae49cb07d466 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -17,6 +17,7 @@ en: remaining_zero: You have no more gems left to give out today. remaining_one: You have 1 more gem left to give out today. remaining_other: You have %{count} more gems left to give out today. + remaining_unlimited: You have unlimited gems to give out. close: Got it modal_exhausted: title: Out of Gems diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 0361043fa103d..983345027915e 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -17,6 +17,7 @@ fr: remaining_zero: Vous n'avez plus de pépites à distribuer aujourd'hui. remaining_one: Il vous reste 1 pépite à distribuer aujourd'hui. remaining_other: Il vous reste %{count} pépites à distribuer aujourd'hui. + remaining_unlimited: Vous disposez de pépites illimitées à distribuer. close: Compris modal_exhausted: title: Plus de pépites diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 47d02ebbdb618..64c1e75c0b31c 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -17,6 +17,7 @@ pt: remaining_zero: Você não tem mais joias para distribuir hoje. remaining_one: Você tem mais 1 joia para distribuir hoje. remaining_other: Você tem mais %{count} joias para distribuir hoje. + remaining_unlimited: Você tem joias ilimitadas para distribuir. close: Entendi modal_exhausted: title: Sem joias restantes diff --git a/config/locales/views/leadership/en.yml b/config/locales/views/leadership/en.yml index 77e5c8677216a..7b3416b53c17f 100644 --- a/config/locales/views/leadership/en.yml +++ b/config/locales/views/leadership/en.yml @@ -12,6 +12,8 @@ en: allowance: remaining_html: "You have %{count} gem picks remaining." replenish_note: "Gems replenish as your picks generate community impact and meaningful discussions." + unlimited_html: "You have unlimited gem picks." + unlimited_note: "As an admin curator, your gem picks are never metered." explainer: heading: How Curation Works human_touch_title: The Power of Human Curation diff --git a/config/locales/views/leadership/fr.yml b/config/locales/views/leadership/fr.yml index d415fb608ab15..c20ccf411d924 100644 --- a/config/locales/views/leadership/fr.yml +++ b/config/locales/views/leadership/fr.yml @@ -12,6 +12,8 @@ fr: allowance: remaining_html: "Il vous reste %{count} pépites à attribuer." replenish_note: "Vos pépites se renouvellent au fur et à mesure que vos sélections génèrent de l'engagement et des échanges de qualité." + unlimited_html: "Vous disposez de pépites illimitées à attribuer." + unlimited_note: "En tant qu'administrateur curateur, vos pépites ne sont jamais limitées." explainer: heading: Comment fonctionne la curation human_touch_title: L'importance de la curation humaine diff --git a/config/locales/views/leadership/pt.yml b/config/locales/views/leadership/pt.yml index a57fe4aeffa96..36af575525110 100644 --- a/config/locales/views/leadership/pt.yml +++ b/config/locales/views/leadership/pt.yml @@ -13,6 +13,8 @@ pt: allowance: remaining_html: "Você tem %{count} joias restantes para escolher." replenish_note: "Suas joias se renovam conforme suas escolhas geram impacto e discussões de qualidade na comunidade." + unlimited_html: "Você tem joias ilimitadas para escolher." + unlimited_note: "Como curador administrador, suas joias nunca são limitadas." explainer: heading: Como funciona a curadoria human_touch_title: O poder da curadoria humana diff --git a/spec/lib/black_box_spec.rb b/spec/lib/black_box_spec.rb index 90ce16a92e633..9acd874e375a7 100644 --- a/spec/lib/black_box_spec.rb +++ b/spec/lib/black_box_spec.rb @@ -23,6 +23,28 @@ score = described_class.article_hotness_score(article) expect(score).to eq(0) end + + it "adds a 200 point bonus for featured articles" do + now = Time.current + article = build_stubbed(:article, score: 99, published_at: now, featured: false) + featured_article = build_stubbed(:article, score: 99, published_at: now, featured: true) + + base_score = described_class.article_hotness_score(article) + featured_score = described_class.article_hotness_score(featured_article) + + expect(featured_score - base_score).to eq(200) + end + + it "adds a 200 point bonus for favorited (gemmed) articles" do + now = Time.current + article = build_stubbed(:article, score: 99, published_at: now, favorited_by_user_id: nil) + favorited_article = build_stubbed(:article, score: 99, published_at: now, favorited_by_user_id: 123) + + base_score = described_class.article_hotness_score(article) + favorited_score = described_class.article_hotness_score(favorited_article) + + expect(favorited_score - base_score).to eq(200) + end end describe "#comment_quality_score" do diff --git a/spec/mailers/survey_mailer_spec.rb b/spec/mailers/survey_mailer_spec.rb index c817a6b92f110..602d51c822ad6 100644 --- a/spec/mailers/survey_mailer_spec.rb +++ b/spec/mailers/survey_mailer_spec.rb @@ -54,6 +54,22 @@ end end + context "when survey is beta_testing" do + let(:survey) do + create(:survey, title: "Beta Survey", type_of: :beta_testing, extra_email_context_paragraph: "Beta details.") + end + + it "renders the headers" do + expect(mail.subject).to eq("You've been randomly selected for a beta testing survey") + end + + it "renders the body with link to survey" do + expect(mail.body.encoded).to include("Take the Beta Testing Survey") + expect(mail.body.encoded).to include("Beta details.") + expect(mail.body.encoded).to include("randomly selected to participate in a beta testing survey.") + end + end + context "when survey has HTML in extra_email_context_paragraph" do let(:survey) do create(:survey, @@ -152,5 +168,24 @@ expect(settings[:message_data]["survey_type"]).to eq("fun") end end + + context "when routed through Customer.io with a beta_testing survey" do + let(:survey) { create(:survey, title: "Beta Survey", type_of: :beta_testing) } + + before do + allow(ApplicationConfig).to receive(:[]).and_call_original + allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") + FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + end + + after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } + + it "sends the beta_testing survey type and subject" do + settings = mail.message.delivery_method.settings + + expect(settings[:message_data]["survey_type"]).to eq("beta_testing") + expect(settings[:message_data]["subject"]).to eq("You've been randomly selected for a beta testing survey") + end + end end end diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb index b09cb79175e86..0d7312ed9e2c1 100644 --- a/spec/models/article_spec.rb +++ b/spec/models/article_spec.rb @@ -181,6 +181,24 @@ def build_and_validate_article(*args) end end + describe "#before_destroy_actions" do + include ActiveSupport::Testing::TimeHelpers + let(:user) { create(:user) } + let(:article) { create(:article, user: user, published: true, type_of: "full_post") } + + it "touches the author's last_article_at when the article is destroyed" do + old_last_article_at = user.reload.last_article_at + travel_to 1.minute.from_now do + expect { article.destroy }.to change { user.reload.last_article_at }.from(old_last_article_at) + end + end + + it "does not raise an error when the article has no associated user" do + article.update_column(:user_id, nil) + expect { article.reload.destroy }.not_to raise_error + end + end + describe "#validate_video" do let(:new_user) { create(:user, created_at: 1.week.ago) } let(:old_user) { create(:user, created_at: 3.weeks.ago) } @@ -1779,6 +1797,34 @@ def build_and_validate_article(*args) end end + describe ".with_at_least_home_feed_minimum_score" do + let(:leader) { create(:user, :community_leader_level_1) } + + before do + allow(Settings::UserExperience).to receive(:home_feed_minimum_score).and_return(10) + end + + it "includes articles with score at or above the minimum score" do + article = create(:article, score: 10, featured: false, favorited_by_user: nil) + expect(described_class.with_at_least_home_feed_minimum_score).to include(article) + end + + it "excludes articles with score below the minimum score when not featured or favorited" do + article = create(:article, score: 5, featured: false, favorited_by_user: nil) + expect(described_class.with_at_least_home_feed_minimum_score).not_to include(article) + end + + it "includes featured articles even if score is below the minimum score" do + article = create(:article, score: -5, featured: true, favorited_by_user: nil) + expect(described_class.with_at_least_home_feed_minimum_score).to include(article) + end + + it "includes favorited (gemmed) articles even if score is below the minimum score" do + article = create(:article, score: -5, featured: false, favorited_by_user: leader, favorited_at: Time.current) + expect(described_class.with_at_least_home_feed_minimum_score).to include(article) + end + end + describe ".cached_admin_published_with" do let(:admin) { create(:user, :admin) } let(:cache_store) { ActiveSupport::Cache::MemoryStore.new } diff --git a/spec/models/async_info_spec.rb b/spec/models/async_info_spec.rb index a1005b734009b..a2ea48aa65521 100644 --- a/spec/models/async_info_spec.rb +++ b/spec/models/async_info_spec.rb @@ -39,6 +39,13 @@ expect(described_class.to_hash(user: user, context: context)[:favorite_allowance]).to eq(3) end + it "sends a nil favorite allowance for an admin curator" do + user.add_role(:admin) + user.add_role(:community_leader_level_1) + + expect(described_class.to_hash(user: user, context: context)[:favorite_allowance]).to be_nil + end + it "includes a list of admin_organization_ids for the current user" do org = create(:organization) create(:organization_membership, user: user, organization: org, type_of_user: "admin") diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 63c938c3ca58c..05c318724bf28 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1810,11 +1810,34 @@ def enable_sync(actor: nil) end end + describe "#unlimited_favorites?" do + it "is true for an admin who curates" do + expect(create(:user, :admin, :community_leader_level_1)).to be_unlimited_favorites + end + + it "is true for a super admin who curates" do + expect(create(:user, :super_admin, :community_leader_level_2)).to be_unlimited_favorites + end + + it "is false for an admin who does not curate" do + expect(create(:user, :admin)).not_to be_unlimited_favorites + end + + it "is false for a curator who is not an admin" do + expect(create(:user, :community_leader_level_1)).not_to be_unlimited_favorites + end + end + describe "#favorite_base_allowance" do it "is 0 for a non-leader" do expect(create(:user).favorite_base_allowance).to eq(0) end + it "is infinite for an admin curator" do + expect(create(:user, :admin, :community_leader_level_1).favorite_base_allowance) + .to eq(Float::INFINITY) + end + it "uses the level 1 setting for a level 1 leader" do allow(Settings::UserExperience).to receive(:community_leader_l1_favorite_allowance).and_return(5) expect(create(:user, :community_leader_level_1).favorite_base_allowance).to eq(5) @@ -1857,5 +1880,29 @@ def enable_sync(actor: nil) expect(leader.favorite_allowance).to eq(5) end + + it "is infinite for an admin curator, no matter what they have spent" do + allow(Settings::UserExperience).to receive_messages( + community_leader_l1_favorite_allowance: 1, community_leader_favorite_refresh_hours: 24, + ) + admin_curator = create(:user, :admin, :community_leader_level_1) + create(:article, favorited_by_user: admin_curator, favorited_at: 1.hour.ago) + + expect(admin_curator.favorite_allowance).to eq(Float::INFINITY) + end + end + + describe "#favorite_allowance_for_client" do + it "returns the numeric allowance for a metered user" do + user = create(:user, earned_favorites_count: 2) + + expect(user.favorite_allowance_for_client).to eq(2) + end + + it "returns nil for an admin curator" do + admin_curator = create(:user, :admin, :community_leader_level_1) + + expect(admin_curator.favorite_allowance_for_client).to be_nil + end end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index e6b0f9bfdbb01..9cb27629226f1 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -52,6 +52,19 @@ end end + context "when the event has chat_url data" do + it "renders the chat iframe and dark mode synchronization script" do + published_event.update!(data: { "chat_url" => "https://www.youtube.com/live_chat?v=12345" }) + + get event_path(published_event.event_name_slug, published_event.event_variation_slug) + + expect(response).to have_http_status(:success) + expect(response.body).to include("id=\"chat-frame\"") + expect(response.body).to include("dark_theme") + expect(response.body).to include("darkpopout") + end + end + context "when the event has associated articles via tags" do let(:tag) { create(:tag, name: "awstest") } let(:article) { create(:article, title: "A Custom Event Article", tag_list: tag.name, published: true) } diff --git a/spec/requests/favorites_spec.rb b/spec/requests/favorites_spec.rb index 17d9410a7e971..84ad143d1c95f 100644 --- a/spec/requests/favorites_spec.rb +++ b/spec/requests/favorites_spec.rb @@ -56,6 +56,25 @@ end end + context "when signed in as an admin curator" do + let(:admin_curator) { create(:user, :admin, :community_leader_level_1) } + + before do + allow(Settings::UserExperience) + .to receive(:community_leader_l1_favorite_allowance).and_return(0) + sign_in admin_curator + end + + it "favorites past the leader allowance and reports an unlimited balance" do + post favorites_path, params: { favoritable_type: "Article", favoritable_id: article.id }, as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body["success"]).to be(true) + expect(response.parsed_body["remaining_allowance"]).to be_nil + expect(article.reload.favorited_by_user_id).to eq(admin_curator.id) + end + end + context "when signed in as a non-leader with no earned credits" do before { sign_in create(:user) } diff --git a/spec/requests/leadership_dashboards_spec.rb b/spec/requests/leadership_dashboards_spec.rb index 8aa2084b4f190..dcbb876dda32b 100644 --- a/spec/requests/leadership_dashboards_spec.rb +++ b/spec/requests/leadership_dashboards_spec.rb @@ -68,6 +68,20 @@ end end + context "when signed in as an admin curator" do + let(:admin_curator) { create(:user, :admin, :community_leader_level_1) } + + before { sign_in admin_curator } + + it "renders unlimited gem picks allowance text" do + get curation_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include(I18n.t("views.leadership.allowance.unlimited_html")) + expect(response.body).to include(I18n.t("views.leadership.allowance.unlimited_note")) + end + end + context "when signed in as a non-leader" do it "returns 404" do sign_in create(:user) diff --git a/spec/requests/stories_index_spec.rb b/spec/requests/stories_index_spec.rb index c55372f812159..d4f4317e19b91 100644 --- a/spec/requests/stories_index_spec.rb +++ b/spec/requests/stories_index_spec.rb @@ -173,6 +173,17 @@ expect(response.body).not_to include(CGI.escapeHTML(article.title)) end + it "renders a favorited (gemmed) article even if below home feed minimum score" do + allow(Settings::UserExperience).to receive(:home_feed_minimum_score).and_return(50) + favorited_article = create(:article, score: 5, favorited_by_user: create(:user), favorited_at: Time.current) + unfavorited_low_score = create(:article, score: 5, featured: false, favorited_by_user: nil) + + get "/" + + expect(response.body).to include(CGI.escapeHTML(favorited_article.title)) + expect(response.body).not_to include(CGI.escapeHTML(unfavorited_low_score.title)) + end + def renders_proper_description expect(response.body).to include(Settings::Community.community_description) end diff --git a/spec/requests/user/user_profile_comments_spec.rb b/spec/requests/user/user_profile_comments_spec.rb new file mode 100644 index 0000000000000..89eede583cb73 --- /dev/null +++ b/spec/requests/user/user_profile_comments_spec.rb @@ -0,0 +1,189 @@ +require "rails_helper" + +RSpec.describe "User profile comments" do + let(:profile_owner) { create(:user) } + let(:viewer) { create(:user) } + let(:article) { create(:article, published: true, cached_tag_list: "ruby") } + + before do + create(:comment, + user: profile_owner, + commentable: article, + body_markdown: "Visible comment one", + deleted: false, + score: 10) + create(:comment, + user: profile_owner, + commentable: article, + body_markdown: "Visible comment two", + deleted: false, + score: 0) + create(:comment, + user: profile_owner, + commentable: article, + body_markdown: "Deleted comment", + deleted: true, + score: 10) + create(:comment, + user: profile_owner, + commentable: article, + body_markdown: "Low quality comment", + deleted: false, + score: -100) + end + + describe "GET /:username" do + context "when visitor is signed in" do + before do + sign_in viewer + end + + it "uses the same visible comment set for the sidebar count and comments section" do + get "/#{profile_owner.username}" + expect(response).to have_http_status(:ok) + + doc = Nokogiri::HTML(response.body) + sidebar_comment_row = doc.css("div.crayons-card--secondary.p-4 div.flex.items-center.mb-4") + .detect { |row| row.text.match?(/comments/i) } + expect(sidebar_comment_row).to be_present + + sidebar_comment_count = sidebar_comment_row.text[/\d[\d,]*/]&.delete(",")&.to_i + expect(sidebar_comment_count).to eq(2) + expect(doc.css("a.profile-comment-row").count).to eq(2) + + expect(response.body).to include("Visible comment one") + expect(response.body).to include("Visible comment two") + expect(response.body).not_to include("Deleted comment") + expect(response.body).not_to include("Low quality comment") + end + end + + context "when visitor is not signed in" do + it "displays the comments locked CTA and consistent sidebar count" do + get "/#{profile_owner.username}" + expect(response).to have_http_status(:ok) + + doc = Nokogiri::HTML(response.body) + sidebar_comment_row = doc.css("div.crayons-card--secondary.p-4 div.flex.items-center.mb-4") + .detect { |row| row.text.match?(/comments/i) } + expect(sidebar_comment_row).to be_present + + sidebar_comment_count = sidebar_comment_row.text[/\d[\d,]*/]&.delete(",")&.to_i + expect(sidebar_comment_count).to eq(2) + expect(doc.css("#comments-locked-cta")).to be_present + end + end + + context "when user has zero comments" do + let(:user_without_comments) { create(:user) } + + it "displays 0 comments in the sidebar and does not display the comments card" do + sign_in viewer + get "/#{user_without_comments.username}" + expect(response).to have_http_status(:ok) + + doc = Nokogiri::HTML(response.body) + sidebar_comment_row = doc.css("div.crayons-card--secondary.p-4 div.flex.items-center.mb-4") + .detect { |row| row.text.match?(/comments/i) } + expect(sidebar_comment_row).to be_present + + sidebar_comment_count = sidebar_comment_row.text[/\d[\d,]*/]&.delete(",")&.to_i + expect(sidebar_comment_count).to eq(0) + expect(doc.css("a.profile-comment-row")).to be_empty + expect(doc.css("#comments-locked-cta")).not_to be_present + end + end + + context "when comments exist on unpublished articles" do + let(:draft_article) { create(:article, published: true) } + + before do + create(:comment, + user: profile_owner, + commentable: draft_article, + body_markdown: "Draft article comment", + deleted: false, + score: 10) + draft_article.update_columns(published: false) + sign_in viewer + end + + it "excludes comments on unpublished articles from sidebar count and comments section" do + get "/#{profile_owner.username}" + expect(response).to have_http_status(:ok) + + doc = Nokogiri::HTML(response.body) + sidebar_comment_row = doc.css("div.crayons-card--secondary.p-4 div.flex.items-center.mb-4") + .detect { |row| row.text.match?(/comments/i) } + + sidebar_comment_count = sidebar_comment_row.text[/\d[\d,]*/]&.delete(",")&.to_i + expect(sidebar_comment_count).to eq(2) + expect(doc.css("a.profile-comment-row").count).to eq(2) + expect(response.body).not_to include("Draft article comment") + end + end + + context "when comments exist in another subforem" do + let(:other_subforem) { create(:subforem) } + let(:subforem_article) { create(:article, published: true, subforem_id: other_subforem.id) } + + before do + create(:subforem) + create(:comment, + user: profile_owner, + commentable: subforem_article, + body_markdown: "Other subforem comment", + deleted: false, + score: 10) + sign_in viewer + end + + after do + RequestStore.store[:subforem_id] = nil + RequestStore.store[:default_subforem_id] = nil + RequestStore.store[:root_subforem_id] = nil + end + + it "excludes comments from different subforems" do + get "/#{profile_owner.username}" + expect(response).to have_http_status(:ok) + + doc = Nokogiri::HTML(response.body) + sidebar_comment_row = doc.css("div.crayons-card--secondary.p-4 div.flex.items-center.mb-4") + .detect { |row| row.text.match?(/comments/i) } + + sidebar_comment_count = sidebar_comment_row.text[/\d[\d,]*/]&.delete(",")&.to_i + expect(sidebar_comment_count).to eq(2) + expect(doc.css("a.profile-comment-row").count).to eq(2) + expect(response.body).not_to include("Other subforem comment") + end + end + end + + describe "GET /:username?view=comments" do + context "when visitor is signed in" do + before { sign_in viewer } + + it "renders the comments section and appropriate header" do + get "/#{profile_owner.username}?view=comments" + expect(response).to have_http_status(:ok) + + doc = Nokogiri::HTML(response.body) + expect(doc.css("a.profile-comment-row").count).to eq(2) + expect(doc.css("#comments-locked-cta")).not_to be_present + expect(response.body).to include(I18n.t("views.users.comments.all.other")) + end + end + + context "when visitor is not signed in" do + it "renders the locked CTA" do + get "/#{profile_owner.username}?view=comments" + expect(response).to have_http_status(:ok) + + doc = Nokogiri::HTML(response.body) + expect(doc.css("#comments-locked-cta")).to be_present + expect(doc.css("a.profile-comment-row")).to be_empty + end + end + end +end diff --git a/spec/services/articles/feeds/large_forem_experimental_spec.rb b/spec/services/articles/feeds/large_forem_experimental_spec.rb index 623fce4f7f891..d60bb4178f362 100644 --- a/spec/services/articles/feeds/large_forem_experimental_spec.rb +++ b/spec/services/articles/feeds/large_forem_experimental_spec.rb @@ -29,6 +29,17 @@ expect(stories).to include(min_score_article) end + it "includes favorited articles even if their score is below the home feed minimum score" do + favorited_low_score_article = create(:article, score: -100, favorited_by_user: create(:user), + favorited_at: Time.current) + expect(stories).to include(favorited_low_score_article) + end + + it "includes featured articles even if their score is below the home feed minimum score" do + featured_low_score_article = create(:article, score: -100, featured: true) + expect(stories).to include(featured_low_score_article) + end + context "when user logged in" do let(:result) { feed.featured_story_and_default_home_feed(user_signed_in: true) } let(:featured_story) { result.first } diff --git a/spec/services/favorites/create_spec.rb b/spec/services/favorites/create_spec.rb index b60c8ed36797c..4ea7d3111ddd3 100644 --- a/spec/services/favorites/create_spec.rb +++ b/spec/services/favorites/create_spec.rb @@ -117,6 +117,33 @@ end end + describe "favoriting by admin curators" do + let(:admin_curator) { create(:user, :admin, :community_leader_level_1) } + + before do + allow(Settings::UserExperience) + .to receive(:community_leader_l1_favorite_allowance).and_return(1) + end + + it "keeps favoriting past the leader budget" do + described_class.call(favoritable: create(:article, user: author), user: admin_curator) + + result = described_class.call(favoritable: article, user: admin_curator) + + expect(result.success?).to be true + expect(article.reload.favorited_by_user_id).to eq(admin_curator.id) + end + + it "leaves an admin who does not curate metered" do + admin = create(:user, :admin) + + result = described_class.call(favoritable: article, user: admin) + + expect(result.error).to eq(:no_allowance) + expect(article.reload.favorited_by_user_id).to be_nil + end + end + describe "favoriting by regular users" do it "lets the user spend earned credits" do spender = create(:user, earned_favorites_count: 1) diff --git a/swagger/v1/api_v1.json b/swagger/v1/api_v1.json index de7df21fbf1c5..007d421db145b 100644 --- a/swagger/v1/api_v1.json +++ b/swagger/v1/api_v1.json @@ -7320,7 +7320,8 @@ "enum": [ "community_pulse", "industry", - "fun" + "fun", + "beta_testing" ], "description": "Survey category" }, @@ -7418,7 +7419,8 @@ "enum": [ "community_pulse", "industry", - "fun" + "fun", + "beta_testing" ], "description": "Survey category" }, @@ -7427,7 +7429,8 @@ "enum": [ "community_pulse", "industry", - "fun" + "fun", + "beta_testing" ], "description": "Survey category (alias of survey_type_of)" },