Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/favorites_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/leadership_dashboards_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
18 changes: 14 additions & 4 deletions app/controllers/stories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/helpers/favorites_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
21 changes: 13 additions & 8 deletions app/javascript/favoriteControl/FavoriteControl.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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!',
Expand All @@ -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 (
Expand Down Expand Up @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions app/javascript/favoriteControl/__tests__/FavoriteControl.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,46 @@ describe('<FavoriteControl />', () => {
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 },
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/packs/favoriteControls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function initializeFavoriteControls(currentUser) {
modalRemainingZero,
modalRemainingOne,
modalRemainingOther,
modalRemainingUnlimited,
modalClose,
modalExhaustedTitle,
modalExhaustedBody,
Expand All @@ -50,6 +51,7 @@ function initializeFavoriteControls(currentUser) {
modalRemainingZero={modalRemainingZero}
modalRemainingOne={modalRemainingOne}
modalRemainingOther={modalRemainingOther}
modalRemainingUnlimited={modalRemainingUnlimited}
modalClose={modalClose}
modalExhaustedTitle={modalExhaustedTitle}
modalExhaustedBody={modalExhaustedBody}
Expand Down
10 changes: 8 additions & 2 deletions app/lib/black_box.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion app/mailers/survey_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
},
)

Expand Down
14 changes: 7 additions & 7 deletions app/models/article.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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..),
)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/models/async_info.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion app/models/survey.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/services/favorites/create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions app/views/events/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,51 @@
<iframe id="chat-frame" width="100%" height="100%" src="<%= chat_url %>" frameborder="0" title="Event Chat" loading="lazy">
</iframe>
</div>
<script>
(function() {
const chatFrame = document.getElementById("chat-frame");
if (!chatFrame) return;

function isDark() {
return document.body.classList.contains("dark-theme") ||
(localStorage.getItem("config_body_class") || "").includes("dark-theme");
}

function applyThemeToChatUrl() {
try {
const url = new URL(chatFrame.src);
const dark = isDark();

if (url.hostname.includes("youtube.com")) {

Check failure

Code scanning / CodeQL

Incomplete URL substring sanitization High

'
youtube.com
' can be anywhere in the URL, and arbitrary hosts may come before or after it.
if (dark) {
url.searchParams.set("dark_theme", "1");
} else {
url.searchParams.delete("dark_theme");
}
} else if (url.hostname.includes("twitch.tv")) {
if (dark) {
url.searchParams.set("darkpopout", "");
} else {
url.searchParams.delete("darkpopout");
}
}

if (url.toString() !== chatFrame.src) {
chatFrame.src = url.toString();
}
} catch (e) {
// ignore invalid URLs
}
}

applyThemeToChatUrl();

const observer = new MutationObserver(function() {
applyThemeToChatUrl();
});
observer.observe(document.body, { attributes: true, attributeFilter: ["class"] });
})();
</script>
<% end %>
</div>

Expand Down
8 changes: 6 additions & 2 deletions app/views/leadership_dashboards/_sidebar.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
<h2 class="fs-base fw-bold m-0"><%= t("views.leadership.icon.leader_alt") %></h2>
</div>
<p class="fs-s mb-2">
<%= 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 %>
</p>
<p class="fs-xs color-base-60 m-0">
<%= t("views.leadership.allowance.replenish_note") %>
<%= @unlimited_favorites ? t("views.leadership.allowance.unlimited_note") : t("views.leadership.allowance.replenish_note") %>
</p>
</div>

Expand Down
Loading
Loading