From b11fdc3ae3f90731c01149a5a36dc64e065d4ea2 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 12 Jan 2017 20:46:24 +0100 Subject: Migrate from ledermann/rails-settings to rails-settings-cached which allows global settings with YAML-defined defaults. Add admin page for editing global settings. Add "site_description" setting that would show as a paragraph on the frontpage --- app/lib/hash_object.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/lib/hash_object.rb (limited to 'app/lib') diff --git a/app/lib/hash_object.rb b/app/lib/hash_object.rb new file mode 100644 index 000000000..274c020ad --- /dev/null +++ b/app/lib/hash_object.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class HashObject + def initialize(hash) + hash.each do |k, v| + instance_variable_set("@#{k}", v) + self.class.send(:define_method, k, proc { instance_variable_get("@#{k}") }) + end + end +end -- cgit From 2939e9898b1e0e7da6db802a00e594be4c85499d Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 13 Jan 2017 02:42:22 +0100 Subject: Extend rails-settings-cached to merge db-saved hash values with defaults --- app/controllers/api/v1/accounts_controller.rb | 2 +- app/lib/settings/extend.rb | 9 ++++ app/lib/settings/scoped_settings.rb | 12 +++++ app/models/setting.rb | 20 ++++++++ app/models/user.rb | 2 +- db/schema.rb | 69 +-------------------------- 6 files changed, 44 insertions(+), 70 deletions(-) create mode 100644 app/lib/settings/extend.rb create mode 100644 app/lib/settings/scoped_settings.rb (limited to 'app/lib') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 05ff806c5..bd52dfb2c 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -96,7 +96,7 @@ class Api::V1::AccountsController < ApiController limit = params[:limit] ? [DEFAULT_ACCOUNTS_LIMIT, params[:limit].to_i].min : DEFAULT_ACCOUNTS_LIMIT @accounts = SearchService.new.call(params[:q], limit, params[:resolve] == 'true') - set_account_counters_maps(@accounts) + set_account_counters_maps(@accounts) unless @accounts.nil? render action: :index end diff --git a/app/lib/settings/extend.rb b/app/lib/settings/extend.rb new file mode 100644 index 000000000..7241a1221 --- /dev/null +++ b/app/lib/settings/extend.rb @@ -0,0 +1,9 @@ +module Settings + module Extend + extend ActiveSupport::Concern + + def settings + ScopedSettings.for_thing(self) + end + end +end \ No newline at end of file diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb new file mode 100644 index 000000000..f8f22a91b --- /dev/null +++ b/app/lib/settings/scoped_settings.rb @@ -0,0 +1,12 @@ +module Settings + class ScopedSettings < ::Setting + def self.for_thing(object) + @object = object + self + end + + def self.thing_scoped + unscoped.where(thing_type: @object.class.base_class.to_s, thing_id: @object.id) + end + end +end \ No newline at end of file diff --git a/app/models/setting.rb b/app/models/setting.rb index 0a429a62b..f3c65c054 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -9,6 +9,26 @@ class Setting < RailsSettings::Base end class << self + + def [](key) + return super(key) unless rails_initialized? + + val = Rails.cache.fetch(cache_key(key, @object)) do + db_val = object(key) + + if db_val + default_value = default_settings[key] + + return default_value.with_indifferent_access.merge!(db_val.value) if default_value.is_a?(Hash) + db_val.value + else + default_settings[key] + end + end + + val + end + def all_as_records vars = thing_scoped records = vars.map { |r| [r.var, r] }.to_h diff --git a/app/models/user.rb b/app/models/user.rb index bf7d04d7c..71d3ee0b8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class User < ApplicationRecord - include RailsSettings::Extend + include Settings::Extend devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable diff --git a/db/schema.rb b/db/schema.rb index 1cd1258db..f1bd752c9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -169,74 +169,6 @@ ActiveRecord::Schema.define(version: 20170112154826) do t.index ["topic", "callback"], name: "index_pubsubhubbub_subscriptions_on_topic_and_callback", unique: true, using: :btree end - create_table "push_devices", force: :cascade do |t| - t.string "service", default: "", null: false - t.string "token", default: "", null: false - t.integer "account", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["service", "token"], name: "index_push_devices_on_service_and_token", unique: true, using: :btree - end - - create_table "rpush_apps", force: :cascade do |t| - t.string "name", null: false - t.string "environment" - t.text "certificate" - t.string "password" - t.integer "connections", default: 1, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "type", null: false - t.string "auth_key" - t.string "client_id" - t.string "client_secret" - t.string "access_token" - t.datetime "access_token_expiration" - end - - create_table "rpush_feedback", force: :cascade do |t| - t.string "device_token", limit: 64, null: false - t.datetime "failed_at", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "app_id" - t.index ["device_token"], name: "index_rpush_feedback_on_device_token", using: :btree - end - - create_table "rpush_notifications", force: :cascade do |t| - t.integer "badge" - t.string "device_token", limit: 64 - t.string "sound", default: "default" - t.text "alert" - t.text "data" - t.integer "expiry", default: 86400 - t.boolean "delivered", default: false, null: false - t.datetime "delivered_at" - t.boolean "failed", default: false, null: false - t.datetime "failed_at" - t.integer "error_code" - t.text "error_description" - t.datetime "deliver_after" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "alert_is_json", default: false - t.string "type", null: false - t.string "collapse_key" - t.boolean "delay_while_idle", default: false, null: false - t.text "registration_ids" - t.integer "app_id", null: false - t.integer "retries", default: 0 - t.string "uri" - t.datetime "fail_after" - t.boolean "processing", default: false, null: false - t.integer "priority" - t.text "url_args" - t.string "category" - t.boolean "content_available", default: false - t.text "notification" - t.index ["delivered", "failed"], name: "index_rpush_notifications_multi", where: "((NOT delivered) AND (NOT failed))", using: :btree - end - create_table "settings", force: :cascade do |t| t.string "var", null: false t.text "value" @@ -259,6 +191,7 @@ ActiveRecord::Schema.define(version: 20170112154826) do t.boolean "sensitive", default: false t.integer "visibility", default: 0, null: false t.integer "in_reply_to_account_id" + t.string "conversation_uri" t.index ["account_id"], name: "index_statuses_on_account_id", using: :btree t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", using: :btree t.index ["reblog_of_id"], name: "index_statuses_on_reblog_of_id", using: :btree -- cgit From e25170f960bdc890e4f8a6b1373ab55192669629 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 13 Jan 2017 03:24:41 +0100 Subject: Add extended about page stub --- app/controllers/about_controller.rb | 6 ++++++ app/lib/settings/extend.rb | 6 ++++-- app/lib/settings/scoped_settings.rb | 4 +++- app/models/account.rb | 1 + app/models/setting.rb | 5 ++--- app/models/web.rb | 2 ++ app/views/about/more.html.haml | 8 ++++++++ app/views/admin/settings/index.html.haml | 18 +++++++++++++----- config/routes.rb | 7 ++++--- config/settings.yml | 2 +- 10 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 app/views/about/more.html.haml (limited to 'app/lib') diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 84e5fbbd9..b69c8e790 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -7,6 +7,12 @@ class AboutController < ApplicationController @description = Setting.site_description end + def more + @extended_description = Setting.site_extended_description + @contact_account = Account.find_local(Setting.site_contact_username) + @contact_email = Setting.site_contact_email + end + def terms; end private diff --git a/app/lib/settings/extend.rb b/app/lib/settings/extend.rb index 7241a1221..407c3480f 100644 --- a/app/lib/settings/extend.rb +++ b/app/lib/settings/extend.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + module Settings module Extend - extend ActiveSupport::Concern + extend ActiveSupport::Concern def settings ScopedSettings.for_thing(self) end end -end \ No newline at end of file +end diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb index f8f22a91b..82b70d128 100644 --- a/app/lib/settings/scoped_settings.rb +++ b/app/lib/settings/scoped_settings.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Settings class ScopedSettings < ::Setting def self.for_thing(object) @@ -9,4 +11,4 @@ module Settings unscoped.where(thing_type: @object.class.base_class.to_s, thing_id: @object.id) end end -end \ No newline at end of file +end diff --git a/app/models/account.rb b/app/models/account.rb index ec0e81f7c..5f07615fc 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -159,6 +159,7 @@ class Account < ApplicationRecord end def find_remote!(username, domain) + return if username.blank? where(arel_table[:username].matches(username.gsub(/[%_]/, '\\\\\0'))).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain.gsub(/[%_]/, '\\\\\0'))).take! end diff --git a/app/models/setting.rb b/app/models/setting.rb index f3c65c054..3796253d4 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -9,10 +9,9 @@ class Setting < RailsSettings::Base end class << self - def [](key) return super(key) unless rails_initialized? - + val = Rails.cache.fetch(cache_key(key, @object)) do db_val = object(key) @@ -25,7 +24,7 @@ class Setting < RailsSettings::Base default_settings[key] end end - + val end diff --git a/app/models/web.rb b/app/models/web.rb index 3c6eebbe2..58654fd77 100644 --- a/app/models/web.rb +++ b/app/models/web.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Web def self.table_name_prefix 'web_' diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml new file mode 100644 index 000000000..49a3a0c95 --- /dev/null +++ b/app/views/about/more.html.haml @@ -0,0 +1,8 @@ +- content_for :page_title do + #{Rails.configuration.x.local_domain} + +.wrapper + = @extended_description.html_safe + + - if @contact_account + = render partial: 'authorize_follow/card', locals: { account: @contact_account } \ No newline at end of file diff --git a/app/views/admin/settings/index.html.haml b/app/views/admin/settings/index.html.haml index 4f9ad2fde..5b482213b 100644 --- a/app/views/admin/settings/index.html.haml +++ b/app/views/admin/settings/index.html.haml @@ -9,6 +9,12 @@ %th Setting %th Click to edit %tbody + %tr + %td{ rowspan: 2 } + %strong Contact information + %td= best_in_place @settings['site_contact_username'], :value, url: admin_setting_path(@settings['site_contact_username']), place_holder: 'Enter a username' + %tr + %td= best_in_place @settings['site_contact_email'], :value, url: admin_setting_path(@settings['site_contact_email']), place_holder: 'Enter a public e-mail address' %tr %td %strong Site description @@ -21,8 +27,10 @@ %code= '' %td= best_in_place @settings['site_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_description']) %tr - %td{ rowspan: 2 } - %strong Contact information - %td= best_in_place @settings['site_contact_username'], :value, url: admin_setting_path(@settings['site_contact_username']), place_holder: 'Enter a username' - %tr - %td= best_in_place @settings['site_contact_email'], :value, url: admin_setting_path(@settings['site_contact_email']), place_holder: 'Enter a public e-mail address' + %td + %strong Extended site description + %br/ + Displayed on extended information page + %br/ + You can use HTML tags + %td= best_in_place @settings['site_extended_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_extended_description']) \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index c0262e933..42de503f0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -143,9 +143,10 @@ Rails.application.routes.draw do get '/web/(*any)', to: 'home#index', as: :web - get :about, to: 'about#index' - get :terms, to: 'about#terms' - + get '/about', to: 'about#index' + get '/about/more', to: 'about#more' + get '/terms', to: 'about#terms' + root 'home#index' match '*unmatched_route', via: :all, to: 'application#raise_not_found' diff --git a/config/settings.yml b/config/settings.yml index 2e309e46e..a78bd067d 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -1,6 +1,7 @@ # config/app.yml for rails-settings-cached defaults: &defaults site_description: '' + site_extended_description: '' site_contact_username: '' site_contact_email: '' notification_emails: @@ -12,7 +13,6 @@ defaults: &defaults interactions: must_be_follower: false must_be_following: false - development: <<: *defaults -- cgit From e9737c2235ec56502e650bd1adad3f32bf85f0ef Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 15 Jan 2017 14:01:33 +0100 Subject: Fix tests, add applications to eager loading/cache for statuses, fix application website validation, don't link to app website if website isn't set, also comment out animated boost icon from #464 until it's consistent with non-animated version --- .rubocop.yml | 1 + .../features/status/components/detailed_status.jsx | 10 +++++-- app/assets/stylesheets/components.scss | 35 +++++++++++----------- app/lib/application_extension.rb | 9 ++++++ app/lib/url_validator.rb | 14 +++++++++ app/models/concerns/application.rb | 8 ----- app/models/status.rb | 2 +- app/services/post_status_service.rb | 9 +++++- app/views/api/v1/apps/show.rabl | 2 +- .../stream_entries/_detailed_status.html.haml | 10 ++++--- config/application.rb | 1 + config/locales/en.yml | 6 ++-- .../controllers/api/v1/statuses_controller_spec.rb | 3 +- spec/fabricators/application_fabricator.rb | 5 ++++ 14 files changed, 78 insertions(+), 37 deletions(-) create mode 100644 app/lib/application_extension.rb create mode 100644 app/lib/url_validator.rb delete mode 100644 app/models/concerns/application.rb create mode 100644 spec/fabricators/application_fabricator.rb (limited to 'app/lib') diff --git a/.rubocop.yml b/.rubocop.yml index 28c735913..ab28c0fe1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -87,3 +87,4 @@ AllCops: - 'bin/*' - 'Rakefile' - 'node_modules/**/*' + - 'Vagrantfile' diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx index 7cbca4633..14a504c7c 100644 --- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx +++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx @@ -32,7 +32,9 @@ const DetailedStatus = React.createClass({ render () { const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; - let media = ''; + + let media = ''; + let applicationLink = ''; if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'video') { @@ -42,6 +44,10 @@ const DetailedStatus = React.createClass({ } } + if (status.get('application')) { + applicationLink = · {status.getIn(['application', 'name'])}; + } + return ( ); diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index f4d822dcf..2d99fcfe8 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -663,20 +663,21 @@ } } -button i.fa-retweet { - height: 19px; - width: 24px; - background: image-url('boost_sprite.png') no-repeat; - background-position: 0 0; - transition: background-position 0.9s steps(11); - transition-duration: 0s; - - &::before { - display: none !important; - } -} - -button.active i.fa-retweet { - transition-duration: 0.9s; - background-position: 0 -209px; -} +// Commented out until sprite matches non-sprite icon visually +// button i.fa-retweet { +// height: 19px; +// width: 24px; +// background: image-url('boost_sprite.png') no-repeat; +// background-position: 0 0; +// transition: background-position 0.9s steps(11); +// transition-duration: 0s; + +// &::before { +// display: none !important; +// } +// } + +// button.active i.fa-retweet { +// transition-duration: 0.9s; +// background-position: 0 -209px; +// } diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb new file mode 100644 index 000000000..93c0f42f0 --- /dev/null +++ b/app/lib/application_extension.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ApplicationExtension + extend ActiveSupport::Concern + + included do + validates :website, url: true, unless: 'website.blank?' + end +end diff --git a/app/lib/url_validator.rb b/app/lib/url_validator.rb new file mode 100644 index 000000000..4a5c4ef3f --- /dev/null +++ b/app/lib/url_validator.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class UrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, I18n.t('applications.invalid_url')) unless compliant?(value) + end + + private + + def compliant?(url) + parsed_url = Addressable::URI.parse(url) + !parsed_url.nil? && %w(http https).include?(parsed_url.scheme) && parsed_url.host + end +end diff --git a/app/models/concerns/application.rb b/app/models/concerns/application.rb deleted file mode 100644 index 613be34ee..000000000 --- a/app/models/concerns/application.rb +++ /dev/null @@ -1,8 +0,0 @@ -module ApplicationExtension - extend ActiveSupport::Concern - included do - validates :website - end -end - -Doorkeeper::Application.send :include, ApplicationExtension \ No newline at end of file diff --git a/app/models/status.rb b/app/models/status.rb index 8301ae16e..5710f9cca 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -35,7 +35,7 @@ class Status < ApplicationRecord scope :remote, -> { where.not(uri: nil) } scope :local, -> { where(uri: nil) } - cache_associated :account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account + cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account def local? uri.nil? diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 86a84f512..af31c923f 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -7,10 +7,17 @@ class PostStatusService < BaseService # @param [Status] in_reply_to Optional status to reply to # @param [Hash] options # @option [Boolean] :sensitive + # @option [String] :visibility # @option [Enumerable] :media_ids Optional array of media IDs to attach + # @option [Doorkeeper::Application] :application # @return [Status] def call(account, text, in_reply_to = nil, options = {}) - status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive], visibility: options[:visibility], application: options[:application]) + status = account.statuses.create!(text: text, + thread: in_reply_to, + sensitive: options[:sensitive], + visibility: options[:visibility], + application: options[:application]) + attach_media(status, options[:media_ids]) process_mentions_service.call(status) process_hashtags_service.call(status) diff --git a/app/views/api/v1/apps/show.rabl b/app/views/api/v1/apps/show.rabl index 30cfd81ab..6d9e607db 100644 --- a/app/views/api/v1/apps/show.rabl +++ b/app/views/api/v1/apps/show.rabl @@ -1,3 +1,3 @@ object @application -attributes :id, :name, :website \ No newline at end of file +attributes :name, :website diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 946adbd8e..bc09d3597 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -29,13 +29,15 @@ %span= l(status.created_at) · - if status.application - = link_to status.application.website, class: 'detailed-status__application', target: @external_links ? '_blank' : nil, rel: 'noopener' do - %span= status.application.name + - if status.application.website.blank? + %strong.detailed-status__application= status.application.name + - else + = link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener' · - %span + %span< = fa_icon('retweet') %span= status.reblogs.count · - %span + %span< = fa_icon('star') %span= status.favourites.count diff --git a/config/application.rb b/config/application.rb index 79ace8521..e561d0473 100644 --- a/config/application.rb +++ b/config/application.rb @@ -46,6 +46,7 @@ module Mastodon config.to_prepare do Doorkeeper::AuthorizationsController.layout 'public' + Doorkeeper::Application.send :include, ApplicationExtension end config.action_dispatch.default_headers = { diff --git a/config/locales/en.yml b/config/locales/en.yml index 128a4d40e..f7d7ed729 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -8,6 +8,7 @@ en: domain_count_after: other instances domain_count_before: Connected to get_started: Get started + learn_more: Learn more links: Links source_code: Source code status_count_after: statuses @@ -15,7 +16,6 @@ en: terms: Terms user_count_after: users user_count_before: Home to - learn_more: Learn more accounts: follow: Follow followers: Followers @@ -28,6 +28,8 @@ en: unfollow: Unfollow application_mailer: signature: Mastodon notifications from %{instance} + applications: + invalid_url: The provided URL is invalid auth: change_password: Change password didnt_get_confirmation: Didn't receive confirmation instructions? @@ -88,9 +90,9 @@ en: proceed: Proceed to follow prompt: 'You are going to follow:' settings: + back: Back to Mastodon edit_profile: Edit profile preferences: Preferences - back: Back to Mastodon stream_entries: click_to_show: Click to show favourited: favourited a post by diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb index d9c73f952..669956659 100644 --- a/spec/controllers/api/v1/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/statuses_controller_spec.rb @@ -4,7 +4,8 @@ RSpec.describe Api::V1::StatusesController, type: :controller do render_views let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } - let(:token) { double acceptable?: true, resource_owner_id: user.id } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { double acceptable?: true, resource_owner_id: user.id, application: app } before do allow(controller).to receive(:doorkeeper_token) { token } diff --git a/spec/fabricators/application_fabricator.rb b/spec/fabricators/application_fabricator.rb new file mode 100644 index 000000000..42b7009dc --- /dev/null +++ b/spec/fabricators/application_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:application, from: Doorkeeper::Application) do + name 'Example' + website 'http://example.com' + redirect_uri 'http://example.com/callback' +end -- cgit From 306eb6e9c90295dcdff2a0094066542a46a8e634 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 18 Jan 2017 23:44:29 +0100 Subject: Add optional StatsD performance tracking --- Gemfile | 1 + Gemfile.lock | 2 ++ app/lib/statsd_monitor.rb | 11 +++++++++++ config/application.rb | 2 ++ config/environments/production.rb | 4 ++++ config/initializers/inflections.rb | 7 +++---- config/initializers/statsd.rb | 20 ++++++++++++++++++++ 7 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 app/lib/statsd_monitor.rb create mode 100644 config/initializers/statsd.rb (limited to 'app/lib') diff --git a/Gemfile b/Gemfile index 6b8519369..1341e45de 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,7 @@ gem 'sidekiq' gem 'rails-settings-cached' gem 'pg_search' gem 'simple-navigation' +gem 'statsd-instrument' gem 'react-rails' gem 'browserify-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 2c009955e..7214d21eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -370,6 +370,7 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) + statsd-instrument (2.1.2) temple (0.7.7) term-ansicolor (1.4.0) tins (~> 1.0) @@ -463,6 +464,7 @@ DEPENDENCIES simple-navigation simple_form simplecov + statsd-instrument uglifier (>= 1.3.0) webmock will_paginate diff --git a/app/lib/statsd_monitor.rb b/app/lib/statsd_monitor.rb new file mode 100644 index 000000000..e48ce6541 --- /dev/null +++ b/app/lib/statsd_monitor.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class StatsDMonitor + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + end +end diff --git a/config/application.rb b/config/application.rb index e561d0473..e97fb165b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -30,6 +30,8 @@ module Mastodon config.active_job.queue_adapter = :sidekiq + config.middleware.insert(0, 'StatsDMonitor') + config.middleware.insert_before 0, Rack::Cors do allow do origins '*' diff --git a/config/environments/production.rb b/config/environments/production.rb index 8b8d974b3..30170e810 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -104,4 +104,8 @@ Rails.application.configure do config.react.variant = :production config.active_record.logger = nil + + config.to_prepare do + StatsD.backend = StatsD::Instrument::Backends::NullBackend if ENV['STATSD_ADDR'].blank? + end end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ac033bf9d..8fd1ae72c 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -10,7 +10,6 @@ # inflect.uncountable %w( fish sheep ) # end -# These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym 'RESTful' -# end +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym 'StatsD' +end diff --git a/config/initializers/statsd.rb b/config/initializers/statsd.rb new file mode 100644 index 000000000..c9c754e7f --- /dev/null +++ b/config/initializers/statsd.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +StatsD.prefix = 'mastodon' +StatsD.default_sample_rate = 1 + +StatsDMonitor.extend(StatsD::Instrument) +StatsDMonitor.statsd_measure(:call, 'request.duration') + +STATSD_REQUEST_METRICS = { + 'request.status.success' => 200, + 'request.status.not_found' => 404, + 'request.status.too_many_requests' => 429, + 'request.status.internal_server_error' => 500, +}.freeze + +STATSD_REQUEST_METRICS.each do |name, code| + StatsDMonitor.statsd_count_if(:call, name) do |status, _env, _body| + status.to_i == code + end +end -- cgit From f0de621e76b5a5ba3f7e67bd88c0183aac22b985 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 20 Jan 2017 01:00:14 +0100 Subject: Fix #463 - Fetch and display previews of URLs using OpenGraph tags --- Gemfile | 1 + Gemfile.lock | 2 + .../javascripts/components/actions/cards.jsx | 40 +++++++++ .../javascripts/components/actions/statuses.jsx | 2 + .../components/features/status/components/card.jsx | 96 ++++++++++++++++++++++ .../features/status/components/detailed_status.jsx | 3 + .../features/status/containers/card_container.jsx | 8 ++ .../javascripts/components/reducers/cards.jsx | 14 ++++ .../javascripts/components/reducers/index.jsx | 4 +- app/assets/stylesheets/components.scss | 6 ++ app/controllers/api/v1/statuses_controller.rb | 8 +- app/lib/statsd_monitor.rb | 11 --- app/models/preview_card.rb | 20 +++++ app/models/status.rb | 1 + app/services/fetch_link_card_service.rb | 33 ++++++++ app/services/post_status_service.rb | 1 + app/views/api/v1/statuses/card.rabl | 5 ++ app/workers/link_crawl_worker.rb | 13 +++ config/application.rb | 3 +- config/initializers/inflections.rb | 1 + config/routes.rb | 3 +- db/migrate/20170119214911_create_preview_cards.rb | 17 ++++ db/schema.rb | 16 +++- lib/statsd_monitor.rb | 11 +++ spec/fabricators/preview_card_fabricator.rb | 5 ++ spec/models/preview_card_spec.rb | 5 ++ spec/models/subscription_spec.rb | 2 +- 27 files changed, 313 insertions(+), 18 deletions(-) create mode 100644 app/assets/javascripts/components/actions/cards.jsx create mode 100644 app/assets/javascripts/components/features/status/components/card.jsx create mode 100644 app/assets/javascripts/components/features/status/containers/card_container.jsx create mode 100644 app/assets/javascripts/components/reducers/cards.jsx delete mode 100644 app/lib/statsd_monitor.rb create mode 100644 app/models/preview_card.rb create mode 100644 app/services/fetch_link_card_service.rb create mode 100644 app/views/api/v1/statuses/card.rabl create mode 100644 app/workers/link_crawl_worker.rb create mode 100644 db/migrate/20170119214911_create_preview_cards.rb create mode 100644 lib/statsd_monitor.rb create mode 100644 spec/fabricators/preview_card_fabricator.rb create mode 100644 spec/models/preview_card_spec.rb (limited to 'app/lib') diff --git a/Gemfile b/Gemfile index aa149c61e..bab7cebb5 100644 --- a/Gemfile +++ b/Gemfile @@ -48,6 +48,7 @@ gem 'rails-settings-cached' gem 'pg_search' gem 'simple-navigation' gem 'statsd-instrument' +gem 'ruby-oembed', require: 'oembed' gem 'react-rails' gem 'browserify-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 9b33580fc..20ea37fcc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -334,6 +334,7 @@ GEM rainbow (>= 1.99.1, < 3.0) ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) + ruby-oembed (0.10.1) ruby-progressbar (1.8.1) safe_yaml (1.0.4) sass (3.4.22) @@ -457,6 +458,7 @@ DEPENDENCIES rspec-rails rspec-sidekiq rubocop + ruby-oembed sass-rails (~> 5.0) sdoc (~> 0.4.0) sidekiq diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx new file mode 100644 index 000000000..808f1835b --- /dev/null +++ b/app/assets/javascripts/components/actions/cards.jsx @@ -0,0 +1,40 @@ +import api from '../api'; + +export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST'; +export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS'; +export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL'; + +export function fetchStatusCard(id) { + return (dispatch, getState) => { + dispatch(fetchStatusCardRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/card`).then(response => { + dispatch(fetchStatusCardSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchStatusCardFail(id, error)); + }); + }; +}; + +export function fetchStatusCardRequest(id) { + return { + type: STATUS_CARD_FETCH_REQUEST, + id + }; +}; + +export function fetchStatusCardSuccess(id, card) { + return { + type: STATUS_CARD_FETCH_SUCCESS, + id, + card + }; +}; + +export function fetchStatusCardFail(id, error) { + return { + type: STATUS_CARD_FETCH_FAIL, + id, + error + }; +}; diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx index 21a56381e..9ac215727 100644 --- a/app/assets/javascripts/components/actions/statuses.jsx +++ b/app/assets/javascripts/components/actions/statuses.jsx @@ -1,6 +1,7 @@ import api from '../api'; import { deleteFromTimelines } from './timelines'; +import { fetchStatusCard } from './cards'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -31,6 +32,7 @@ export function fetchStatus(id) { api(getState).get(`/api/v1/statuses/${id}`).then(response => { dispatch(fetchStatusSuccess(response.data, skipLoading)); dispatch(fetchContext(id)); + dispatch(fetchStatusCard(id)); }).catch(error => { dispatch(fetchStatusFail(id, error, skipLoading)); }); diff --git a/app/assets/javascripts/components/features/status/components/card.jsx b/app/assets/javascripts/components/features/status/components/card.jsx new file mode 100644 index 000000000..7161de364 --- /dev/null +++ b/app/assets/javascripts/components/features/status/components/card.jsx @@ -0,0 +1,96 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const outerStyle = { + display: 'flex', + cursor: 'pointer', + fontSize: '14px', + border: '1px solid #363c4b', + borderRadius: '4px', + color: '#616b86', + marginTop: '14px', + textDecoration: 'none', + overflow: 'hidden' +}; + +const contentStyle = { + flex: '2', + padding: '8px', + paddingLeft: '14px' +}; + +const titleStyle = { + display: 'block', + fontWeight: '500', + marginBottom: '5px', + color: '#d9e1e8' +}; + +const descriptionStyle = { + color: '#d9e1e8' +}; + +const imageOuterStyle = { + flex: '1', + background: '#373b4a' +}; + +const imageStyle = { + display: 'block', + width: '100%', + height: 'auto', + margin: '0', + borderRadius: '4px 0 0 4px' +}; + +const hostStyle = { + display: 'block', + marginTop: '5px', + fontSize: '13px' +}; + +const getHostname = url => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +const Card = React.createClass({ + propTypes: { + card: ImmutablePropTypes.map + }, + + mixins: [PureRenderMixin], + + render () { + const { card } = this.props; + + if (card === null) { + return null; + } + + let image = ''; + + if (card.get('image')) { + image = ( +
+ {card.get('title')} +
+ ); + } + + return ( + + {image} + +
+ {card.get('title')} +

{card.get('description')}

+ {getHostname(card.get('url'))} +
+
+ ); + } +}); + +export default Card; diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx index 14a504c7c..f2d6ae48a 100644 --- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx +++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx @@ -7,6 +7,7 @@ import MediaGallery from '../../../components/media_gallery'; import VideoPlayer from '../../../components/video_player'; import { Link } from 'react-router'; import { FormattedDate, FormattedNumber } from 'react-intl'; +import CardContainer from '../containers/card_container'; const DetailedStatus = React.createClass({ @@ -42,6 +43,8 @@ const DetailedStatus = React.createClass({ } else { media = ; } + } else { + media = ; } if (status.get('application')) { diff --git a/app/assets/javascripts/components/features/status/containers/card_container.jsx b/app/assets/javascripts/components/features/status/containers/card_container.jsx new file mode 100644 index 000000000..5c8bfeec2 --- /dev/null +++ b/app/assets/javascripts/components/features/status/containers/card_container.jsx @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Card from '../components/card'; + +const mapStateToProps = (state, { statusId }) => ({ + card: state.getIn(['cards', statusId], null) +}); + +export default connect(mapStateToProps)(Card); diff --git a/app/assets/javascripts/components/reducers/cards.jsx b/app/assets/javascripts/components/reducers/cards.jsx new file mode 100644 index 000000000..3c9395011 --- /dev/null +++ b/app/assets/javascripts/components/reducers/cards.jsx @@ -0,0 +1,14 @@ +import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards'; + +import Immutable from 'immutable'; + +const initialState = Immutable.Map(); + +export default function cards(state = initialState, action) { + switch(action.type) { + case STATUS_CARD_FETCH_SUCCESS: + return state.set(action.id, Immutable.fromJS(action.card)); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx index 80c913d2d..0798116c4 100644 --- a/app/assets/javascripts/components/reducers/index.jsx +++ b/app/assets/javascripts/components/reducers/index.jsx @@ -13,6 +13,7 @@ import search from './search'; import notifications from './notifications'; import settings from './settings'; import status_lists from './status_lists'; +import cards from './cards'; export default combineReducers({ timelines, @@ -28,5 +29,6 @@ export default combineReducers({ relationships, search, notifications, - settings + settings, + cards }); diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index fedf73b1d..7e61323ab 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -680,3 +680,9 @@ button.active i.fa-retweet { transition-duration: 0.9s; background-position: 0 -209px; } + +.status-card { + &:hover { + background: #363c4b; + } +} diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index c661d81c1..37ed5e6dd 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -3,8 +3,8 @@ class Api::V1::StatusesController < ApiController before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite] before_action -> { doorkeeper_authorize! :write }, only: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite] - before_action :require_user!, except: [:show, :context, :reblogged_by, :favourited_by] - before_action :set_status, only: [:show, :context, :reblogged_by, :favourited_by] + before_action :require_user!, except: [:show, :context, :card, :reblogged_by, :favourited_by] + before_action :set_status, only: [:show, :context, :card, :reblogged_by, :favourited_by] respond_to :json @@ -21,6 +21,10 @@ class Api::V1::StatusesController < ApiController set_counters_maps(statuses) end + def card + @card = PreviewCard.find_by!(status: @status) + end + def reblogged_by results = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h diff --git a/app/lib/statsd_monitor.rb b/app/lib/statsd_monitor.rb deleted file mode 100644 index e48ce6541..000000000 --- a/app/lib/statsd_monitor.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class StatsDMonitor - def initialize(app) - @app = app - end - - def call(env) - @app.call(env) - end -end diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb new file mode 100644 index 000000000..e59b05eb8 --- /dev/null +++ b/app/models/preview_card.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class PreviewCard < ApplicationRecord + IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze + + belongs_to :status + + has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' } + + validates :url, presence: true + validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES + validates_attachment_size :image, less_than: 1.megabytes + + def save_with_optional_image! + save! + rescue ActiveRecord::RecordInvalid + self.image = nil + save! + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 5710f9cca..d5f52b55c 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -23,6 +23,7 @@ class Status < ApplicationRecord has_and_belongs_to_many :tags has_one :notification, as: :activity, dependent: :destroy + has_one :preview_card, dependent: :destroy validates :account, presence: true validates :uri, uniqueness: true, unless: 'local?' diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb new file mode 100644 index 000000000..2779b79b5 --- /dev/null +++ b/app/services/fetch_link_card_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class FetchLinkCardService < BaseService + def call(status) + # Get first URL + url = URI.extract(status.text).reject { |uri| (uri =~ /\Ahttps?:\/\//).nil? }.first + + return if url.nil? + + response = http_client.get(url) + + return if response.code != 200 + + page = Nokogiri::HTML(response.to_s) + card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url) + + card.title = meta_property(page, 'og:title') || page.at_xpath('//title')&.content + card.description = meta_property(page, 'og:description') || meta_property(page, 'description') + card.image = URI.parse(meta_property(page, 'og:image')) if meta_property(page, 'og:image') + + card.save_with_optional_image! + end + + private + + def http_client + HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow + end + + def meta_property(html, property) + html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value + end +end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index af31c923f..8765ef5e3 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -22,6 +22,7 @@ class PostStatusService < BaseService process_mentions_service.call(status) process_hashtags_service.call(status) + LinkCrawlWorker.perform_async(status.id) DistributionWorker.perform_async(status.id) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) diff --git a/app/views/api/v1/statuses/card.rabl b/app/views/api/v1/statuses/card.rabl new file mode 100644 index 000000000..8ba8dcbb1 --- /dev/null +++ b/app/views/api/v1/statuses/card.rabl @@ -0,0 +1,5 @@ +object @card + +attributes :url, :title, :description + +node(:image) { |card| card.image? ? full_asset_url(card.image.url(:original)) : nil } diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb new file mode 100644 index 000000000..af3394b8b --- /dev/null +++ b/app/workers/link_crawl_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class LinkCrawlWorker + include Sidekiq::Worker + + sidekiq_options retry: false + + def perform(status_id) + FetchLinkCardService.new.call(Status.find(status_id)) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/config/application.rb b/config/application.rb index e97fb165b..d0b06bf95 100644 --- a/config/application.rb +++ b/config/application.rb @@ -3,6 +3,7 @@ require_relative 'boot' require 'rails/all' require_relative '../app/lib/exceptions' +require_relative '../lib/statsd_monitor' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -30,7 +31,7 @@ module Mastodon config.active_job.queue_adapter = :sidekiq - config.middleware.insert(0, 'StatsDMonitor') + config.middleware.insert(0, ::StatsDMonitor) config.middleware.insert_before 0, Rack::Cors do allow do diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 8fd1ae72c..b5e43e705 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -12,4 +12,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'StatsD' + inflect.acronym 'OEmbed' end diff --git a/config/routes.rb b/config/routes.rb index 42de503f0..4606c663a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,7 @@ Rails.application.routes.draw do resources :statuses, only: [:create, :show, :destroy] do member do get :context + get :card get :reblogged_by get :favourited_by @@ -146,7 +147,7 @@ Rails.application.routes.draw do get '/about', to: 'about#index' get '/about/more', to: 'about#more' get '/terms', to: 'about#terms' - + root 'home#index' match '*unmatched_route', via: :all, to: 'application#raise_not_found' diff --git a/db/migrate/20170119214911_create_preview_cards.rb b/db/migrate/20170119214911_create_preview_cards.rb new file mode 100644 index 000000000..70ed91bbd --- /dev/null +++ b/db/migrate/20170119214911_create_preview_cards.rb @@ -0,0 +1,17 @@ +class CreatePreviewCards < ActiveRecord::Migration[5.0] + def change + create_table :preview_cards do |t| + t.integer :status_id + t.string :url, null: false, default: '' + + # OpenGraph + t.string :title, null: true + t.string :description, null: true + t.attachment :image + + t.timestamps + end + + add_index :preview_cards, :status_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 37da0c44e..abe6f1bfe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170114203041) do +ActiveRecord::Schema.define(version: 20170119214911) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -157,6 +157,20 @@ ActiveRecord::Schema.define(version: 20170114203041) do t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree end + create_table "preview_cards", force: :cascade do |t| + t.integer "status_id" + t.string "url", default: "", null: false + t.string "title" + t.string "description" + t.string "image_file_name" + t.string "image_content_type" + t.integer "image_file_size" + t.datetime "image_updated_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["status_id"], name: "index_preview_cards_on_status_id", unique: true, using: :btree + end + create_table "pubsubhubbub_subscriptions", force: :cascade do |t| t.string "topic", default: "", null: false t.string "callback", default: "", null: false diff --git a/lib/statsd_monitor.rb b/lib/statsd_monitor.rb new file mode 100644 index 000000000..e48ce6541 --- /dev/null +++ b/lib/statsd_monitor.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class StatsDMonitor + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + end +end diff --git a/spec/fabricators/preview_card_fabricator.rb b/spec/fabricators/preview_card_fabricator.rb new file mode 100644 index 000000000..448a94e7e --- /dev/null +++ b/spec/fabricators/preview_card_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:preview_card) do + status_id 1 + url "MyString" + html "MyText" +end diff --git a/spec/models/preview_card_spec.rb b/spec/models/preview_card_spec.rb new file mode 100644 index 000000000..14ef23923 --- /dev/null +++ b/spec/models/preview_card_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe PreviewCard, type: :model do + +end diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb index d40bf0b44..9cb3d41ce 100644 --- a/spec/models/subscription_spec.rb +++ b/spec/models/subscription_spec.rb @@ -1,5 +1,5 @@ require 'rails_helper' RSpec.describe Subscription, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + end -- cgit From d4d56b8af0e85e328eb1a323816d734ca16a4176 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 23 Jan 2017 14:45:09 +0100 Subject: Fix #365, 1/2 of #408 - replace rails_autolink with URI.regexp, run link_hashtags on simplified_format --- Gemfile | 1 - Gemfile.lock | 3 --- app/lib/formatter.rb | 10 ++++++++-- 3 files changed, 8 insertions(+), 6 deletions(-) (limited to 'app/lib') diff --git a/Gemfile b/Gemfile index ebee7e35f..7fb3ab91d 100644 --- a/Gemfile +++ b/Gemfile @@ -31,7 +31,6 @@ gem 'link_header' gem 'ostatus2' gem 'goldfinger' gem 'devise' -gem 'rails_autolink' gem 'doorkeeper' gem 'rabl' gem 'oj' diff --git a/Gemfile.lock b/Gemfile.lock index 0e720d7fb..12f6679c7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -268,8 +268,6 @@ GEM rails_12factor (0.0.3) rails_serve_static_assets rails_stdout_logging - rails_autolink (1.1.6) - rails (> 3.1) rails_serve_static_assets (0.0.5) rails_stdout_logging (0.0.5) railties (5.0.1) @@ -456,7 +454,6 @@ DEPENDENCIES rails (~> 5.0.1.0) rails-settings-cached rails_12factor - rails_autolink react-rails redis (~> 3.2) redis-rails diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 04386d295..3565611bc 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -32,6 +32,7 @@ class Formatter html = encode(account.note) html = link_urls(html) + html = link_hashtags(html) html.html_safe # rubocop:disable Rails/OutputSafety end @@ -43,8 +44,8 @@ class Formatter end def link_urls(html) - auto_link(html, link: :urls, html: { rel: 'nofollow noopener', target: '_blank' }) do |text| - truncate(text.gsub(/\Ahttps?:\/\/(www\.)?/, ''), length: 30) + html.gsub(URI.regexp(%w(http https))) do |match| + link_html(match) end end @@ -63,6 +64,11 @@ class Formatter end end + def link_html(url) + link_text = truncate(url.gsub(/\Ahttps?:\/\/(www\.)?/, ''), length: 30) + "#{link_text}" + end + def hashtag_html(match) prefix, affix = match.split('#') "#{prefix}##{affix}" -- cgit From f392030ab82a70086f93bb02c3faab53c3fbd28e Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 23 Jan 2017 21:09:27 +0100 Subject: Add /api/v1/notifications/clear, non-existing link cards for statuses will now return empty hash instead of throwing a 404 error. When following, merge into timeline will filter statuses --- app/assets/javascripts/components/actions/cards.jsx | 7 +++---- app/controllers/api/v1/notifications_controller.rb | 5 +++++ app/controllers/api/v1/statuses_controller.rb | 3 ++- app/lib/feed_manager.rb | 1 + config/routes.rb | 16 ++++++++++------ 5 files changed, 21 insertions(+), 11 deletions(-) (limited to 'app/lib') diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx index ee421d5d7..714e80525 100644 --- a/app/assets/javascripts/components/actions/cards.jsx +++ b/app/assets/javascripts/components/actions/cards.jsx @@ -9,13 +9,12 @@ export function fetchStatusCard(id) { dispatch(fetchStatusCardRequest(id)); api(getState).get(`/api/v1/statuses/${id}/card`).then(response => { - dispatch(fetchStatusCardSuccess(id, response.data)); - }).catch(error => { - if (error.response.status === 404) { - // This is fine + if (response.data.length === 0) { return; } + dispatch(fetchStatusCardSuccess(id, response.data)); + }).catch(error => { dispatch(fetchStatusCardFail(id, error)); }); }; diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 3fd701997..ee12446d8 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -24,4 +24,9 @@ class Api::V1::NotificationsController < ApiController def show @notification = Notification.where(account: current_account).find(params[:id]) end + + def clear + Notification.where(account: current_account).delete_all + render_empty + end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 37ed5e6dd..da87ebbad 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -22,7 +22,8 @@ class Api::V1::StatusesController < ApiController end def card - @card = PreviewCard.find_by!(status: @status) + @card = PreviewCard.find_by(status: @status) + render_empty if @card.nil? end def reblogged_by diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 0056321fa..19f9dc16f 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -43,6 +43,7 @@ class FeedManager timeline_key = key(:home, into_account.id) from_account.statuses.limit(MAX_ITEMS).each do |status| + next if filter?(:home, status, into_account) redis.zadd(timeline_key, status.id, status.id) end diff --git a/config/routes.rb b/config/routes.rb index 7b9cda908..15fb924f1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,10 +103,11 @@ Rails.application.routes.draw do get '/timelines/public', to: 'timelines#public', as: :public_timeline get '/timelines/tag/:id', to: 'timelines#tag', as: :hashtag_timeline - resources :follows, only: [:create] - resources :media, only: [:create] - resources :apps, only: [:create] - resources :blocks, only: [:index] + resources :follows, only: [:create] + resources :media, only: [:create] + resources :apps, only: [:create] + resources :blocks, only: [:index] + resources :favourites, only: [:index] resources :follow_requests, only: [:index] do member do @@ -115,8 +116,11 @@ Rails.application.routes.draw do end end - resources :notifications, only: [:index, :show] - resources :favourites, only: [:index] + resources :notifications, only: [:index, :show] do + collection do + post :clear + end + end resources :accounts, only: [:show] do collection do -- cgit From cca82bf0a2f0ccbf0feda00763fd7df0877845b6 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 23 Jan 2017 21:29:34 +0100 Subject: Move merging/unmerging of timelines into background. Move blocking into background as well since it's a computationally expensive --- app/controllers/api/v1/accounts_controller.rb | 9 +++++++-- app/lib/feed_manager.rb | 9 +++++++++ app/services/follow_service.rb | 2 +- app/services/unfollow_service.rb | 17 +---------------- app/workers/block_worker.rb | 9 +++++++++ app/workers/merge_worker.rb | 9 +++++++++ app/workers/unmerge_worker.rb | 9 +++++++++ 7 files changed, 45 insertions(+), 19 deletions(-) create mode 100644 app/workers/block_worker.rb create mode 100644 app/workers/merge_worker.rb create mode 100644 app/workers/unmerge_worker.rb (limited to 'app/lib') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index bd52dfb2c..234844ed7 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -65,8 +65,13 @@ class Api::V1::AccountsController < ApiController end def block - BlockService.new.call(current_user.account, @account) - set_relationship + BlockWorker.perform_async(current_user.account_id, @account.id) + + @following = { @account.id => false } + @followed_by = { @account.id => false } + @blocking = { @account.id => true } + @requested = { @account.id => false } + render action: :relationship end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 19f9dc16f..cdd26e69c 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -50,6 +50,15 @@ class FeedManager trim(:home, into_account.id) end + def unmerge_from_timeline(from_account, into_account) + timeline_key = key(:home, into_account.id) + + from_account.statuses.select('id').find_each do |status| + redis.zrem(timeline_key, status.id) + redis.zremrangebyscore(timeline_key, status.id, status.id) + end + end + def inline_render(target_account, template, object) rabl_scope = Class.new do include RoutingHelper diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 555f01b6d..87c16a621 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -38,7 +38,7 @@ class FollowService < BaseService NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) end - FeedManager.instance.merge_into_timeline(target_account, source_account) + MergeWorker.perform_async(target_account.id, source_account.id) Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id) follow diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 7973a3611..f469793c1 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -7,21 +7,6 @@ class UnfollowService < BaseService def call(source_account, target_account) follow = source_account.unfollow!(target_account) NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) unless target_account.local? - unmerge_from_timeline(target_account, source_account) - end - - private - - def unmerge_from_timeline(from_account, into_account) - timeline_key = FeedManager.instance.key(:home, into_account.id) - - from_account.statuses.select('id').find_each do |status| - redis.zrem(timeline_key, status.id) - redis.zremrangebyscore(timeline_key, status.id, status.id) - end - end - - def redis - Redis.current + UnmergeWorker.perform_async(target_account.id, source_account.id) end end diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb new file mode 100644 index 000000000..dc00db197 --- /dev/null +++ b/app/workers/block_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class BlockWorker + include Sidekiq::Worker + + def perform(account_id, target_account_id) + BlockService.new.call(Account.find(account_id), Account.find(target_account_id)) + end +end diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb new file mode 100644 index 000000000..0f288f43f --- /dev/null +++ b/app/workers/merge_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MergeWorker + include Sidekiq::Worker + + def perform(from_account_id, into_account_id) + FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id)) + end +end diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb new file mode 100644 index 000000000..dbf7243de --- /dev/null +++ b/app/workers/unmerge_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class UnmergeWorker + include Sidekiq::Worker + + def perform(from_account_id, into_account_id) + FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id)) + end +end -- cgit From bf0f6eb62d0f5bd1f0d8e4e2a6e9e8fd3b297b6c Mon Sep 17 00:00:00 2001 From: blackle Date: Thu, 12 Jan 2017 23:54:26 -0500 Subject: Implement a click-to-view spoiler system --- .../javascripts/components/actions/compose.jsx | 18 ++++++ .../components/components/status_content.jsx | 18 ++++++ .../features/compose/components/compose_form.jsx | 29 ++++++++- .../compose/containers/compose_form_container.jsx | 12 ++++ .../javascripts/components/reducers/compose.jsx | 10 +++ app/assets/javascripts/extras.jsx | 10 +++ app/assets/stylesheets/components.scss | 44 ++++++++++++-- app/controllers/api/v1/statuses_controller.rb | 2 +- app/helpers/atom_builder_helper.rb | 2 + app/lib/formatter.rb | 17 +++++- app/models/status.rb | 4 +- app/services/post_status_service.rb | 4 ++ app/services/process_hashtags_service.rb | 1 + app/validators/status_length_validator.rb | 15 +++++ app/views/api/v1/statuses/_show.rabl | 2 +- .../20170112041538_add_spoiler_to_statuses.rb | 5 ++ .../20170114014334_add_spoiler_text_to_statuses.rb | 5 ++ db/schema.rb | 71 +--------------------- 18 files changed, 192 insertions(+), 77 deletions(-) create mode 100644 app/validators/status_length_validator.rb create mode 100644 db/migrate/20170112041538_add_spoiler_to_statuses.rb create mode 100644 db/migrate/20170114014334_add_spoiler_text_to_statuses.rb (limited to 'app/lib') diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index 05674ba89..948ccf872 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -23,6 +23,8 @@ export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; +export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; +export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; @@ -68,6 +70,8 @@ export function submitCompose() { in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), sensitive: getState().getIn(['compose', 'sensitive']), + spoiler: getState().getIn(['compose', 'spoiler']), + spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public') }).then(function (response) { dispatch(submitComposeSuccess({ ...response.data })); @@ -218,6 +222,20 @@ export function changeComposeSensitivity(checked) { }; }; +export function changeComposeSpoilerness(checked) { + return { + type: COMPOSE_SPOILERNESS_CHANGE, + checked + }; +}; + +export function changeComposeSpoilerText(text) { + return { + type: COMPOSE_SPOILER_TEXT_CHANGE, + text + }; +}; + export function changeComposeVisibility(checked) { return { type: COMPOSE_VISIBILITY_CHANGE, diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx index f2c88cee0..7287aa836 100644 --- a/app/assets/javascripts/components/components/status_content.jsx +++ b/app/assets/javascripts/components/components/status_content.jsx @@ -18,6 +18,12 @@ const StatusContent = React.createClass({ componentDidMount () { const node = ReactDOM.findDOMNode(this); const links = node.querySelectorAll('a'); + const spoilers = node.querySelectorAll('.spoiler'); + + for (var i = 0; i < spoilers.length; ++i) { + let spoiler = spoilers[i]; + spoiler.addEventListener('click', this.onSpoilerClick.bind(this, spoiler), true); + } for (var i = 0; i < links.length; ++i) { let link = links[i]; @@ -52,6 +58,18 @@ const StatusContent = React.createClass({ } }, + onSpoilerClick (spoiler, e) { + if (e.button === 0) { + //only toggle if we're not clicking a visible link + var hasClass = $(spoiler).hasClass('spoiler-on'); + if (hasClass || e.target === spoiler) { + e.stopPropagation(); + e.preventDefault(); + $(spoiler).siblings(".spoiler").andSelf().toggleClass('spoiler-on', !hasClass); + } + } + }, + onNormalClick (e) { e.stopPropagation(); }, diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index 80cb38e16..84d273299 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -14,6 +14,7 @@ import { Motion, spring } from 'react-motion'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' }, publish: { id: 'compose_form.publish', defaultMessage: 'Publish' } }); @@ -25,6 +26,8 @@ const ComposeForm = React.createClass({ suggestion_token: React.PropTypes.string, suggestions: ImmutablePropTypes.list, sensitive: React.PropTypes.bool, + spoiler: React.PropTypes.bool, + spoiler_text: React.PropTypes.string, unlisted: React.PropTypes.bool, private: React.PropTypes.bool, fileDropDate: React.PropTypes.instanceOf(Date), @@ -40,6 +43,8 @@ const ComposeForm = React.createClass({ onFetchSuggestions: React.PropTypes.func.isRequired, onSuggestionSelected: React.PropTypes.func.isRequired, onChangeSensitivity: React.PropTypes.func.isRequired, + onChangeSpoilerness: React.PropTypes.func.isRequired, + onChangeSpoilerText: React.PropTypes.func.isRequired, onChangeVisibility: React.PropTypes.func.isRequired, onChangeListability: React.PropTypes.func.isRequired, }, @@ -77,6 +82,15 @@ const ComposeForm = React.createClass({ this.props.onChangeSensitivity(e.target.checked); }, + handleChangeSpoilerness (e) { + this.props.onChangeSpoilerness(e.target.checked); + this.props.onChangeSpoilerText(''); + }, + + handleChangeSpoilerText (e) { + this.props.onChangeSpoilerText(e.target.value); + }, + handleChangeVisibility (e) { this.props.onChangeVisibility(e.target.checked); }, @@ -115,6 +129,14 @@ const ComposeForm = React.createClass({ return (
+ + {({ opacity, height }) => +
+ +
+ } +
+ {replyArea}
-
+
@@ -142,6 +164,11 @@ const ComposeForm = React.createClass({ + + {({ opacity, height }) =>