docbrown/app/controllers/stories_controller.rb
Jeremy Friesen fbce226c08
Low Stakes Refactor of Feed class (#15180)
* Removing state change and unused method

Prior to this commit, the `@comment_weight` value would change.  This is
not ideal as depending on the call sequence, can notably change the
output.  I suspect this state change occurred so as to not alter an
underlying spec.

What this change does is remove the state change, removes a dead method,
renames a method (to the dead method name), and leverages
parameterization to better test a spec that was brittle based on
possible state changes.

I believe, from a logical stand point, that this change does not impact
the functionality nor the actual logic that is part of the production
call path.

* Marking methods as @api private

The goal is to highlight that we really shouldn't be calling these
outside of their contained class.  Ideally, I'd love to make them
private methods, but there are specs and would prefer to not use
`__send__` to change those specs.

This is a noop change.

* Renaming method to refelct returned param order

Prior to this commit the "default_home_feed_and_featured_story" returned
an array of `[featured_story, default_home_feed]`.  The method name and
the order of the returned values were misaligned.  This change helps
align the method name and the order of those returned values.

Note, methods such as `each_with_index` have an `element, index`
parameter order.
2021-10-26 09:57:33 -04:00

423 lines
14 KiB
Ruby

class StoriesController < ApplicationController
helper ProfileHelper
DEFAULT_HOME_FEED_ATTRIBUTES_FOR_SERIALIZATION = {
only: %i[
title path id user_id comments_count public_reactions_count organization_id
reading_time video_thumbnail_url video video_duration_in_minutes
experience_level_rating experience_level_rating_distribution cached_user cached_organization
listing_category_id
],
methods: %i[
readable_publish_date cached_tag_list_array flare_tag class_name
cloudinary_video_url video_duration_in_minutes published_at_int published_timestamp
]
}.freeze
SIGNED_OUT_RECORD_COUNT = 60
before_action :authenticate_user!, except: %i[index show]
before_action :set_cache_control_headers, only: %i[index show]
before_action :redirect_to_lowercase_username, only: %i[index]
rescue_from ArgumentError, with: :bad_request
def index
@page = (params[:page] || 1).to_i
@article_index = true
return handle_user_or_organization_or_podcast_or_page_index if params[:username]
handle_base_index
end
def show
@story_show = true
path = "/#{params[:username].downcase}/#{params[:slug]}"
if (@article = Article.includes(:user).find_by(path: path)&.decorate)
handle_article_show
elsif (@article = Article.find_by(slug: params[:slug])&.decorate)
handle_possible_redirect
else
@podcast = Podcast.available.find_by!(slug: params[:username])
@episode = @podcast.podcast_episodes.available.find_by!(slug: params[:slug])
handle_podcast_show
end
end
private
def assign_hero_html
return if Campaign.current.hero_html_variant_name.blank?
@hero_area = HtmlVariant.relevant.select(:name, :html)
.find_by(group: "campaign", name: Campaign.current.hero_html_variant_name)
@hero_html = @hero_area&.html
end
def get_latest_campaign_articles
campaign_articles_scope = Article.tagged_with(Campaign.current.featured_tags, any: true)
.where("published_at > ? AND score > ?", Settings::Campaign.articles_expiry_time.weeks.ago, 0)
.order(hotness_score: :desc)
requires_approval = Campaign.current.articles_require_approval?
campaign_articles_scope = campaign_articles_scope.where(approved: true) if requires_approval
@campaign_articles_count = campaign_articles_scope.count
@latest_campaign_articles = campaign_articles_scope.limit(5).pluck(:path, :title, :comments_count, :created_at)
end
def redirect_to_changed_username_profile
potential_username = params[:username].tr("@", "")
user_or_org = User.find_by("old_username = ? OR old_old_username = ?", potential_username, potential_username) ||
Organization.find_by("old_slug = ? OR old_old_slug = ?", potential_username, potential_username)
if user_or_org.present? && !user_or_org.decorate.fully_banished?
redirect_permanently_to(user_or_org.path)
else
not_found
end
end
def handle_possible_redirect
if @article.organization
redirect_permanently_to(@article.path)
return
end
potential_username = params[:username].tr("@", "").downcase
@user = User.find_by("old_username = ? OR old_old_username = ?", potential_username, potential_username)
if @user&.articles&.find_by(slug: params[:slug])
redirect_permanently_to(URI.parse("/#{@user.username}/#{params[:slug]}").path)
return
end
not_found
end
def handle_user_or_organization_or_podcast_or_page_index
@podcast = Podcast.available.find_by(slug: params[:username])
@organization = Organization.find_by(slug: params[:username])
@page = Page.find_by(slug: params[:username], is_top_level_path: true)
if @podcast
Honeycomb.add_field("stories_route", "podcast")
handle_podcast_index
elsif @organization
Honeycomb.add_field("stories_route", "org")
handle_organization_index
elsif @page
if FeatureFlag.accessible?(@page.feature_flag_name, current_user)
Honeycomb.add_field("stories_route", "page")
handle_page_display
else
not_found
end
else
Honeycomb.add_field("stories_route", "user")
handle_user_index
end
end
def handle_page_display
@story_show = true
set_surrogate_key_header "show-page-#{params[:username]}"
if @page.template == "json"
render json: @page.body_json
else
render template: "pages/show"
end
end
def handle_base_index
@home_page = true
assign_feed_stories unless user_signed_in? # Feed fetched async for signed-in users
assign_hero_html
assign_podcasts
get_latest_campaign_articles if Campaign.current.show_in_sidebar?
@article_index = true
set_surrogate_key_header "main_app_home_page"
set_cache_control_headers(600,
stale_while_revalidate: 30,
stale_if_error: 86_400)
render template: "articles/index"
end
def pinned_article
@pinned_article ||= PinnedArticle.get
end
def featured_story
@featured_story ||= Articles::Feeds::FindFeaturedStory.call(@stories)
end
def handle_podcast_index
@podcast_index = true
@list_of = "podcast-episodes"
@podcast_episodes = @podcast.podcast_episodes
.reachable.order(published_at: :desc).page(params[:page]).per(30)
set_surrogate_key_header "podcast_episodes"
render template: "podcast_episodes/index"
end
def handle_organization_index
@user = @organization
@stories = ArticleDecorator.decorate_collection(@organization.articles.published
.limited_column_select
.order(published_at: :desc).page(@page).per(8))
@organization_article_index = true
set_organization_json_ld
set_surrogate_key_header "articles-org-#{@organization.id}"
render template: "organizations/show"
end
def handle_user_index
@user = User.find_by(username: params[:username].tr("@", ""))
unless @user
redirect_to_changed_username_profile
return
end
not_found if @user.username.include?("spam_") && @user.decorate.fully_banished?
not_found unless @user.registered
assign_user_comments
assign_user_stories
@list_of = "articles"
redirect_if_view_param
return if performed?
assign_user_github_repositories
# @badges_limit is here and is set to 6 because it determines how many badges we will display
# on Profile sidebar widget. If user has more badges, we hide them and let them be revealed
# by clicking "See more" button (because we want to save space etc..). But why 6 exactly?
# To make that widget look good:
# - On desktop it will have 3 rows, each row with 2 badges.
# - On mobile it will have 2 rows, each row with 3 badges.
# So it's always 6. If we make it higher or lower number, we would have to sacrifice UI:
# - Let's say it's `4`. On mobile it would display two rows: 1st with 3 badges and
# 2nd with 1 badge (!) <-- and that would look off.
@badges_limit = 6
@profile = @user.profile.decorate
@is_user_flagged = Reaction.where(user_id: session_current_user_id, reactable: @user).any?
set_surrogate_key_header "articles-user-#{@user.id}"
set_user_json_ld
render template: "users/show"
end
def handle_podcast_show
set_surrogate_key_header @episode.record_key
@episode = @episode.decorate
@podcast_episode_show = true
@comments_to_show_count = 25
@comment = Comment.new
render template: "podcast_episodes/show"
nil
end
def redirect_if_view_param
redirect_to admin_user_path(@user.id) if params[:view] == "moderate"
redirect_to edit_admin_user_path(@user.id) if params[:view] == "admin"
end
def redirect_if_show_view_param
redirect_to admin_article_path(@article.id) if params[:view] == "moderate"
end
def handle_article_show
assign_article_show_variables
set_surrogate_key_header @article.record_key
redirect_if_show_view_param
return if performed?
render template: "articles/show"
end
def assign_feed_stories
if params[:timeframe].in?(Timeframe::FILTER_TIMEFRAMES)
@stories = Articles::Feeds::Timeframe.call(params[:timeframe])
elsif params[:timeframe] == Timeframe::LATEST_TIMEFRAME
@stories = Articles::Feeds::Latest.call
else
@default_home_feed = true
feed = Articles::Feeds::LargeForemExperimental.new(page: @page, tag: params[:tag])
@featured_story, @stories = feed.featured_story_and_default_home_feed(user_signed_in: user_signed_in?)
end
@pinned_article = pinned_article&.decorate
@featured_story = (featured_story || Article.new)&.decorate
@stories = ArticleDecorator.decorate_collection(@stories)
end
def assign_article_show_variables
not_found if permission_denied?
not_found unless @article.user
@pinned_article_id = PinnedArticle.id
@article_show = true
@discussion_lock = @article.discussion_lock
@user = @article.user
@organization = @article.organization
if @article.collection
@collection = @article.collection
# we need to make sure that articles that were cross posted after their
# original publication date appear in the correct order in the collection,
# considering non cross posted articles with a more recent publication date
@collection_articles = @article.collection.articles
.published
.order(Arel.sql("COALESCE(crossposted_at, published_at) ASC"))
end
@comments_to_show_count = @article.cached_tag_list_array.include?("discuss") ? 50 : 30
set_article_json_ld
assign_co_authors
@comment = Comment.new(body_markdown: @article&.comment_template)
end
def permission_denied?
!@article.published && params[:preview] != @article.password
end
def assign_co_authors
return if @article.co_author_ids.blank?
@co_author_ids = User.find(@article.co_author_ids)
end
def assign_user_comments
comment_count = helpers.comment_count(params[:view])
@comments = if @user.comments_count.positive?
@user.comments.where(deleted: false)
.order(created_at: :desc).includes(:commentable).limit(comment_count)
else
[]
end
end
def assign_user_stories
@pinned_stories = Article.published.where(id: @user.profile_pins.select(:pinnable_id))
.limited_column_select
.order(published_at: :desc).decorate
@stories = ArticleDecorator.decorate_collection(@user.articles.published
.limited_column_select
.where.not(id: @pinned_stories.map(&:id))
.order(published_at: :desc).page(@page).per(user_signed_in? ? 2 : SIGNED_OUT_RECORD_COUNT))
end
def assign_user_github_repositories
@github_repositories = @user.github_repos.featured.order(stargazers_count: :desc, name: :asc)
end
def assign_podcasts
return unless user_signed_in?
@podcast_episodes = PodcastEpisode
.includes(:podcast)
.order(published_at: :desc)
.where("published_at > ?", 24.hours.ago)
.select(:slug, :title, :podcast_id, :image)
end
def redirect_to_lowercase_username
return unless params[:username] && params[:username]&.match?(/[[:upper:]]/)
redirect_permanently_to("/#{params[:username].downcase}")
end
def set_user_json_ld
# For more info on structuring data with JSON-LD,
# please refer to this link: https://moz.com/blog/json-ld-for-beginners
decorated_user = @user.decorate
@user_json_ld = {
"@context": "http://schema.org",
"@type": "Person",
mainEntityOfPage: {
"@type": "WebPage",
"@id": URL.user(@user)
},
url: URL.user(@user),
sameAs: user_same_as,
image: Images::Profile.call(@user.profile_image_url, length: 320),
name: @user.name,
email: decorated_user.profile_email,
description: decorated_user.profile_summary
}.reject { |_, v| v.blank? }
end
def set_article_json_ld
@article_json_ld = {
"@context": "http://schema.org",
"@type": "Article",
mainEntityOfPage: {
"@type": "WebPage",
"@id": URL.article(@article)
},
url: URL.article(@article),
image: seo_optimized_images,
publisher: {
"@context": "http://schema.org",
"@type": "Organization",
name: Settings::Community.community_name.to_s,
logo: {
"@context": "http://schema.org",
"@type": "ImageObject",
url: ApplicationController.helpers.optimized_image_url(Settings::General.logo_png, width: 192,
fetch_format: "png"),
width: "192",
height: "192"
}
},
headline: @article.title,
author: {
"@context": "http://schema.org",
"@type": "Person",
url: URL.user(@user),
name: @user.name
},
datePublished: @article.published_timestamp,
dateModified: @article.edited_at&.iso8601 || @article.published_timestamp
}
end
def seo_optimized_images
# This array of images exists for SEO optimization purposes.
# For more info on this structure, please refer to this documentation:
# https://developers.google.com/search/docs/data-types/article
[
ApplicationController.helpers.article_social_image_url(@article, width: 1080, height: 1080),
ApplicationController.helpers.article_social_image_url(@article, width: 1280, height: 720),
ApplicationController.helpers.article_social_image_url(@article, width: 1600, height: 900),
]
end
def set_organization_json_ld
@organization_json_ld = {
"@context": "http://schema.org",
"@type": "Organization",
mainEntityOfPage: {
"@type": "WebPage",
"@id": URL.organization(@organization)
},
url: URL.organization(@organization),
image: Images::Profile.call(@organization.profile_image_url, length: 320),
name: @organization.name,
description: @organization.summary.presence || "404 bio not found"
}
end
def user_same_as
# For further information on the sameAs property, please refer to this link:
# https://schema.org/sameAs
[
@user.twitter_username.present? ? "https://twitter.com/#{@user.twitter_username}" : nil,
@user.github_username.present? ? "https://github.com/#{@user.github_username}" : nil,
@user.profile.website_url,
].reject(&:blank?)
end
end