diff --git a/Gemfile b/Gemfile index 1ca351528..d5ca99bea 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,6 @@ gem "ddtrace", "~> 0.32.0" # ddtrace is Datadog’s tracing client for Ruby. gem "devise", "~> 4.7" # Flexible authentication solution for Rails gem "dogstatsd-ruby", "~> 4.7" # A client for DogStatsD, an extension of the StatsD metric server for Datadog gem "doorkeeper", "~> 5.3" # Oauth 2 provider -gem "draper", "~> 4.0" # Draper adds an object-oriented layer of presentation logic to your Rails apps gem "dry-struct", "~> 1.2" # Typed structs and value objects gem "elasticsearch", "~> 7.4" # Powers DEVs core search functionality gem "email_validator", "~> 2.0" # Email validator for Rails and ActiveModel diff --git a/Gemfile.lock b/Gemfile.lock index ec747d79d..231ae56ab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,10 +39,6 @@ GEM globalid (>= 0.3.6) activemodel (5.2.4.1) activesupport (= 5.2.4.1) - activemodel-serializers-xml (1.0.2) - activemodel (> 5.x) - activesupport (> 5.x) - builder (~> 3.1) activerecord (5.2.4.1) activemodel (= 5.2.4.1) activesupport (= 5.2.4.1) @@ -233,12 +229,6 @@ GEM unf (>= 0.0.5, < 1.0.0) doorkeeper (5.3.1) railties (>= 5) - draper (4.0.0) - actionpack (>= 5.0) - activemodel (>= 5.0) - activemodel-serializers-xml (>= 1.0) - activesupport (>= 5.0) - request_store (>= 1.0) dry-configurable (0.11.1) concurrent-ruby (~> 1.0) dry-core (~> 0.4, >= 0.4.7) @@ -887,7 +877,6 @@ DEPENDENCIES devise (~> 4.7) dogstatsd-ruby (~> 4.7) doorkeeper (~> 5.3) - draper (~> 4.0) dry-struct (~> 1.2) elasticsearch (~> 7.4) email_validator (~> 2.0) diff --git a/app/controllers/api/v0/articles_controller.rb b/app/controllers/api/v0/articles_controller.rb index 99a34940d..1bf6ca817 100644 --- a/app/controllers/api/v0/articles_controller.rb +++ b/app/controllers/api/v0/articles_controller.rb @@ -29,7 +29,8 @@ module Api end def create - @article = Articles::Creator.call(@user, article_params) + @article = Articles::Creator.call(@user, article_params).decorate + if @article.persisted? render "show", status: :created, location: @article.url else diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb index b84956e5e..c3f45722d 100644 --- a/app/controllers/articles_controller.rb +++ b/app/controllers/articles_controller.rb @@ -88,14 +88,16 @@ class ArticlesController < ApplicationController def edit authorize @article + @version = @article.has_frontmatter? ? "v1" : "v2" @user = @article.user @organizations = @user&.organizations end def manage - @article = @article.decorate authorize @article + + @article = @article.decorate @user = @article.user @rating_vote = RatingVote.where(article_id: @article.id, user_id: @user.id).first @buffer_updates = BufferUpdate.where(composer_user_id: @user.id, article_id: @article.id) @@ -136,13 +138,12 @@ class ArticlesController < ApplicationController def create authorize Article - @user = current_user - @article = Articles::Creator.call(@user, article_params_json) + article = Articles::Creator.call(current_user, article_params_json) - render json: if @article.persisted? - @article.to_json(only: [:id], methods: [:current_state_path]) + render json: if article.persisted? + { id: article.id, current_state_path: article.decorate.current_state_path }.to_json else - @article.errors.to_json + article.errors.to_json end end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index cf66526b7..b46559352 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -1,4 +1,16 @@ class StoriesController < ApplicationController + DEFAULT_HOME_FEED_ATTRIBUTES_FOR_SERIALIZATION = { + only: %i[ + title path id user_id comments_count positive_reactions_count organization_id + reading_time video_thumbnail_url video video_duration_in_minutes language + experience_level_rating experience_level_rating_distribution cached_user cached_organization + ], + 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 + before_action :authenticate_user!, except: %i[index search show] before_action :set_cache_control_headers, only: %i[index search show] diff --git a/app/decorators/application_decorator.rb b/app/decorators/application_decorator.rb index 5caf08b69..804aebb03 100644 --- a/app/decorators/application_decorator.rb +++ b/app/decorators/application_decorator.rb @@ -1,8 +1,20 @@ -class ApplicationDecorator < Draper::Decorator - # Define methods for all decorated objects. - # Helpers are accessed through `helpers` (aka `h`). For example: - # - # def percent_amount - # h.number_to_percentage object.amount, precision: 2 - # end +class ApplicationDecorator + include ActiveModel::Serialization + include ActiveModel::Serializers::JSON + + delegate_missing_to :@object + + attr_reader :object + + def initialize(object) + @object = object + end + + def decorated? + true + end + + def self.decorate_collection(objects) + objects.map(&:decorate) + end end diff --git a/app/decorators/article_decorator.rb b/app/decorators/article_decorator.rb index 044beba00..f05865408 100644 --- a/app/decorators/article_decorator.rb +++ b/app/decorators/article_decorator.rb @@ -1,6 +1,4 @@ class ArticleDecorator < ApplicationDecorator - delegate_all - def current_state_path published ? "/#{username}/#{slug}" : "/#{username}/#{slug}?preview=#{password}" end @@ -26,13 +24,13 @@ class ArticleDecorator < ApplicationDecorator end def title_length_classification - if article.title.size > 105 + if title.size > 105 "longest" - elsif article.title.size > 80 + elsif title.size > 80 "longer" - elsif article.title.size > 60 + elsif title.size > 60 "long" - elsif article.title.size > 22 + elsif title.size > 22 "medium" else "short" @@ -40,12 +38,15 @@ class ArticleDecorator < ApplicationDecorator end def internal_utm_params(place = "additional_box") + org_slug = organization&.slug + campaign = if boosted_additional_articles - "#{organization&.slug}_boosted" + "#{org_slug}_boosted" else "regular" end - "?utm_source=#{place}&utm_medium=internal&utm_campaign=#{campaign}&booster_org=#{organization&.slug}" + + "?utm_source=#{place}&utm_medium=internal&utm_campaign=#{campaign}&booster_org=#{org_slug}" end def published_at_int diff --git a/app/decorators/comment_decorator.rb b/app/decorators/comment_decorator.rb index 10f37e737..3f5d5c2ff 100644 --- a/app/decorators/comment_decorator.rb +++ b/app/decorators/comment_decorator.rb @@ -1,6 +1,4 @@ class CommentDecorator < ApplicationDecorator - delegate_all - LOW_QUALITY_THRESHOLD = -75 def low_quality diff --git a/app/decorators/notification_decorator.rb b/app/decorators/notification_decorator.rb index 6e994653f..4c40cbd78 100644 --- a/app/decorators/notification_decorator.rb +++ b/app/decorators/notification_decorator.rb @@ -1,21 +1,30 @@ -class NotificationDecorator < Draper::Decorator - delegate_all - - def mocked_object(type) - struct = Struct.new(:name, :id) do - def class - second_struct = Struct.new(:name) - second_struct.new(name) - end +class NotificationDecorator < ApplicationDecorator + NOTIFIABLE_STUB = Struct.new(:name, :id) do + def class + Struct.new(:name).new(name) end - struct.new(json_data[type]["class"]["name"], json_data[type]["id"]) + end.freeze + + # returns a stub notifiable object with name and id + def mocked_object(type) + return NOTIFIABLE_STUB.new("", nil) if json_data.blank? + + NOTIFIABLE_STUB.new(json_data[type]["class"]["name"], json_data[type]["id"]) end + # returns the type of a milestone notification action, + # eg. "Milestone::Reaction::64" def milestone_type - action.split("::")[1] + return "" if action.blank? + + action.split("::").second end + # returns the count of a milestone notification action, + # eg. "Milestone::Reaction::64" def milestone_count - action.split("::")[2] + return "" if action.blank? + + action.split("::").third end end diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index c6b130ad3..1ccab9a08 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -1,6 +1,4 @@ class OrganizationDecorator < ApplicationDecorator - delegate_all - def darker_color(adjustment = 0.88) HexComparer.new([enriched_colors[:bg], enriched_colors[:text]]).brightness(adjustment) end @@ -13,8 +11,8 @@ class OrganizationDecorator < ApplicationDecorator } else { - bg: bg_color_hex || assigned_color[:bg], - text: text_color_hex || assigned_color[:text] + bg: bg_color_hex, + text: text_color_hex.presence || assigned_color[:text] } end end diff --git a/app/decorators/podcast_episode_decorator.rb b/app/decorators/podcast_episode_decorator.rb index c95dac37e..4fc8ed2d8 100644 --- a/app/decorators/podcast_episode_decorator.rb +++ b/app/decorators/podcast_episode_decorator.rb @@ -1,16 +1,17 @@ class PodcastEpisodeDecorator < ApplicationDecorator - delegate_all - def comments_to_show_count cached_tag_list_array.include?("discuss") ? 75 : 25 end + # this method exists because podcast episodes are "commentables" + # and in some parts of the code we assume they have this method, + # but podcast episodes don't have a cached_tag_list like articles do def cached_tag_list_array - (tag_list || "").split(", ") + tag_list end def readable_publish_date - return unless published_at + return "" unless published_at if published_at.year == Time.current.year published_at.strftime("%b %e") @@ -20,6 +21,8 @@ class PodcastEpisodeDecorator < ApplicationDecorator end def published_timestamp - published_at&.utc&.iso8601 + return "" unless published_at + + published_at.utc.iso8601 end end diff --git a/app/decorators/reaction_decorator.rb b/app/decorators/reaction_decorator.rb deleted file mode 100644 index 3a80a79bd..000000000 --- a/app/decorators/reaction_decorator.rb +++ /dev/null @@ -1 +0,0 @@ -class ReactionDecorator < ApplicationDecorator; end diff --git a/app/decorators/sponsorship_decorator.rb b/app/decorators/sponsorship_decorator.rb index 02c083a27..2cd5bfba3 100644 --- a/app/decorators/sponsorship_decorator.rb +++ b/app/decorators/sponsorship_decorator.rb @@ -1,12 +1,11 @@ class SponsorshipDecorator < ApplicationDecorator - delegate_all - - def level_color_hex + def level_background_color hexes = { "gold" => "linear-gradient(to right, #faf0e6 8%, #faf3e6 18%, #fcf6eb 33%);", "silver" => "linear-gradient(to right, #e3e3e3 8%, #f0eded 18%, #e8e8e8 33%);", "bronze" => "linear-gradient(to right, #ebe2d3 8%, #f5eee1 18%, #ede6d8 33%);" } - hexes[level] + + hexes[level].to_s end end diff --git a/app/decorators/user_decorator.rb b/app/decorators/user_decorator.rb index 9749ca3ce..c583dff34 100644 --- a/app/decorators/user_decorator.rb +++ b/app/decorators/user_decorator.rb @@ -18,15 +18,14 @@ class UserDecorator < ApplicationDecorator }, ].freeze - delegate_all - def cached_followed_tags Rails.cache.fetch("user-#{id}-#{updated_at}/followed_tags_11-30", expires_in: 20.hours) do - follows_query = Follow.where(follower_id: id, followable_type: "ActsAsTaggableOn::Tag").pluck(:followable_id, :points) - tags = Tag.where(id: follows_query.map { |f| f[0] }).order("hotness_score DESC") - tags.each do |t| - follow_query_item = follows_query.detect { |f| f[0] == t.id } - t.points = follow_query_item[1] + follows = Follow.where(follower_id: id, followable_type: "ActsAsTaggableOn::Tag").pluck(:followable_id, :points) + follows_map = follows.to_h + + tags = Tag.where(id: follows_map.keys).order(hotness_score: :desc) + tags.each do |tag| + tag.points = follows_map[tag.id] end tags end @@ -44,20 +43,21 @@ class UserDecorator < ApplicationDecorator } else { - bg: bg_color_hex || assigned_color[:bg], - text: text_color_hex || assigned_color[:text] + bg: bg_color_hex, + text: text_color_hex } end end def config_body_class - body_class = "" - body_class += config_theme.tr("_", "-") - body_class += " #{config_font.tr('_', '-')}-article-body" - body_class += " pro-status-#{pro?}" - body_class += " trusted-status-#{trusted}" - body_class += " #{config_navbar.tr('_', '-')}-navbar-config" - body_class + body_class = [ + config_theme.tr("_", "-"), + "#{config_font.tr('_', '-')}-article-body", + "pro-status-#{pro?}", + "trusted-status-#{trusted}", + "#{config_navbar.tr('_', '-')}-navbar-config", + ] + body_class.join(" ") end def dark_theme? @@ -95,11 +95,9 @@ class UserDecorator < ApplicationDecorator colors[id % 10] end + # returns true if the user has been suspended and has no content def fully_banished? - # User suspended and has no content - articles_count.zero? && - comments_count.zero? && - banned + articles_count.zero? && comments_count.zero? && banned end def stackbit_integration? diff --git a/app/errors/uninferrable_decorator_error.rb b/app/errors/uninferrable_decorator_error.rb new file mode 100644 index 000000000..9bbfbae33 --- /dev/null +++ b/app/errors/uninferrable_decorator_error.rb @@ -0,0 +1,4 @@ +# raised when an object or collection tries to decorate itself, +# without having an inferrable decorator +class UninferrableDecoratorError < NameError +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index ab8e7207f..cb35a325c 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -20,4 +20,31 @@ class ApplicationRecord < ActiveRecord::Base result.clear # PG::Result is manually managed in memory, we need to release its resources count end + + # Decorate object with appropriate decorator + def decorate + self.class.decorator_class.new(self) + end + + def decorated? + false + end + + # Decorate collection with appropriate decorator + def self.decorate + decorator_class.decorate_collection(all) + end + + # Infers the decorator class to be used by (e.g. `User` maps to `UserDecorator`). + # adapted from https://github.com/drapergem/draper/blob/157eb955072a941e6455e0121fca09a989fcbc21/lib/draper/decoratable.rb#L71 + def self.decorator_class(called_on = self) + prefix = respond_to?(:model_name) ? model_name : name + decorator_name = "#{prefix}Decorator" + decorator_name_constant = decorator_name.safe_constantize + return decorator_name_constant unless decorator_name_constant.nil? + + return superclass.decorator_class(called_on) if superclass.respond_to?(:decorator_class) + + raise UninferrableDecoratorError, "Could not infer a decorator for #{called_on.class.name}." + end end diff --git a/app/models/podcast_episode.rb b/app/models/podcast_episode.rb index 5f1a2910c..4e06fbe87 100644 --- a/app/models/podcast_episode.rb +++ b/app/models/podcast_episode.rb @@ -98,10 +98,6 @@ class PodcastEpisode < ApplicationRecord ActionView::Base.full_sanitizer.sanitize(processed_html) end - def published_at_date_slashes - published_at&.to_date&.strftime("%m/%d/%Y") - end - def user podcast end diff --git a/app/services/articles/creator.rb b/app/services/articles/creator.rb index 0b6d491b3..4e42cdf4a 100644 --- a/app/services/articles/creator.rb +++ b/app/services/articles/creator.rb @@ -14,6 +14,7 @@ module Articles raise if RateLimitChecker.new(user).limit_by_action("published_article_creation") article = save_article + if article.persisted? NotificationSubscription.create(user: user, notifiable_id: article.id, notifiable_type: "Article", config: "all_comments") Notification.send_to_followers(article, "Published") if article.published? @@ -21,7 +22,7 @@ module Articles dispatch_event(article) end - article.decorate + article end private diff --git a/app/views/organizations/_sidebar_additional.html.erb b/app/views/organizations/_sidebar_additional.html.erb index 265c964a4..42c60d6c5 100644 --- a/app/views/organizations/_sidebar_additional.html.erb +++ b/app/views/organizations/_sidebar_additional.html.erb @@ -2,7 +2,7 @@