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