diff options
361 files changed, 8250 insertions, 807 deletions
diff --git a/.rubocop.yml b/.rubocop.yml index 14728bf0e..74651358c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -299,3 +299,45 @@ Style/TrailingCommaInHashLiteral: Style/UnpackFirst: Enabled: false + +Layout/EmptyLinesAroundAttributeAccessor: + Enabled: true + +Layout/SpaceAroundMethodCallOperator: + Enabled: true + +Lint/DeprecatedOpenSSLConstant: + Enabled: true + +Lint/MixedRegexpCaptureTypes: + Enabled: true + +Lint/RaiseException: + Enabled: true + +Lint/StructNewOverride: + Enabled: true + +Style/ExponentialNotation: + Enabled: true + +Style/HashEachMethods: + Enabled: true + +Style/HashTransformKeys: + Enabled: true + +Style/HashTransformValues: + Enabled: true + +Style/RedundantFetchBlock: + Enabled: true + +Style/RedundantRegexpCharacterClass: + Enabled: true + +Style/RedundantRegexpEscape: + Enabled: true + +Style/SlicingWithRange: + Enabled: true diff --git a/Gemfile b/Gemfile index 6e0ece406..cab8eae87 100644 --- a/Gemfile +++ b/Gemfile @@ -162,3 +162,9 @@ end gem 'concurrent-ruby', require: false gem 'connection_pool', require: false + +gem "reek", "~> 6.0", :group => :development + +gem "w3c_validators", "~> 1.3" + +gem "activerecord-import", "~> 1.0" diff --git a/Gemfile.lock b/Gemfile.lock index e72a3be84..15973bdec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -54,6 +54,8 @@ GEM activemodel (= 5.2.4.3) activesupport (= 5.2.4.3) arel (>= 9.0) + activerecord-import (1.0.6) + activerecord (>= 3.2) activestorage (5.2.4.3) actionpack (= 5.2.4.3) activerecord (= 5.2.4.3) @@ -319,6 +321,7 @@ GEM activerecord kaminari-core (= 1.2.1) kaminari-core (1.2.1) + kwalify (0.7.2) launchy (2.5.0) addressable (~> 2.7) letter_opener (1.7.0) @@ -426,6 +429,7 @@ GEM pry (~> 0.13.0) pry-rails (0.3.9) pry (>= 0.10.4) + psych (3.1.0) public_suffix (4.0.5) puma (4.3.5) nio4r (~> 2.0) @@ -501,6 +505,11 @@ GEM redis-store (>= 1.2, < 2) redis-store (1.9.0) redis (>= 4, < 5) + reek (6.0.1) + kwalify (~> 0.7.0) + parser (>= 2.5.0.0, < 2.8, != 2.5.1.1) + psych (~> 3.1.0) + rainbow (>= 2.0, < 4.0) regexp_parser (1.7.1) request_store (1.5.0) rack (>= 1.4) @@ -642,6 +651,9 @@ GEM unf_ext (0.0.7.7) unicode-display_width (1.7.0) uniform_notifier (1.13.0) + w3c_validators (1.3.5) + json (>= 1.8) + nokogiri (~> 1.6) warden (1.2.8) rack (>= 2.0.6) webauthn (3.0.0.alpha1) @@ -679,6 +691,7 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10) active_record_query_trace (~> 1.7) + activerecord-import (~> 1.0) addressable (~> 2.7) annotate (~> 3.1) aws-sdk-s3 (~> 1.79) @@ -776,6 +789,7 @@ DEPENDENCIES redis (~> 4.2) redis-namespace (~> 1.8) redis-rails (~> 5.0) + reek (~> 6.0) rqrcode (~> 1.1) rspec-rails (~> 4.0) rspec-sidekiq (~> 3.1) @@ -802,7 +816,14 @@ DEPENDENCIES tty-prompt (~> 0.22) twitter-text (~> 1.14) tzinfo-data (~> 1.2020) + w3c_validators (~> 1.3) webauthn (~> 3.0.0.alpha1) webmock (~> 3.8) webpacker (~> 5.2) webpush + +RUBY VERSION + ruby 2.6.6p146 + +BUNDLED WITH + 2.1.4 diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 5d5db937c..bf3d3ff42 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -4,16 +4,14 @@ class AboutController < ApplicationController before_action :set_pack layout 'public' - before_action :require_open_federation!, only: [:show, :more] + #before_action :require_open_federation!, only: [:show, :more] before_action :set_body_classes, only: :show before_action :set_instance_presenter before_action :set_expires_in, only: [:show, :more, :terms] skip_before_action :require_functional!, only: [:more, :terms] - def show; end - - def more + def show flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor] toc_generator = TOCGenerator.new(@instance_presenter.site_extended_description) @@ -21,10 +19,15 @@ class AboutController < ApplicationController @contents = toc_generator.html @table_of_contents = toc_generator.toc @blocks = DomainBlock.with_user_facing_limitations.by_severity if display_blocks? + @allows = DomainAllow.where(hidden: false) if display_allows? end + alias more show + def terms; end + helper_method :display_allows? + helper_method :display_blocks? helper_method :display_blocks_rationale? helper_method :public_fetch_mode? @@ -66,4 +69,10 @@ class AboutController < ApplicationController def set_expires_in expires_in 0, public: true end + + # Monsterfork additions + + def display_allows? + Setting.show_domain_allows == 'all' || (Setting.show_domain_allows == 'users' && user_signed_in?) + end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 54106933c..232a5fc71 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -10,20 +10,24 @@ class AccountsController < ApplicationController before_action :set_cache_headers before_action :set_body_classes + before_action :require_authenticated!, if: -> { @account.require_auth? || @account.private? } + skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) } - skip_before_action :require_functional!, unless: :whitelist_mode? + skip_before_action :require_functional! # , unless: :whitelist_mode? def show + @without_unlisted = !@account.show_unlisted? + respond_to do |format| format.html do use_pack 'public' - expires_in 0, public: true unless user_signed_in? + expires_in 0, public: true unless user_signed_in? || signed_request_account.present? @pinned_statuses = [] - @endorsed_accounts = @account.endorsed_accounts.to_a.sample(4) - @featured_hashtags = @account.featured_tags.order(statuses_count: :desc) + @endorsed_accounts = unauthorized? ? [] : @account.endorsed_accounts.to_a.sample(4) + @featured_hashtags = unauthorized? ? [] : @account.featured_tags.order(statuses_count: :desc) - if current_account && @account.blocking?(current_account) + if unauthorized? @statuses = [] return end @@ -39,16 +43,19 @@ class AccountsController < ApplicationController end format.rss do - expires_in 1.minute, public: true + return forbidden if unauthorized? + + expires_in 1.minute, public: !current_account? - limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE + @without_unlisted = true + limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE @statuses = filtered_statuses.without_reblogs.limit(limit) @statuses = cache_collection(@statuses, Status) render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag]) end format.json do - expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?) + expires_in 3.minutes, public: !current_account? render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to end end @@ -61,19 +68,28 @@ class AccountsController < ApplicationController end def show_pinned_statuses? - [replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none? + [threads_requested?, replies_requested?, reblogs_requested?, mentions_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none? end def filtered_statuses + return mentions_scope if mentions_requested? + default_statuses.tap do |statuses| - statuses.merge!(hashtag_scope) if tag_requested? statuses.merge!(only_media_scope) if media_requested? - statuses.merge!(no_replies_scope) unless replies_requested? end end def default_statuses - @account.statuses.not_local_only.where(visibility: [:public, :unlisted]) + @account.statuses.permitted_for( + @account, + current_account, + include_semiprivate: true, + include_reblogs: !(threads_requested? || replies_requested?), + only_reblogs: reblogs_requested?, + include_replies: replies_requested?, + tag: tag_requested? ? params[:tag] : nil, + public: @without_unlisted + ) end def only_media_scope @@ -84,18 +100,10 @@ class AccountsController < ApplicationController @account.media_attachments.attached.reorder(nil).select(:status_id).distinct end - def no_replies_scope - Status.without_replies - end - - def hashtag_scope - tag = Tag.find_normalized(params[:tag]) + def mentions_scope + return Status.none unless current_account? - if tag - Status.tagged_with(tag.id) - else - Status.none - end + Status.mentions_between(@account, current_account) end def username_param @@ -123,8 +131,14 @@ class AccountsController < ApplicationController short_account_tag_url(@account, params[:tag], max_id: max_id, min_id: min_id) elsif media_requested? short_account_media_url(@account, max_id: max_id, min_id: min_id) + elsif threads_requested? + short_account_threads_url(@account, max_id: max_id, min_id: min_id) elsif replies_requested? short_account_with_replies_url(@account, max_id: max_id, min_id: min_id) + elsif reblogs_requested? + short_account_reblogs_url(@account, max_id: max_id, min_id: min_id) + elsif mentions_requested? + short_account_mentions_url(@account, max_id: max_id, min_id: min_id) else short_account_url(@account, max_id: max_id, min_id: min_id) end @@ -134,7 +148,13 @@ class AccountsController < ApplicationController request.path.split('.').first.ends_with?('/media') && !tag_requested? end + def threads_requested? + request.path.split('.').first.ends_with?('/threads') && !tag_requested? + end + def replies_requested? + return false unless current_account&.id == @account.id || @account.show_replies? + request.path.split('.').first.ends_with?('/with_replies') && !tag_requested? end @@ -151,15 +171,31 @@ class AccountsController < ApplicationController ) end + def reblogs_requested? + request.path.split('.').first.ends_with?('/reblogs') && !tag_requested? + end + + def mentions_requested? + request.path.split('.').first.ends_with?('/mentions') && !tag_requested? + end + def params_slice(*keys) params.slice(*keys).permit(*keys) end def restrict_fields_to - if signed_request_account.present? || public_fetch_mode? + if current_account&.id == @account.id || (signed_request_account.present? && !blocked?) # Return all fields else %i(id type preferred_username inbox public_key endpoints) end end + + def blocked? + @blocked ||= current_account && @account.blocking?(current_account) + end + + def unauthorized? + @unauthorized ||= blocked? || (@account.private? && !following?(@account)) + end end diff --git a/app/controllers/activitypub/claims_controller.rb b/app/controllers/activitypub/claims_controller.rb index 08ad952df..5009a9f05 100644 --- a/app/controllers/activitypub/claims_controller.rb +++ b/app/controllers/activitypub/claims_controller.rb @@ -4,7 +4,7 @@ class ActivityPub::ClaimsController < ActivityPub::BaseController include SignatureVerification include AccountOwnedConcern - skip_before_action :authenticate_user! + #skip_before_action :authenticate_user! before_action :require_signature! before_action :set_claim_result diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 0a561e7f0..3e67f3909 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -7,7 +7,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController before_action :skip_unknown_actor_delete before_action :require_signature! - skip_before_action :authenticate_user! + #skip_before_action :authenticate_user! def create upgrade_account diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index e066860bf..51945656f 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -10,9 +10,12 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController before_action :set_statuses before_action :set_cache_headers + before_action :require_authenticated!, if: -> { @account.require_auth? } + before_action -> { require_following!(@account) }, if: -> { @account.private? } + def show - expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode? && !(signed_request_account.present? && page_requested?)) - render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode? && !(current_account.present? && page_requested?)) + render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', target_domain: current_account&.domain end private @@ -31,7 +34,6 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController ActivityPub::CollectionPresenter.new( id: account_outbox_url(@account), type: :ordered, - size: @account.statuses_count, first: outbox_url(page: true), last: outbox_url(page: true, min_id: 0) ) @@ -54,12 +56,39 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController account_outbox_url(@account, page: true, min_id: @statuses.first.id) unless @statuses.empty? end + def permitted_account_statuses + @account.statuses.permitted_for( + @account, + current_account, + include_replies: true, + include_reblogs: true, + public: !(owner? || follower?), + include_semiprivate: owner? || mutual_follower?, + exclude_local_only: true + ) + end + + def owner? + return @owner if defined?(@owner) + + @owner = @account.id == current_account&.id + @owner ||= @account.moved_to_account_id == current_account&.id if @account.moved_to_account_id.present? + @owner + end + + def follower? + @following ||= current_account&.following?(@account) + end + + def mutual_follower? + follower? && @account.following?(current_account) + end + def set_statuses return unless page_requested? - @statuses = @account.statuses.permitted_for(@account, signed_request_account) @statuses = cache_collection_paginated_by_id( - @statuses, + permitted_account_statuses, Status, LIMIT, params_slice(:max_id, :min_id, :since_id) diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index 43bf4e657..4d553fc07 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -14,7 +14,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController def index expires_in 0, public: public_fetch_mode? - render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true + render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true, target_domain: current_account&.domain end private @@ -33,6 +33,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController def set_replies @replies = only_other_accounts? ? Status.where.not(account_id: @account.id) : @account.statuses @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted]) + @replies = @replies.without_semiprivate unless authenticated_or_following?(@account) @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) end diff --git a/app/controllers/admin/domain_allows_controller.rb b/app/controllers/admin/domain_allows_controller.rb index 31be1978b..95d9a31fb 100644 --- a/app/controllers/admin/domain_allows_controller.rb +++ b/app/controllers/admin/domain_allows_controller.rb @@ -35,6 +35,6 @@ class Admin::DomainAllowsController < Admin::BaseController end def resource_params - params.require(:domain_allow).permit(:domain) + params.require(:domain_allow).permit(:domain, :hidden) end end diff --git a/app/controllers/admin/pending_accounts_controller.rb b/app/controllers/admin/pending_accounts_controller.rb index b62a9bc84..8a9a51d84 100644 --- a/app/controllers/admin/pending_accounts_controller.rb +++ b/app/controllers/admin/pending_accounts_controller.rb @@ -18,19 +18,19 @@ module Admin end def approve_all - Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'approve').save + Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.confirmed.pluck(:account_id), action: 'approve').save redirect_to admin_pending_accounts_path(current_params) end def reject_all - Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'reject').save + Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.confirmed.pluck(:account_id), action: 'reject').save redirect_to admin_pending_accounts_path(current_params) end private def set_accounts - @accounts = Account.joins(:user).merge(User.pending.recent).includes(user: :invite_request).page(params[:page]) + @accounts = Account.joins(:user).merge(User.pending.confirmed.recent).includes(user: :invite_request).page(params[:page]) end def form_account_batch_params diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 59df4470e..8abe19626 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -54,7 +54,7 @@ module Admin def set_usage_by_domain @usage_by_domain = @tag.statuses - .with_public_visibility + .distributable .excluding_silenced_accounts .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day))) .joins(:account) diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 467225547..ac49a4dca 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -7,7 +7,7 @@ class Api::BaseController < ApplicationController include RateLimitHeaders skip_before_action :store_current_location - skip_before_action :require_functional!, unless: :whitelist_mode? + skip_before_action :require_functional! #, unless: :whitelist_mode? before_action :require_authenticated_user!, if: :disallow_unauthenticated_api_access? before_action :set_cache_headers diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 64b5cb747..3c8187a99 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -21,7 +21,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController private def account_params - params.permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value]) + params.permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, + :require_dereference, :show_replies, :show_unlisted, + fields_attributes: [:name, :value]) end def user_settings_params diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 85a9133e3..d7e973e31 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -8,7 +8,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def index @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_account&.id) end private @@ -17,17 +17,17 @@ class Api::V1::Accounts::StatusesController < Api::BaseController @account = Account.find(params[:account_id]) end + def owner? + @account.id == current_account&.id + end + def load_statuses cached_account_statuses end def cached_account_statuses statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses - statuses.merge!(only_media_scope) if truthy_param?(:only_media) - statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) - statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) - statuses.merge!(hashtag_scope) if params[:tagged].present? cache_collection_paginated_by_id( statuses, @@ -38,39 +38,66 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def permitted_account_statuses - @account.statuses.permitted_for(@account, current_account) + return mentions_scope if truthy_param?(:mentions) + return Status.none if unauthorized? + + @account.statuses.permitted_for( + @account, + current_account, + include_semiprivate: true, + include_reblogs: include_reblogs?, + include_replies: include_replies?, + only_reblogs: only_reblogs?, + only_replies: only_replies?, + include_unpublished: owner?, + tag: params[:tagged] + ) end def only_media_scope Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id) end - def pinned_scope - return Status.none if @account.blocking?(current_account) + def unauthorized? + (@account.private && !following?(@account)) || (@account.require_auth && !current_account?) + end - @account.pinned_statuses + def include_reblogs? + params[:include_reblogs].present? ? truthy_param?(:include_reblogs) : !truthy_param?(:exclude_reblogs) + end + + def include_replies? + return false unless owner? || @account.show_replies? + + params[:include_replies].present? ? truthy_param?(:include_replies) : !truthy_param?(:exclude_replies) end - def no_replies_scope - Status.without_replies + def only_reblogs? + truthy_param?(:only_reblogs).presence || false end - def no_reblogs_scope - Status.without_reblogs + def only_replies? + return false unless owner? || @account.show_replies? + + truthy_param?(:only_replies).presence || false end - def hashtag_scope - tag = Tag.find_normalized(params[:tagged]) + def mentions_scope + return Status.none unless current_account? + + Status.mentions_between(@account, current_account) + end - if tag - Status.tagged_with(tag.id) - else - Status.none - end + def pinned_scope + return Status.none if @account.blocking?(current_account) + + @account.pinned_statuses end def pagination_params(core_params) - params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params) + params.slice(:limit, :only_media, :include_replies, :exclude_replies, :only_replies, :include_reblogs, :exclude_reblogs, :only_relogs, :mentions) + .permit(:limit, :only_media, :include_replies, :exclude_replies, :only_replies, :include_reblogs, :exclude_reblogs, :only_relogs, :mentions) + .merge(core_params) end def insert_pagination_headers @@ -78,15 +105,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def next_path - if records_continue? - api_v1_account_statuses_url pagination_params(max_id: pagination_max_id) - end + api_v1_account_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? end def prev_path - unless @statuses.empty? - api_v1_account_statuses_url pagination_params(min_id: pagination_since_id) - end + api_v1_account_statuses_url pagination_params(min_id: pagination_since_id) unless @statuses.empty? end def records_continue? diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 0080faf33..e9f848ac9 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -44,7 +44,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications)) + MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), timelines_only: truthy_param?(:timelines_only)) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb new file mode 100644 index 000000000..1b150d480 --- /dev/null +++ b/app/controllers/api/v1/admin/domain_allows_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Api::V1::Admin::DomainAllowsController < Api::BaseController + include Authorization + + LIMIT = 100 + + before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:domain_allows' }, only: :show + before_action :require_staff! + after_action :insert_pagination_headers, only: :show + + def show + @allows = load_domain_allows + render json: @allows + end + + private + + def load_domain_allows + DomainAllow.paginate_by_max_id( + limit_param(LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_admin_domain_allows_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_admin_domain_allows_url pagination_params(since_id: pagination_since_id) unless @allows.empty? + end + + def pagination_max_id + @allows.last.id + end + + def pagination_since_id + @allows.first.id + end + + def records_continue? + @allows.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb new file mode 100644 index 000000000..c0ce0da25 --- /dev/null +++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Api::V1::Admin::DomainBlocksController < Api::BaseController + include Authorization + + LIMIT = 100 + + before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:domain_blocks' }, only: :show + before_action :require_staff! + after_action :insert_pagination_headers, only: :show + + def show + @blocks = load_domain_blocks + render json: @blocks + end + + private + + def load_domain_blocks + DomainBlock.paginate_by_max_id( + limit_param(LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_admin_domain_blocks_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_admin_domain_blocks_url pagination_params(since_id: pagination_since_id) unless @blocks.empty? + end + + def pagination_max_id + @blocks.last.id + end + + def pagination_since_id + @blocks.first.id + end + + def records_continue? + @blocks.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/domain_permissions_controller.rb b/app/controllers/api/v1/domain_permissions_controller.rb new file mode 100644 index 000000000..1b0e37135 --- /dev/null +++ b/app/controllers/api/v1/domain_permissions_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class Api::V1::DomainPermissionsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:domain_permissions', :'read:domain_permissions:account' }, only: :show + before_action -> { doorkeeper_authorize! :write, :'write:domain_permissions', :'write:domain_permissions:account' }, only: [:create, :update, :destroy] + before_action :require_user! + before_action :set_permission, except: [:show, :create] + after_action :insert_pagination_headers + + LIMIT = 100 + + def show + @permissions = load_account_domain_permissions + render json: @permissions, each_serializer: REST::AccountDomainPermissionSerializer + end + + def create + @permission = current_account.domain_permissions.create!(domain_permission_params) + render json: @permission, serializer: REST::AccountDomainPermissionSerializer + end + + def update + @permission.update!(domain_permission_params) + render json: @permission, serializer: REST::AccountDomainPermissionSerializer + end + + def destroy + @permission.destroy! + render_empty + end + + private + + def load_account_domain_permissions + account_domain_permissions.paginate_by_max_id( + limit_param(LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def set_permission + @permission = current_account.domain_permissions.find(params[:id]) + end + + def account_domain_permissions + current_account.domain_permissions + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_domain_permissions_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_domain_permissions_url pagination_params(since_id: pagination_since_id) unless @permissions.empty? + end + + def pagination_max_id + @permissions.last.id + end + + def pagination_since_id + @permissions.first.id + end + + def records_continue? + @permissions.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def domain_permission_params + params.permit(:domain, :visibility) + end +end diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb index 4f6b4bcbf..f2ac902e1 100644 --- a/app/controllers/api/v1/instances/activity_controller.rb +++ b/app/controllers/api/v1/instances/activity_controller.rb @@ -4,7 +4,7 @@ class Api::V1::Instances::ActivityController < Api::BaseController before_action :require_enabled_api! skip_before_action :set_cache_headers - skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + skip_before_action :require_authenticated_user! #, unless: :whitelist_mode? def show expires_in 1.day, public: true @@ -33,6 +33,6 @@ class Api::V1::Instances::ActivityController < Api::BaseController end def require_enabled_api! - head 404 unless Setting.activity_api_enabled && !whitelist_mode? + head 404 unless Setting.activity_api_enabled #&& !whitelist_mode? end end diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index 9fa440935..d30ef1fe9 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -4,7 +4,7 @@ class Api::V1::Instances::PeersController < Api::BaseController before_action :require_enabled_api! skip_before_action :set_cache_headers - skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + skip_before_action :require_authenticated_user! #, unless: :whitelist_mode? def index expires_in 1.day, public: true @@ -14,6 +14,6 @@ class Api::V1::Instances::PeersController < Api::BaseController private def require_enabled_api! - head 404 unless Setting.peers_api_enabled && !whitelist_mode? + head 404 unless Setting.peers_api_enabled #&& !whitelist_mode? end end diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index 5b5058a7b..844bab68a 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -2,7 +2,7 @@ class Api::V1::InstancesController < Api::BaseController skip_before_action :set_cache_headers - skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + skip_before_action :require_authenticated_user! #, unless: :whitelist_mode? def show expires_in 3.minutes, public: true diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index 513b937ef..91ca96ef0 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -17,6 +17,7 @@ class Api::V1::Polls::VotesController < Api::BaseController def set_poll @poll = Poll.attached.find(params[:poll_id]) authorize @poll.status, :show? + authorize @poll.status.reblog, :show? if @poll.status.reblog? rescue Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index 6435e9f0d..75f5a9f08 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -16,6 +16,7 @@ class Api::V1::PollsController < Api::BaseController def set_poll @poll = Poll.attached.find(params[:id]) authorize @poll.status, :show? + authorize @poll.status.reblog, :show? if @poll.status.reblog? rescue Mastodon::NotPermittedError not_found end diff --git a/app/controllers/api/v1/statuses/hides_controller.rb b/app/controllers/api/v1/statuses/hides_controller.rb new file mode 100644 index 000000000..8c5457c82 --- /dev/null +++ b/app/controllers/api/v1/statuses/hides_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::HidesController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:mutes' } + before_action :require_user! + before_action :set_status + + def create + MuteStatusService.new.call(current_account, @status) + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + current_account.unmute_status!(@status) + render json: @status, serializer: REST::StatusSerializer + end + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/api/v1/statuses/mutes_controller.rb b/app/controllers/api/v1/statuses/mutes_controller.rb index 87071a2b9..73d9df734 100644 --- a/app/controllers/api/v1/statuses/mutes_controller.rb +++ b/app/controllers/api/v1/statuses/mutes_controller.rb @@ -9,12 +9,14 @@ class Api::V1::Statuses::MutesController < Api::BaseController before_action :set_conversation def create - current_account.mute_conversation!(@conversation) + MuteConversationService.new.call(current_account, @status.conversation, hidden: truthy_param?(:hide)) @mutes_map = { @conversation.id => true } render json: @status, serializer: REST::StatusSerializer end + alias update create + def destroy current_account.unmute_conversation!(@conversation) @mutes_map = { @conversation.id => false } diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb index 51b1621b6..187b6145c 100644 --- a/app/controllers/api/v1/statuses/pins_controller.rb +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -9,7 +9,7 @@ class Api::V1::Statuses::PinsController < Api::BaseController def create StatusPin.create!(account: current_account, status: @status) - distribute_add_activity! + distribute_add_activity! unless @status.semiprivate? render json: @status, serializer: REST::StatusSerializer end diff --git a/app/controllers/api/v1/statuses/publishing_controller.rb b/app/controllers/api/v1/statuses/publishing_controller.rb new file mode 100644 index 000000000..97c052e22 --- /dev/null +++ b/app/controllers/api/v1/statuses/publishing_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::PublishingController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:statuses:publish' } + before_action :require_user! + before_action :set_status + + def create + PublishStatusService.new.call(@status) + + render json: @status, + serializer: (@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer), + source_requested: truthy_param?(:source) + end + + private + + def set_status + @status = Status.unpublished.find(params[:status_id]) + authorize @status, :destroy? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index c8529318f..cbd232a50 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -19,7 +19,7 @@ class Api::V1::StatusesController < Api::BaseController def show @status = cache_collection([@status], Status).first - render json: @status, serializer: REST::StatusSerializer + render json: @status, serializer: REST::StatusSerializer, source_requested: truthy_param?(:source) end def context @@ -31,7 +31,7 @@ class Api::V1::StatusesController < Api::BaseController @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) statuses = [@status] + @context.ancestors + @context.descendants - render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id), current_account_id: current_user&.account_id end def create @@ -41,24 +41,80 @@ class Api::V1::StatusesController < Api::BaseController media_ids: status_params[:media_ids], sensitive: status_params[:sensitive], spoiler_text: status_params[:spoiler_text], + title: status_params[:title], + footer: status_params[:footer], + notify: status_params[:notify], + publish: status_params[:publish], visibility: status_params[:visibility], scheduled_at: status_params[:scheduled_at], application: doorkeeper_token.application, poll: status_params[:poll], content_type: status_params[:content_type], + tags: parse_tags_param(status_params[:tags]), + mentions: parse_mentions_param(status_params[:mentions]), idempotency: request.headers['Idempotency-Key'], - with_rate_limit: true) + with_rate_limit: true, + expires_at: status_params[:expires_at], + publish_at: status_params[:publish_at]) - render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer + render json: @status, + serializer: (@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer), + source_requested: truthy_param?(:source) + end + + def update + @status = Status.where(account_id: current_user.account).find(params[:id]) + authorize @status, :destroy? + + @status = PostStatusService.new.call(current_user.account, + text: status_params[:status], + thread: @thread, + media_ids: status_params[:media_ids], + sensitive: status_params[:sensitive], + spoiler_text: status_params[:spoiler_text], + title: status_params[:title], + footer: status_params[:footer], + notify: status_params[:notify], + publish: status_params[:publish], + visibility: status_params[:visibility], + scheduled_at: status_params[:scheduled_at], + application: doorkeeper_token.application, + poll: status_params[:poll], + content_type: status_params[:content_type], + status: @status, + tags: parse_tags_param(status_params[:tags]), + mentions: parse_mentions_param(status_params[:mentions]), + idempotency: request.headers['Idempotency-Key'], + with_rate_limit: true, + expires_at: status_params[:expires_at], + publish_at: status_params[:publish_at]) + + render json: @status, + serializer: (@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer), + source_requested: truthy_param?(:source) end def destroy @status = Status.where(account_id: current_user.account).find(params[:id]) authorize @status, :destroy? - @status.discard - RemovalWorker.perform_async(@status.id, redraft: true) - @status.account.statuses_count = @status.account.statuses_count - 1 + if !(current_user.setting_unpublish_on_delete && @status.published?) || truthy_param?(:redraft) + @status.discard + RemovalWorker.perform_async(@status.id, redraft: true) + @status.account.statuses_count = @status.account.statuses_count - 1 + else + RemovalWorker.perform_async(@status.id, redraft: true, unpublish: true) + tag_script = "#!redraft #{@status.id}\n" + @status.text = "#{tag_script}#{@status.text.sub(/^\s*#!redraft \d+\n/, '')}" + @status.original_text = "#{tag_script}#{@status.original_text.sub(/^\s*#!redraft \d+\n/, '')}" + end + + @status.local_only = @status.originally_local_only? + unless @status.original_text.match?(/^\s*#!\s*federate\b/i) + tag_script = "#!federate #{@status.originally_local_only? ? 'off' : 'on'}\n" + @status.text.prepend(tag_script) + @status.original_text.prepend(tag_script) + end render json: @status, serializer: REST::StatusSerializer, source_requested: true end @@ -84,9 +140,17 @@ class Api::V1::StatusesController < Api::BaseController :in_reply_to_id, :sensitive, :spoiler_text, + :title, + :footer, + :notify, + :publish, :visibility, :scheduled_at, :content_type, + :expires_at, + :publish_at, + tags: [], + mentions: [], media_ids: [], poll: [ :multiple, @@ -100,4 +164,26 @@ class Api::V1::StatusesController < Api::BaseController def pagination_params(core_params) params.slice(:limit).permit(:limit).merge(core_params) end + + def parse_tags_param(tags_param) + return if tags_param.blank? + + tags_param.select { |value| value.respond_to?(:to_str) && value.present? } + end + + def parse_mentions_param(mentions_param) + return if mentions_param.blank? + + mentions_param.map do |value| + next if value.blank? + + value = value.split('@', 3) if value.respond_to?(:to_str) + next unless value.is_a?(Enumerable) + + mentioned_account = Account.find_by(username: value[0], domain: value[1]) + next if mentioned_account.nil? || mentioned_account.suspended? + + mentioned_account + end + end end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 52b5cb323..b53f7750f 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -41,7 +41,8 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def public_timeline_statuses - Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : truthy_param?(:local)) + local = truthy_param?(:local) ? true : :local_reblogs + Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : local) end def insert_pagination_headers diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e996c2217..8154924b9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base include SessionTrackingConcern include CacheConcern include DomainControlHelper + include SignatureVerification helper_method :current_account helper_method :current_session @@ -48,7 +49,7 @@ class ApplicationController < ActionController::Base end def authorized_fetch_mode? - ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.whitelist_mode + !(Rails.env.development? || Rails.env.test?) end def public_fetch_mode? @@ -68,7 +69,29 @@ class ApplicationController < ActionController::Base end def require_functional! - redirect_to edit_user_registration_path unless current_user.functional? + redirect_to edit_user_registration_path unless current_user&.functional? + end + + def require_authenticated! + return if current_account? + + respond_to do |format| + format.any { redirect_to edit_user_registration_path } + format.json { forbidden } + end + end + + def require_known!(account) + return if authenticated_or_following?(account) + + respond_to do |format| + format.any { redirect_to edit_user_registration_path } + format.json { forbidden } + end + end + + def require_following!(account) + forbidden unless following?(account) end def after_sign_out_path_for(_resource_or_scope) @@ -197,7 +220,7 @@ class ApplicationController < ActionController::Base def current_account return @current_account if defined?(@current_account) - @current_account = current_user&.account + @current_account = current_user&.account.presence || signed_request_account end def current_session @@ -225,4 +248,21 @@ class ApplicationController < ActionController::Base format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code } end end + + def following?(account) + return if account.blank? + + @account_following ||= {} + return @account_following[account.id] if @account_following[account.id].present? + + @account_following[account.id] = current_account.present? && (current_account.id == account.id || current_account.following?(account)) + end + + def authenticated_or_following?(account) + current_user&.account.present? || following?(account) + end + + def current_account? + current_account.present? + end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 96d973394..55975b274 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -35,6 +35,12 @@ class Auth::RegistrationsController < Devise::RegistrationsController end end + def create + super do |resource| + return redirect_to root_path if resource.destroyed? + end + end + protected def update_resource(resource, params) @@ -55,7 +61,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController def configure_sign_up_params devise_parameter_sanitizer.permit(:sign_up) do |u| - u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement) + u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :username, :email, :password, :password_confirmation, :kobold, :invite_code, :agreement) end end diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb index 460f71f65..65168efff 100644 --- a/app/controllers/concerns/account_owned_concern.rb +++ b/app/controllers/concerns/account_owned_concern.rb @@ -4,7 +4,7 @@ module AccountOwnedConcern extend ActiveSupport::Concern included do - before_action :authenticate_user!, if: -> { whitelist_mode? && request.format != :json } + #before_action :authenticate_user!, if: -> { whitelist_mode? && request.format != :json } before_action :set_account, if: :account_required? before_action :check_account_approval, if: :account_required? before_action :check_account_suspension, if: :account_required? diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index c9b840881..d15adbf62 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -47,7 +47,7 @@ class HomeController < ApplicationController end def default_redirect_path - if request.path.start_with?('/web') || whitelist_mode? + if request.path.start_with?('/web') #|| whitelist_mode? new_user_session_path elsif single_user_mode? short_account_path(Account.local.without_suspended.where('id > 0').first) diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 772fc42cb..db8ccd173 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -4,9 +4,9 @@ class MediaController < ApplicationController include Authorization skip_before_action :store_current_location - skip_before_action :require_functional!, unless: :whitelist_mode? + skip_before_action :require_functional! #, unless: :whitelist_mode? - before_action :authenticate_user!, if: :whitelist_mode? + #before_action :authenticate_user!, if: :whitelist_mode? before_action :set_media_attachment before_action :verify_permitted_status! before_action :check_playable, only: :player @@ -33,6 +33,7 @@ class MediaController < ApplicationController def verify_permitted_status! authorize @media_attachment.status, :show? + authorize @media_attachment.status.reblog, :show? if @media_attachment.status.reblog? rescue Mastodon::NotPermittedError not_found end diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index 0b1d09de9..ee7568a33 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -7,7 +7,7 @@ class MediaProxyController < ApplicationController skip_before_action :store_current_location skip_before_action :require_functional! - before_action :authenticate_user!, if: :whitelist_mode? + #before_action :authenticate_user!, if: :whitelist_mode? rescue_from ActiveRecord::RecordInvalid, with: :not_found rescue_from Mastodon::UnexpectedResponseError, with: :not_found @@ -19,6 +19,7 @@ class MediaProxyController < ApplicationController if lock.acquired? @media_attachment = MediaAttachment.remote.attached.find(params[:id]) authorize @media_attachment.status, :show? + authorize @media_attachment.status.reblog, :show? if @media_attachment.status.reblog? redownload! if @media_attachment.needs_redownload? && !reject_media? else raise Mastodon::RaceConditionError diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb index a277bfa10..5ead3aaa0 100644 --- a/app/controllers/remote_interaction_controller.rb +++ b/app/controllers/remote_interaction_controller.rb @@ -5,13 +5,13 @@ class RemoteInteractionController < ApplicationController layout 'modal' - before_action :authenticate_user!, if: :whitelist_mode? + #before_action :authenticate_user!, if: :whitelist_mode? before_action :set_interaction_type before_action :set_status before_action :set_body_classes before_action :set_pack - skip_before_action :require_functional!, unless: :whitelist_mode? + skip_before_action :require_functional! #, unless: :whitelist_mode? def new @remote_follow = RemoteFollow.new(session_params) diff --git a/app/controllers/settings/preferences/filters_controller.rb b/app/controllers/settings/preferences/filters_controller.rb new file mode 100644 index 000000000..c58a698ef --- /dev/null +++ b/app/controllers/settings/preferences/filters_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Settings::Preferences::FiltersController < Settings::PreferencesController + private + + def after_update_redirect_path + settings_preferences_filters_path + end +end diff --git a/app/controllers/settings/preferences/publishing_controller.rb b/app/controllers/settings/preferences/publishing_controller.rb new file mode 100644 index 000000000..5b298d94d --- /dev/null +++ b/app/controllers/settings/preferences/publishing_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Settings::Preferences::PublishingController < Settings::PreferencesController + private + + def after_update_redirect_path + settings_preferences_publishing_path + end +end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 75c3e2495..ddbf89665 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -11,6 +11,7 @@ class Settings::PreferencesController < Settings::BaseController user_settings.update(user_settings_params.to_h) if current_user.update(user_params) + Rails.cache.delete("filter_settings:#{current_user.account_id}") I18n.locale = current_user.locale redirect_to after_update_redirect_path, notice: I18n.t('generic.changes_saved_msg') else @@ -61,6 +62,21 @@ class Settings::PreferencesController < Settings::BaseController :setting_use_pending_items, :setting_trends, :setting_crop_images, + :setting_manual_publish, + :setting_style_dashed_nest, + :setting_style_underline_a, + :setting_style_css_profile, + :setting_style_css_webapp, + :setting_style_wide_media, + :setting_publish_in, + :setting_unpublish_in, + :setting_unpublish_delete, + :setting_boost_every, + :setting_boost_jitter, + :setting_boost_random, + :setting_filter_to_unknown, + :setting_filter_from_unknown, + :setting_unpublish_on_delete, notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag), interactions: %i(must_be_follower must_be_following must_be_following_dm) ) diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 19a7ce157..8c4efa21d 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -23,7 +23,9 @@ class Settings::ProfilesController < Settings::BaseController private def account_params - params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value]) + params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, + :require_dereference, :show_replies, :show_unlisted, :private, :require_auth, + fields_attributes: [:name, :value]) end def set_account diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index a6ab8828f..6f8e74414 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -8,7 +8,9 @@ class StatusesController < ApplicationController layout 'public' - before_action :require_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } + before_action :require_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? && current_user&.account_id != @account.id } + before_action :require_authenticated!, if: -> { @account.require_auth? } + before_action -> { require_following!(@account) }, if: -> { request.format != :json && @account.private? } before_action :set_status before_action :set_instance_presenter before_action :set_link_headers @@ -19,7 +21,7 @@ class StatusesController < ApplicationController before_action :set_autoplay, only: :embed skip_around_action :set_locale, if: -> { request.format == :json } - skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode? + skip_before_action :require_functional!, only: [:show, :embed] # , unless: :whitelist_mode? content_security_policy only: :embed do |p| p.frame_ancestors(false) @@ -37,14 +39,18 @@ class StatusesController < ApplicationController format.json do expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? - render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter + render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, target_domain: current_account&.domain end end end def activity expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? - render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter + render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), + content_type: 'application/activity+json', + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter, + target_domain: current_account&.domain end def embed diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 69db89eb3..368419ef5 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -9,13 +9,13 @@ class TagsController < ApplicationController layout 'public' before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } - before_action :authenticate_user!, if: :whitelist_mode? + # before_action :authenticate_user!, if: :whitelist_mode? before_action :set_tag before_action :set_local before_action :set_body_classes before_action :set_instance_presenter - skip_before_action :require_functional!, unless: :whitelist_mode? + skip_before_action :require_functional! # , unless: :whitelist_mode? def show respond_to do |format| @@ -37,10 +37,12 @@ class TagsController < ApplicationController format.json do expires_in 3.minutes, public: public_fetch_mode? - @statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, @local).paginate_by_max_id(PAGE_SIZE, params[:max_id]) + @statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, @local) + @statuses = @statuses.without_semiprivate unless authenticated_or_following?(@account) + @statuses = @statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id]) @statuses = cache_collection(@statuses, Status) - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', target_domain: current_account&.domain end end end diff --git a/app/controllers/user_profile_css_controller.rb b/app/controllers/user_profile_css_controller.rb new file mode 100644 index 000000000..0a0588e88 --- /dev/null +++ b/app/controllers/user_profile_css_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class UserProfileCssController < ApplicationController + skip_before_action :store_current_location + skip_before_action :require_functional! + + before_action :set_cache_headers + before_action :set_account + + def show + expires_in 3.minutes, public: true + render plain: css, content_type: 'text/css' + end + + private + + def css + @account.user&.setting_style_css_profile_errors.blank? ? (@account.user&.setting_style_css_profile || '') : '' + end + + def set_account + @account = Account.find(params[:id]) + end +end diff --git a/app/controllers/user_webapp_css_controller.rb b/app/controllers/user_webapp_css_controller.rb new file mode 100644 index 000000000..b2baa2843 --- /dev/null +++ b/app/controllers/user_webapp_css_controller.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +class UserWebappCssController < ApplicationController + skip_before_action :store_current_location + skip_before_action :require_functional! + + before_action :set_cache_headers + before_action :set_account + + def show + expires_in 3.minutes, public: false + render plain: css, content_type: 'text/css' + end + + private + + def css_dashed_nest + return unless @account.user&.setting_style_dashed_nest + + %( + div[data-nest-level] + { border-style: dashed; } + ) + end + + def css_underline_a + return unless @account.user&.setting_style_underline_a + + %( + .status__content__text a, + .reply-indicator__content a, + .composer--reply > .content a, + .account__header__content a + { text-decoration: underline; } + + .status__content__text a:hover, + .reply-indicator__content a:hover, + .composer--reply > .content a:hover, + .account__header__content a:hover + { text-decoration: none; } + ) + end + + def css_wide_media + return unless @account.user&.setting_style_wide_media + + %( + .media-gallery + { height: auto !important; } + + .media-gallery__item + { width: 100% !important; } + + .spoiler-button + .media-gallery__item + { height: 5em !important; } + + .spoiler-button--minified + .media-gallery__item + { height: 280px !important; } + ) + end + + def css_webapp + @account.user&.setting_style_css_webapp_errors.blank? ? (@account.user&.setting_style_css_webapp || '') : '' + end + + def css + "#{css_dashed_nest}\n#{css_underline_a}\n#{css_wide_media}\n#{css_webapp}".squish + end + + def set_account + @account = Account.find(params[:id]) + end +end diff --git a/app/helpers/domain_control_helper.rb b/app/helpers/domain_control_helper.rb index ac60cad29..765ffa536 100644 --- a/app/helpers/domain_control_helper.rb +++ b/app/helpers/domain_control_helper.rb @@ -20,6 +20,6 @@ module DomainControlHelper end def whitelist_mode? - Rails.configuration.x.whitelist_mode + !(Rails.env.development? || Rails.env.test?) end end diff --git a/app/helpers/img_proxy_helper.rb b/app/helpers/img_proxy_helper.rb new file mode 100644 index 000000000..5ea4bcd93 --- /dev/null +++ b/app/helpers/img_proxy_helper.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# .~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~. # +################### Cthulhu Code! ################### +# `~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~` # +# - Has a high complexity level and needs tests. # +# - Makes many assumptions the environment it's included into. # +# - Incurs a high performance penalty. # +# # +############################################################################### + +module ImgProxyHelper + def process_inline_images! + raise NameError('@status must be defined by the instance this method is being called from.') unless defined?(@status) + return if @status.text&.strip.blank? || @status.content_type == 'text/plain' + + replace_markdown_images_with_html! + + handler = ImgTagHandler.new + Ox.sax_parse(handler, StringIO.new(@status.text, 'r')) + return if handler.srcs.blank? + + @skip_download_from = { @status.account.domain => DomainBlock.reject_media?(@status.account.domain) } + @redownload_attachment_ids = Set[] + + handler.srcs.each do |src| + alt = handler.alts[src] + normalized_src_parts = begin + Addressable::URI.parse(src&.strip).normalize + rescue Addressable::URI::InvalidURIError + nil + end + normalized_src = normalized_src_parts.to_s + + next replace_text!(src) if normalized_src.blank? || skip_download_from?(normalized_src_parts.host) + + file_name = normalized_src_parts.path.split('/').last + media_attachment = find_media_attachment(normalized_src, file_name) + + if media_attachment.present? + media_attachment.update(description: alt) if alt_more_descriptive?(alt, media_attachment.description) + elsif normalized_src_parts.scheme.blank? || !file_name.match?(/\S\.\w{3,}/) + next replace_text!(src) + else + media_attachment = create_media_attachment!(normalized_src, alt) + end + + next replace_text!(src) if media_attachment.blank? || media_attachment.destroyed? + + if media_attachment.needs_redownload? + replace_text!(src, "#{media_attachment.file.url(:small)}##{media_attachment.id}") + else + replace_text!(src, media_attachment.file.url(:small)) + end + end + end + + private + + def skip_download_from?(domain) + return true if @skip_download_from[@status.account.domain] + return @skip_download_from[domain] if @skip_download_from[domain] + + @skip_download_from[domain] = DomainBlock.reject_media?(domain) + end + + def unsupported_media_type?(mime_type) + mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type) + end + + def html_entities + @html_entities ||= HTMLEntities.new + end + + def replace_markdown_images_with_html! + return unless @status.content_type == 'text/markdown' + + @status.text.gsub!(/!\[(\S+)\]\(\s*(\S+)\s*\)/) do + begin + alt = html_entities.encode(Regexp.last_match(1).strip) + url = Addressable::URI.parse(Regexp.last_match(2)).normalize.to_s + "<img title=\"#{alt}\" alt=\"#{alt}\" src=\"#{url}\" />" + rescue Addressable::URI::InvalidURIError + '' + end + end + end + + def replace_text!(text, replacement = '') + @status.text.gsub!(text, replacement) + end + + def alt_more_descriptive?(alt, description) + return false unless alt.present? && description != alt + return true if description.blank? || alt.split(/[\s\n\r]+/).count > description.split(/[\s\n\r]+/).count + end + + def find_media_attachment(src, file_name) + media_attachment = src.start_with?('http') ? MediaAttachment.find_by(account: @account, remote_url: src, inline: true) : nil + return media_attachment if media_attachment.present? + + MediaAttachment.where(account: @status.account, file_file_name: file_name, inline: true) + .find { |m| [m.file.url(:small), m.file.url(:original)].include?(src) || m.status_id == @status.id } + end + + def create_media_attachment!(src, alt) + media_attachment = MediaAttachment.create!(account: @status.account, remote_url: src, description: alt, focus: nil, inline: true) + media_attachment = process_media_attachment!(media_attachment) + return if media_attachment.destroyed? + + @status.inlined_attachments.first_or_create!(media_attachment: media_attachment) + media_attachment + end + + def process_media_attachment!(media_attachment) + media_attachment.download_file! + media_attachment.download_thumbnail! + media_attachment.save! + media_attachment.destroy! if unsupported_media_type?(media_attachment.file.content_type) + media_attachment + rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError + return if @redownload_attachment_ids.include?(media_attachment.id) + + RedownloadMediaWorker.perform_in(rand(30..60).seconds, media_attachment.id) + @redownload_attachment_ids << media_attachment.id + media_attachment + end +end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 1c473efa3..b93284637 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -76,9 +76,31 @@ module JsonLdHelper json.present? && json['id'] == uri ? json : nil end + def uri_allowed?(uri) + host = Addressable::URI.parse(uri)&.normalized_host + Rails.cache.fetch("fetch_resource:#{host}", expires_in: 1.hour) { DomainAllow.allowed?(host) } + rescue Addressable::URI::InvalidURIError + false + end + def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false) + return unless uri_allowed?(uri) + on_behalf_of ||= Account.representative + skip_retry = on_behalf_of.id == -99 || Rails.env.development? + begin + fetch_body(uri, on_behalf_of, !skip_retry || raise_on_temporary_error) + rescue Mastodon::UnexpectedResponseError + raise if skip_retry + + fetch_body(uri, Account.representative, raise_on_temporary_error) + end + rescue Addressable::URI::InvalidURIError + nil + end + + def fetch_body(uri, on_behalf_of, raise_on_temporary_error = false) build_request(uri, on_behalf_of).perform do |response| raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error @@ -87,6 +109,9 @@ module JsonLdHelper end def body_to_json(body, compare_id: nil) + body.strip! if body.is_a?(String) + return if body.blank? + json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body return if compare_id.present? && json['id'] != compare_id @@ -114,7 +139,7 @@ module JsonLdHelper def build_request(uri, on_behalf_of = nil) Request.new(:get, uri).tap do |request| - request.on_behalf_of(on_behalf_of) if on_behalf_of + request.on_behalf_of(on_behalf_of) unless Rails.env.development? || on_behalf_of.blank? request.add_headers('Accept' => 'application/activity+json, application/ld+json') end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 87718dc05..bb98a71a5 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -2,6 +2,7 @@ module SettingsHelper HUMAN_LOCALES = { + 'en-MP': 'English (Monsterpit)', ar: 'العربية', ast: 'Asturianu', bg: 'Български', diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index e1012a80b..32e533bd0 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -264,11 +264,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id, notifications) { +export function muteAccount(id, notifications, timelinesOnly) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, timelinesOnly }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index f83738093..4c2cca9eb 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -147,6 +147,9 @@ export function submitCompose(routerHistory) { let media = getState().getIn(['compose', 'media_attachments']); const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']); let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : ''; + const id = getState().getIn(['compose', 'id'], null); + const submit_url = id ? `/api/v1/statuses/${id}` : '/api/v1/statuses'; + const submit_action = (res, body, config) => id ? api(getState).put(res, body, config) : api(getState).post(res, body, config); if ((!status || !status.length) && media.size === 0) { return; @@ -156,7 +159,7 @@ export function submitCompose(routerHistory) { if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) { status = status + ' 👁️'; } - api(getState).post('/api/v1/statuses', { + submit_action(submit_url, { status, content_type: getState().getIn(['compose', 'content_type']), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index 05955963c..729c8d700 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { export function searchTextFromRawStatus (status) { const spoilerText = status.spoiler_text || ''; - const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).concat(status.tags ? status.tags.map(tag => tag.name) : []).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; } @@ -53,11 +53,15 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.poll = status.poll.id; } + const oldUpdatedAt = normalOldStatus ? normalOldStatus.updated_at || normalOldStatus.created_at : null; + const newUpdatedAt = normalStatus ? normalStatus.updated_at || normalStatus.created_at : null; + // Only calculate these values when status first encountered // Otherwise keep the ones already in the reducer - if (normalOldStatus) { + if (normalOldStatus && oldUpdatedAt === newUpdatedAt) { normalStatus.search_index = normalOldStatus.get('search_index'); normalStatus.contentHtml = normalOldStatus.get('contentHtml'); + normalStatus.articleHtml = normalOldStatus.get('articleHtml'); normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); } else { const spoilerText = normalStatus.spoiler_text || ''; @@ -66,6 +70,7 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); + normalStatus.articleHtml = normalStatus.article_content ? emojify(normalStatus.article_content, emojiMap) : normalStatus.contentHtml; normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); } diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js index 927fc7415..645261627 100644 --- a/app/javascript/flavours/glitch/actions/mutes.js +++ b/app/javascript/flavours/glitch/actions/mutes.js @@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; +export const MUTES_TOGGLE_TIMELINES_ONLY = 'MUTES_TOGGLE_TIMELINES_ONLY'; export function fetchMutes() { return (dispatch, getState) => { @@ -104,3 +105,9 @@ export function toggleHideNotifications() { dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); }; } + +export function toggleTimelinesOnly() { + return dispatch => { + dispatch({ type: MUTES_TOGGLE_TIMELINES_ONLY }); + }; +} diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 4d2bda78b..018641fc7 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -12,6 +12,10 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; +export const STATUS_PUBLISH_REQUEST = 'STATUS_PUBLISH_REQUEST'; +export const STATUS_PUBLISH_SUCCESS = 'STATUS_PUBLISH_SUCCESS'; +export const STATUS_PUBLISH_FAIL = 'STATUS_PUBLISH_FAIL'; + export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; @@ -34,9 +38,9 @@ export function fetchStatusRequest(id, skipLoading) { }; }; -export function fetchStatus(id) { +export function fetchStatus(id, skipLoading = null) { return (dispatch, getState) => { - const skipLoading = getState().getIn(['statuses', id], null) !== null; + skipLoading = skipLoading === null ? getState().getIn(['statuses', id], null) !== null : skipLoading; dispatch(fetchContext(id)); @@ -55,6 +59,59 @@ export function fetchStatus(id) { }; }; +export function editStatus(status, routerHistory) { + return (dispatch, getState) => { + const id = status.get('id'); + + dispatch(fetchContext(id)); + dispatch(fetchStatusRequest(id, false)); + + api(getState).get(`/api/v1/statuses/${id}`, { params: { source: 1 } }).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(fetchStatusSuccess(false)); + dispatch(redraft(status, response.data.text, response.data.content_type, true)); + ensureComposeIsVisible(getState, routerHistory); + }).catch(error => { + dispatch(fetchStatusFail(id, error, false)); + }); + }; +}; + +export function publishStatus(id) { + return (dispatch, getState) => { + dispatch(publishStatusRequest(id)); + + api(getState).post(`/api/v1/statuses/${id}/publish`).then(() => { + dispatch(publishStatusSuccess(id)); + dispatch(fetchStatus(id, false)); + }).catch(error => { + dispatch(publishStatusFail(id, error)); + }); + }; +}; + +export function publishStatusRequest(id) { + return { + type: STATUS_PUBLISH_REQUEST, + id: id, + }; +}; + +export function publishStatusSuccess(id) { + return { + type: STATUS_PUBLISH_SUCCESS, + id: id, + }; +}; + +export function publishStatusFail(id, error) { + return { + type: STATUS_PUBLISH_FAIL, + id: id, + error: error, + }; +}; + export function fetchStatusSuccess(skipLoading) { return { type: STATUS_FETCH_SUCCESS, @@ -72,12 +129,13 @@ export function fetchStatusFail(id, error, skipLoading) { }; }; -export function redraft(status, raw_text, content_type) { +export function redraft(status, raw_text, content_type, inplace = false) { return { type: REDRAFT, status, raw_text, content_type, + inplace, }; }; @@ -91,7 +149,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) { dispatch(deleteStatusRequest(id)); - api(getState).delete(`/api/v1/statuses/${id}`).then(response => { + api(getState).delete(`/api/v1/statuses/${id}`, { params: { redraft: withRedraft?1:0 } } ).then(response => { dispatch(deleteStatusSuccess(id)); dispatch(deleteFromTimelines(id)); @@ -172,12 +230,16 @@ export function fetchContextFail(id, error) { }; }; -export function muteStatus(id) { +export function muteStatus(id, hide = false) { return (dispatch, getState) => { dispatch(muteStatusRequest(id)); - api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { + api(getState).post(`/api/v1/statuses/${id}/mute`, { params: { hide: hide?1:0 } }).then(() => { dispatch(muteStatusSuccess(id)); + + if (hide) { + dispatch(deleteFromTimelines(id)); + } }).catch(error => { dispatch(muteStatusFail(id, error)); }); diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index 35db5dcc9..295896e55 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -18,6 +18,7 @@ import { } from './announcements'; import { fetchFilters } from './filters'; import { getLocale } from 'mastodon/locales'; +import { resetCompose } from 'flavours/glitch/actions/compose'; const { messages } = getLocale(); @@ -96,6 +97,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'announcement.delete': dispatch(deleteAnnouncement(data.payload)); break; + case 'refresh': + dispatch(resetCompose()); + window.location.reload(); + break; } }, }; diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index b19666e62..bd79d64f5 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -133,7 +133,18 @@ export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => ex export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }, done); export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); -export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); +export const expandAccountTimeline = (accountId, { maxId, filter } = {}) => { + const path = filter ? filter : ''; + const params = { + include_replies: filter === ':replies', + include_reblogs: filter === ':reblogs', + only_reblogs: filter === ':reblogs', + mentions: filter === ':mentions', + max_id: maxId, + }; + + return expandTimeline(`account:${accountId}${path}`, `/api/v1/accounts/${accountId}/statuses`, params); +}; export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index 96042f07a..1ab9a6adb 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -384,6 +384,66 @@ class MediaGallery extends React.PureComponent { ); } + let parts = {}; + + media.map( + (attachment, i) => { + if (attachment.get('description')) { + if (attachment.get('description') in parts) { + parts[attachment.get('description')].push([i, attachment.get('url'), attachment.get('id')]); + } else { + parts[attachment.get('description')] = [[i, attachment.get('url'), attachment.get('id')]]; + } + } + }, + ); + + let descriptions = Object.entries(parts).map( + part => { + const [desc, idx] = part; + if (idx.length === 1) { + const url = idx[0][1]; + return ( + <p key={idx[0][2]}> + <strong> + <a href={url} title={url} target='_blank' rel='nofollow noopener'> + <FormattedMessage id='status.media.description' defaultMessage='Attachment #{index}: ' values={{ index: 1+idx[0][0] }} /> + </a> + </strong> + <span>{desc}</span> + </p> + ); + } else if (idx.length !== 0) { + const indexes = ( + <React.Fragment> + { + idx.map((i, c) => { + const url = i[1]; + return (<span key={i[2]}>{c === 0 ? ' ' : ', '}<a href={url} title={url} target='_blank' rel='nofollow noopener'>#{1+i[0]}</a></span>); + }) + } + </React.Fragment> + ); + return ( + <p key={idx[0][2]}> + <strong> + <FormattedMessage id='status.media.descriptions' defaultMessage='Attachments {list}: ' values={{ list: indexes }} /> + </strong> + <span>{desc}</span> + </p> + ); + } else { + return null; + } + }, + ); + + let description_wrapper = visible && ( + <div className='media-caption'> + {descriptions} + </div> + ); + return ( <div className={computedClass} style={style} ref={this.handleRef}> <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}> @@ -396,6 +456,7 @@ class MediaGallery extends React.PureComponent { </div> {children} + {description_wrapper} </div> ); } diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 4e628a420..69f93a2f1 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -73,6 +73,8 @@ class Status extends ImmutablePureComponent { onReblog: PropTypes.func, onBookmark: PropTypes.func, onDelete: PropTypes.func, + onEdit: PropTypes.func, + onPublish: PropTypes.func, onDirect: PropTypes.func, onMention: PropTypes.func, onPin: PropTypes.func, @@ -368,7 +370,7 @@ class Status extends ImmutablePureComponent { } handleExpandedToggle = () => { - if (this.props.status.get('spoiler_text')) { + if (this.props.status.get('spoiler_text') || this.props.status.get('reblogSpoilerHtml')) { this.setExpansion(!this.state.isExpanded); } }; @@ -672,6 +674,9 @@ class Status extends ImmutablePureComponent { // Users can use those for theming, hiding avatars etc via UserStyle const selectorAttribs = { 'data-status-by': `@${status.getIn(['account', 'acct'])}`, + 'data-nest-level': status.get('nest_level'), + 'data-nest-deep': status.get('nest_level') >= 15, + 'data-local-only': !!status.get('local_only'), }; if (prepend && account) { @@ -692,6 +697,7 @@ class Status extends ImmutablePureComponent { const computedClass = classNames('status', `status-${status.get('visibility')}`, { collapsed: isCollapsed, + unpublished: status.get('published') === false, 'has-background': isCollapsed && background, 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index cfb03c21b..0822239f5 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -13,6 +13,8 @@ import classNames from 'classnames'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, + publish: { id: 'status.publish', defaultMessage: 'Publish' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, @@ -63,6 +65,8 @@ class StatusActionBar extends ImmutablePureComponent { onFavourite: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, + onEdit: PropTypes.func, + onPublish: PropTypes.func, onDirect: PropTypes.func, onMention: PropTypes.func, onMute: PropTypes.func, @@ -125,7 +129,7 @@ class StatusActionBar extends ImmutablePureComponent { _openInteractionDialog = type => { window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); - } + } handleDeleteClick = () => { this.props.onDelete(this.props.status, this.context.router.history); @@ -135,6 +139,14 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onDelete(this.props.status, this.context.router.history, true); } + handleEditClick = () => { + this.props.onEdit(this.props.status, this.context.router.history); + } + + handlePublishClick = () => { + this.props.onPublish(this.props.status); + } + handlePinClick = () => { this.props.onPin(this.props.status); } @@ -221,10 +233,8 @@ class StatusActionBar extends ImmutablePureComponent { menu.push(null); - if (status.getIn(['account', 'id']) === me || withDismiss) { - menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); - menu.push(null); - } + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + menu.push(null); if (status.getIn(['account', 'id']) === me) { if (publicStatus) { @@ -233,6 +243,11 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); + menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); + + if (status.get('published') === false) { + menu.push({ text: intl.formatMessage(messages.publish), action: this.handlePublishClick }); + } } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index a39f747b8..a4546edfd 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import { isRtl } from 'flavours/glitch/util/rtl'; import { FormattedMessage } from 'react-intl'; import Permalink from './permalink'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; import classnames from 'classnames'; import Icon from 'flavours/glitch/components/icon'; import { autoPlayGif } from 'flavours/glitch/util/initial_state'; @@ -13,7 +14,7 @@ const textMatchesTarget = (text, origin, host) => { return (text === origin || text === host || text.startsWith(origin + '/') || text.startsWith(host + '/') || 'www.' + text === host || ('www.' + text).startsWith(host + '/')); -} +}; const isLinkMisleading = (link) => { let linkTextParts = []; @@ -77,11 +78,13 @@ export default class StatusContent extends React.PureComponent { onUpdate: PropTypes.func, tagLinks: PropTypes.bool, rewriteMentions: PropTypes.string, + article: PropTypes.bool, }; static defaultProps = { tagLinks: true, rewriteMentions: 'no', + article: false, }; state = { @@ -231,7 +234,7 @@ export default class StatusContent extends React.PureComponent { let element = e.target; while (element) { - if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName)) { + if (['button', 'video', 'a', 'label', 'canvas', 'details', 'summary'].includes(element.localName)) { return; } element = element.parentNode; @@ -271,23 +274,213 @@ export default class StatusContent extends React.PureComponent { disabled, tagLinks, rewriteMentions, + article, } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; - const content = { __html: status.get('contentHtml') }; - const spoilerContent = { __html: status.get('spoilerHtml') }; + const edited = (status.get('edited') === 0) ? null : ( + <div className='status__notice status__edit-notice'> + <Icon id='pencil-square-o' /> + <FormattedMessage + id='status.edited' + defaultMessage='{count, plural, one {# edit} other {# edits}} · last update: {updated_at}' + key={`edit-${status.get('id')}`} + values={{ + count: status.get('edited'), + updated_at: <RelativeTimestamp timestamp={status.get('updated_at')} />, + }} + /> + </div> + ); + + const unpublished = (status.get('published') === false) && ( + <div className='status__notice status__unpublished-notice'> + <Icon id='chain-broken' /> + <FormattedMessage + id='status.unpublished' + defaultMessage='Unpublished' + key={`unpublished-${status.get('id')}`} + /> + </div> + ); + + const local_only = (status.get('local_only') === true) && ( + <div className='status__notice status__localonly-notice'> + <Icon id='home' /> + <FormattedMessage + id='advanced_options.local-only.short' + defaultMessage='Local-only' + key={`localonly-${status.get('id')}`} + /> + </div> + ); + + const quiet = (status.get('notify') === false) && ( + <div className='status__notice status__quiet-notice'> + <Icon id='bell-slash' /> + <FormattedMessage + id='status.quiet' + defaultMessage='Quiet local publish' + key={`quiet-${status.get('id')}`} + /> + </div> + ); + + const article_content = status.get('article') && ( + <div className='status__notice status__article-notice'> + <Icon id='file-text-o' /> + <Permalink + href={status.get('url')} + to={`/statuses/${status.get('id')}`} + > + <FormattedMessage + id='status.article' + defaultMessage='Article' + key={`article-${status.get('id')}`} + /> + </Permalink> + </div> + ); + + const publish_at = status.get('publish_at') && ( + <div className='status__notice status__publish-notice'> + <Icon id='bullhorn' /> + <FormattedMessage + id='status.publish_at' + defaultMessage='Auto-publish: {publish_at}' + key={`publish-${status.get('id')}`} + values={{ + publish_at: <RelativeTimestamp timestamp={status.get('publish_at')} futureDate />, + }} + /> + </div> + ); + + const expires_at = !unpublished && status.get('expires_at') && ( + <div className='status__notice status__expires-notice'> + <Icon id='clock-o' /> + <FormattedMessage + id='status.expires_at' + defaultMessage='Self-destruct: {expires_at}' + key={`expires-${status.get('id')}`} + values={{ + expires_at: <RelativeTimestamp timestamp={status.get('expires_at')} futureDate />, + }} + /> + </div> + ); + + const status_notice_wrapper = ( + <div className='status__notice-wrapper'> + {unpublished} + {publish_at} + {expires_at} + {quiet} + {edited} + {local_only} + {article_content} + </div> + ); + + const permissions_present = status.get('domain_permissions') && status.get('domain_permissions').size > 0; + + const status_permission_items = permissions_present && status.get('domain_permissions').map((permission) => ( + <li className='permission-status'> + <Icon id='eye-slash' /> + <FormattedMessage + id='status.permissions.visibility.status' + defaultMessage='{visibility} 🡲 {domain}' + key={`permissions-visibility-${status.get('id')}`} + values={{ + domain: <span>{permission.get('domain')}</span>, + visibility: <span>{permission.get('visibility')}</span>, + }} + /> + </li> + )); + + const permissions = status_permission_items && ( + <details className='status__permissions' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <summary> + <Icon id='unlock-alt' /> + <FormattedMessage + id='status.permissions.title' + defaultMessage='Show extended permissions...' + key={`permissions-${status.get('id')}`} + /> + </summary> + <ul> + {status_permission_items} + </ul> + </details> + ); + + const tag_items = (status.get('tags') && status.get('tags').size > 0) && status.get('tags').map(hashtag => + ( + <li> + <Icon id='tag' /> + <Permalink + href={hashtag.get('url')} + to={`/timelines/tag/${hashtag.get('name')}`} + > + <span>{hashtag.get('name')}</span> + </Permalink> + </li> + )); + + const tags = tag_items && ( + <details className='status__tags' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <summary> + <Icon id='tag' /> + <FormattedMessage + id='status.tags' + defaultMessage='Show all tags...' + key={`tags-${status.get('id')}`} + /> + </summary> + <ul> + {tag_items} + </ul> + </details> + ); + + const footers = ( + <div className='status__footers'> + {permissions} + {tags} + </div> + ); + + const reblog_spoiler_html = status.get('reblogSpoilerPresent') && { __html: status.get('reblogSpoilerHtml') }; + const reblog_spoiler = reblog_spoiler_html && ( + <div className='reblog-spoiler spoiler'> + <Icon id='retweet' /> + <span dangerouslySetInnerHTML={reblog_spoiler_html} /> + </div> + ); + + const spoiler_html = status.get('spoiler_text').length > 0 && { __html: status.get('spoilerHtml') }; + const spoiler = spoiler_html && ( + <div className='spoiler'> + <Icon id='info-circle' /> + <span dangerouslySetInnerHTML={spoiler_html} /> + </div> + ); + + const spoiler_present = status.get('spoiler_text').length > 0 || status.get('reblogSpoilerPresent'); + const content = { __html: article ? status.get('articleHtml') : status.get('contentHtml') }; const directionStyle = { direction: 'ltr' }; const classNames = classnames('status__content', { 'status__content--with-action': parseClick && !disabled, - 'status__content--with-spoiler': status.get('spoiler_text').length > 0, + 'status__content--with-spoiler': spoiler_present, }); if (isRtl(status.get('search_index'))) { directionStyle.direction = 'rtl'; } - if (status.get('spoiler_text').length > 0) { + if (spoiler_present) { let mentionsPlaceholder = ''; const mentionLinks = status.get('mentions').map(item => ( @@ -302,11 +495,19 @@ export default class StatusContent extends React.PureComponent { )).reduce((aggregate, item) => [...aggregate, item, ' '], []); const toggleText = hidden ? [ - <FormattedMessage - id='status.show_more' - defaultMessage='Show more' - key='0' - />, + article ? ( + <FormattedMessage + id='status.show_article' + defaultMessage='Show article' + key='0' + /> + ) : ( + <FormattedMessage + id='status.show_more' + defaultMessage='Show more' + key='0' + /> + ), mediaIcon ? ( <Icon fixedWidth @@ -330,15 +531,18 @@ export default class StatusContent extends React.PureComponent { return ( <div className={classNames} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} ref={this.setRef}> - <p + {status_notice_wrapper} + <div style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} > - <span dangerouslySetInnerHTML={spoilerContent} /> - {' '} - <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> - {toggleText} - </button> - </p> + {reblog_spoiler} + {spoiler} + <div class='spoiler-actions'> + <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> + {toggleText} + </button> + </div> + </div> {mentionsPlaceholder} @@ -354,6 +558,8 @@ export default class StatusContent extends React.PureComponent { {media} </div> + {footers} + </div> ); } else if (parseClick) { @@ -366,6 +572,7 @@ export default class StatusContent extends React.PureComponent { tabIndex='0' ref={this.setRef} > + {status_notice_wrapper} <div ref={this.setContentsRef} key={`contents-${tagLinks}-${rewriteMentions}`} @@ -374,6 +581,7 @@ export default class StatusContent extends React.PureComponent { tabIndex='0' /> {media} + {footers} </div> ); } else { @@ -384,8 +592,10 @@ export default class StatusContent extends React.PureComponent { tabIndex='0' ref={this.setRef} > + {status_notice_wrapper} <div ref={this.setContentsRef} key={`contents-${tagLinks}`} className='status__content__text' dangerouslySetInnerHTML={content} tabIndex='0' /> {media} + {footers} </div> ); } diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 2cbe3d094..bccaba92d 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -17,7 +17,7 @@ import { pin, unpin, } from 'flavours/glitch/actions/interactions'; -import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; +import { muteStatus, unmuteStatus, deleteStatus, editStatus, publishStatus } from 'flavours/glitch/actions/statuses'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; @@ -38,6 +38,8 @@ const messages = defineMessages({ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + publishConfirm: { id: 'confirmations.publish.confirm', defaultMessage: 'Publish' }, + publishMessage: { id: 'confirmations.publish.message', defaultMessage: 'Are you ready to publish your post?' }, unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' }, author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' }, matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' }, @@ -166,6 +168,18 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onEdit (status, history) { + dispatch(editStatus(status, history)); + }, + + onPublish (status) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.publishMessage), + confirm: intl.formatMessage(messages.publishConfirm), + onConfirm: () => dispatch(publishStatus(status.get('id'))), + })); + }, + onDirect (account, router) { dispatch(directCompose(account, router)); }, diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js index 6576bff8e..2f5a943fd 100644 --- a/app/javascript/flavours/glitch/features/account/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js @@ -31,14 +31,20 @@ class ActionBar extends React.PureComponent { if (account.get('acct') !== account.get('username')) { extraInfo = ( <div className='account__disclaimer'> - <Icon id='info-circle' fixedWidth /> <FormattedMessage - id='account.disclaimer_full' - defaultMessage="Information below may reflect the user's profile incompletely." - /> - {' '} - <a target='_blank' rel='noopener' href={account.get('url')}> - <FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' /> - </a> + <p> + <Icon id='info-circle' fixedWidth /> <FormattedMessage + id='account.disclaimer_full' + defaultMessage="Information below may reflect the user's profile incompletely." + /> + </p> + <p> + <Icon id='link' fixedWidth /> <a target='_blank' rel='noopener' href={account.get('url')}> + <FormattedMessage + id='account.view_full_profile' + defaultMessage='View full profile' + /> + </a> + </p> </div> ); } @@ -51,17 +57,14 @@ class ActionBar extends React.PureComponent { <div className='account__action-bar-links'> <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> <FormattedMessage id='account.posts' defaultMessage='Posts' /> - <strong><FormattedNumber value={account.get('statuses_count')} /></strong> </NavLink> <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> <FormattedMessage id='account.follows' defaultMessage='Follows' /> - <strong><FormattedNumber value={account.get('following_count')} /></strong> </NavLink> <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> <FormattedMessage id='account.followers' defaultMessage='Followers' /> - <strong>{ account.get('followers_count') < 0 ? '-' : <FormattedNumber value={account.get('followers_count')} /> }</strong> </NavLink> </div> </div> diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js index 8195735a1..591f8dffc 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js +++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js @@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { FormattedMessage } from 'react-intl'; import { NavLink } from 'react-router-dom'; import MovedNote from './moved_note'; +import { me } from 'flavours/glitch/util/initial_state'; export default class Header extends ImmutablePureComponent { @@ -123,9 +124,12 @@ export default class Header extends ImmutablePureComponent { {!hideTabs && ( <div className='account__section-headline'> - <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink> - <NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink> + <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.threads' defaultMessage='Threads' /></NavLink> + { (account.get('id') === me || account.get('show_replies')) && + (<NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>) } + { (account.get('id') !== me) && (<NavLink exact to={`/accounts/${account.get('id')}/mentions`}><FormattedMessage id='account.mentions' defaultMessage='Mentions' /></NavLink>) } <NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink> + <NavLink exact to={`/accounts/${account.get('id')}/reblogs`}><FormattedMessage id='account.reblogs' defaultMessage='Boosts' /></NavLink> </div> )} </div> diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js index 5558ba2a3..66bf55ec4 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.js +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -17,15 +17,15 @@ import { fetchAccountIdentityProofs } from '../../actions/identity_proofs'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import TimelineHint from 'flavours/glitch/components/timeline_hint'; -const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { - const path = withReplies ? `${accountId}:with_replies` : accountId; +const mapStateToProps = (state, { params: { accountId }, filter = '' }) => { + const path = `${accountId}${filter}`; return { remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])), remoteUrl: state.getIn(['accounts', accountId, 'url']), isAccount: !!state.getIn(['accounts', accountId]), statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), - featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), + featuredStatusIds: !filter ? state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()) : ImmutableList(), isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), }; @@ -49,7 +49,7 @@ class AccountTimeline extends ImmutablePureComponent { featuredStatusIds: ImmutablePropTypes.list, isLoading: PropTypes.bool, hasMore: PropTypes.bool, - withReplies: PropTypes.bool, + filter: PropTypes.string, isAccount: PropTypes.bool, remote: PropTypes.bool, remoteUrl: PropTypes.string, @@ -57,24 +57,24 @@ class AccountTimeline extends ImmutablePureComponent { }; componentWillMount () { - const { params: { accountId }, withReplies } = this.props; + const { params: { accountId }, filter } = this.props; this.props.dispatch(fetchAccount(accountId)); this.props.dispatch(fetchAccountIdentityProofs(accountId)); - if (!withReplies) { + if (!filter) { this.props.dispatch(expandAccountFeaturedTimeline(accountId)); } - this.props.dispatch(expandAccountTimeline(accountId, { withReplies })); + this.props.dispatch(expandAccountTimeline(accountId, { filter })); } componentWillReceiveProps (nextProps) { - if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { + if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.filter !== this.props.filter) { this.props.dispatch(fetchAccount(nextProps.params.accountId)); this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId)); - if (!nextProps.withReplies) { + if (!nextProps.filter) { this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId)); } - this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies })); + this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { filter: nextProps.params.filter })); } } @@ -83,7 +83,7 @@ class AccountTimeline extends ImmutablePureComponent { } handleLoadMore = maxId => { - this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies })); + this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, filter: this.props.filter })); } setRef = c => { diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js index a7cb95222..1c05fdafc 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -71,10 +71,12 @@ class ComposeForm extends ImmutablePureComponent { onChangeVisibility: PropTypes.func, onPaste: PropTypes.func, onMediaDescriptionConfirm: PropTypes.func, + clearTimeout: PropTypes.bool, }; static defaultProps = { showSearch: false, + clearTimeout: null, }; handleChange = (e) => { @@ -149,6 +151,17 @@ class ComposeForm extends ImmutablePureComponent { this.handleSubmit(sideArm === 'none' ? null : sideArm); } + handleClearAll = () => { + if(!this.clearTimeout || this.clearTimeout === null) { + this.clearTimeout = window.setTimeout(() => { + this.clearTimeout = null; + }, 500); + } else { + this.clearTimeout = null; + this.props.onClearAll(); + } + } + // Selects a suggestion from the autofill. onSuggestionSelected = (tokenStart, token, value) => { this.props.onSuggestionSelected(tokenStart, token, value, ['text']); @@ -256,6 +269,7 @@ class ComposeForm extends ImmutablePureComponent { handleSecondarySubmit, handleSelect, handleSubmit, + handleClearAll, handleRefTextarea, } = this; const { @@ -281,6 +295,7 @@ class ComposeForm extends ImmutablePureComponent { suggestions, text, spoilersAlwaysOn, + clearTimeout, } = this.props; let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia); @@ -356,6 +371,7 @@ class ComposeForm extends ImmutablePureComponent { disabled={disabledButton} onSecondarySubmit={handleSecondarySubmit} onSubmit={handleSubmit} + onClearAll={handleClearAll} privacy={privacy} sideArm={sideArm} /> diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js index 97890f40d..e5a3d023f 100644 --- a/app/javascript/flavours/glitch/features/compose/components/publisher.js +++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js @@ -23,6 +23,10 @@ const messages = defineMessages({ defaultMessage: '{publish}!', id: 'compose_form.publish_loud', }, + clear: { + defaultMessage: 'Double-click to clear', + id: 'compose_form.clear', + }, }); export default @injectIntl @@ -34,6 +38,7 @@ class Publisher extends ImmutablePureComponent { intl: PropTypes.object.isRequired, onSecondarySubmit: PropTypes.func, onSubmit: PropTypes.func, + onClearAll: PropTypes.func, privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']), sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']), }; @@ -43,7 +48,7 @@ class Publisher extends ImmutablePureComponent { }; render () { - const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm } = this.props; + const { countText, disabled, intl, onClearAll, onSecondarySubmit, privacy, sideArm } = this.props; const diff = maxChars - length(countText || ''); const computedClass = classNames('composer--publisher', { @@ -53,6 +58,17 @@ class Publisher extends ImmutablePureComponent { return ( <div className={computedClass}> + <Button + className='clear' + onClick={onClearAll} + style={{ padding: null }} + title={intl.formatMessage(messages.clear)} + text={ + <span> + <Icon id='trash-o' /> + </span> + } + /> {sideArm && sideArm !== 'none' ? ( <Button className='side_arm' diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js index fcd2caf1b..3c641d7ec 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -12,6 +12,7 @@ import { selectComposeSuggestion, submitCompose, uploadCompose, + resetCompose, } from 'flavours/glitch/actions/compose'; import { openModal, @@ -82,6 +83,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(submitCompose(routerHistory)); }, + onClearAll() { + dispatch(resetCompose()); + }, + onClearSuggestions() { dispatch(clearComposeSuggestions()); }, diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index 0f16d93fe..b2c8ac87f 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -11,6 +11,8 @@ import classNames from 'classnames'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, + publish: { id: 'status.publish', defaultMessage: 'Publish' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, @@ -52,6 +54,8 @@ class ActionBar extends React.PureComponent { onMuteConversation: PropTypes.func, onBlock: PropTypes.func, onDelete: PropTypes.func.isRequired, + onEdit: PropTypes.func.isRequired, + onPublish: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onReport: PropTypes.func, @@ -84,6 +88,14 @@ class ActionBar extends React.PureComponent { this.props.onDelete(this.props.status, this.context.router.history, true); } + handleEditClick = () => { + this.props.onEdit(this.props.status, this.context.router.history); + } + + handlePublishClick = () => { + this.props.onPublish(this.props.status); + } + handleDirectClick = () => { this.props.onDirect(this.props.status.get('account'), this.context.router.history); } @@ -166,6 +178,11 @@ class ActionBar extends React.PureComponent { menu.push(null); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); + menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); + + if (status.get('published') === false) { + menu.push({ text: intl.formatMessage(messages.publish), action: this.handlePublishClick }); + } } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index e4aecbf94..4344e9cce 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -17,7 +17,7 @@ import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; import classNames from 'classnames'; import PollContainer from 'flavours/glitch/containers/poll_container'; import Icon from 'flavours/glitch/components/icon'; -import AnimatedNumber from 'flavours/glitch/components/animated_number'; +import { me } from 'flavours/glitch/util/initial_state'; export default class DetailedStatus extends ImmutablePureComponent { @@ -195,7 +195,7 @@ export default class DetailedStatus extends ImmutablePureComponent { applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>; } - const visibilityLink = <React.Fragment> · <VisibilityIcon visibility={status.get('visibility')} /></React.Fragment>; + const visibilityLink = <React.Fragment><VisibilityIcon visibility={status.get('visibility')} /> · </React.Fragment>; if (status.get('visibility') === 'direct') { reblogIcon = 'envelope'; @@ -203,7 +203,7 @@ export default class DetailedStatus extends ImmutablePureComponent { reblogIcon = 'lock'; } - if (!['unlisted', 'public'].includes(status.get('visibility'))) { + if (status.getIn(['account', 'id']) !== me || !['unlisted', 'public'].includes(status.get('visibility'))) { reblogLink = null; } else if (this.context.router) { reblogLink = ( @@ -211,9 +211,6 @@ export default class DetailedStatus extends ImmutablePureComponent { <React.Fragment> · </React.Fragment> <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> <Icon id={reblogIcon} /> - <span className='detailed-status__reblogs'> - <AnimatedNumber value={status.get('reblogs_count')} /> - </span> </Link> </React.Fragment> ); @@ -223,37 +220,43 @@ export default class DetailedStatus extends ImmutablePureComponent { <React.Fragment> · </React.Fragment> <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}> <Icon id={reblogIcon} /> - <span className='detailed-status__reblogs'> - <AnimatedNumber value={status.get('reblogs_count')} /> - </span> </a> </React.Fragment> ); } - if (this.context.router) { + if (status.getIn(['account', 'id']) !== me) { + favouriteLink = null; + } else if (this.context.router) { favouriteLink = ( - <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> - <Icon id='star' /> - <span className='detailed-status__favorites'> - <AnimatedNumber value={status.get('favourites_count')} /> - </span> - </Link> + <React.Fragment> + <React.Fragment> · </React.Fragment> + <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> + <Icon id='star' /> + </Link> + </React.Fragment> ); } else { favouriteLink = ( - <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> - <Icon id='star' /> - <span className='detailed-status__favorites'> - <AnimatedNumber value={status.get('favourites_count')} /> - </span> - </a> + <React.Fragment> + <React.Fragment> · </React.Fragment> + <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> + <Icon id='star' /> + </a> + </React.Fragment> ); } + const selectorAttribs = { + 'data-status-by': `@${status.getIn(['account', 'acct'])}`, + 'data-nest-level': status.get('nest_level'), + 'data-nest-deep': status.get('nest_level') >= 15, + 'data-local-only': !!status.get('local_only'), + }; + return ( <div style={outerStyle}> - <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}> + <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact, unpublished: status.get('published') === false })} {...selectorAttribs}> <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div> <DisplayName account={status.get('account')} localDomain={this.props.domain} /> @@ -270,13 +273,15 @@ export default class DetailedStatus extends ImmutablePureComponent { onUpdate={this.handleChildUpdate} tagLinks={settings.get('tag_misleading_links')} rewriteMentions={settings.get('rewrite_mentions')} + article disabled /> <div className='detailed-status__meta'> + {visibilityLink} <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> - </a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} + </a>{applicationLink}{reblogLink}{favouriteLink} </div> </div> </div> diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js index 9d11f37e0..124de903a 100644 --- a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js +++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js @@ -17,7 +17,9 @@ import { import { muteStatus, unmuteStatus, + editStatus, deleteStatus, + publishStatus, hideStatus, revealStatus, } from 'flavours/glitch/actions/statuses'; @@ -34,6 +36,8 @@ const messages = defineMessages({ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, + publishConfirm: { id: 'confirmations.publish.confirm', defaultMessage: 'Publish' }, + publishMessage: { id: 'confirmations.publish.message', defaultMessage: 'Are you ready to publish your post?' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, }); @@ -118,6 +122,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onEdit (status, history) { + dispatch(editStatus(status, history)); + }, + + onPublish (status) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.publishMessage), + confirm: intl.formatMessage(messages.publishConfirm), + onConfirm: () => dispatch(publishStatus(status.get('id'))), + })); + }, + onDirect (account, router) { dispatch(directCompose(account, router)); }, diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 3e2e95f35..3a6847e8d 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -26,7 +26,7 @@ import { directCompose, } from 'flavours/glitch/actions/compose'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; -import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; +import { muteStatus, unmuteStatus, deleteStatus, editStatus, publishStatus } from 'flavours/glitch/actions/statuses'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; @@ -50,6 +50,8 @@ const messages = defineMessages({ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' }, + publishConfirm: { id: 'confirmations.publish.confirm', defaultMessage: 'Publish' }, + publishMessage: { id: 'confirmations.publish.message', defaultMessage: 'Are you ready to publish your post?' }, revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' }, hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' }, detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' }, @@ -304,6 +306,20 @@ class Status extends ImmutablePureComponent { } } + handleEditClick = (status, history) => { + this.props.dispatch(editStatus(status, history)); + } + + handlePublishClick = (status) => { + const { dispatch, intl } = this.props; + + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.publishMessage), + confirm: intl.formatMessage(messages.publishConfirm), + onConfirm: () => dispatch(publishStatus(status.get('id'))), + })); + } + handleDirectClick = (account, router) => { this.props.dispatch(directCompose(account, router)); } @@ -588,6 +604,8 @@ class Status extends ImmutablePureComponent { onReblog={this.handleReblogClick} onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} + onEdit={this.handleEditClick} + onPublish={this.handlePublishClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} onMute={this.handleMuteClick} diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js index 4d7fc36c2..f8a61d2fb 100644 --- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js +++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js @@ -60,6 +60,7 @@ class LinkFooter extends React.PureComponent { id='getting_started.open_source_notice' defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.' values={{ + monsterware: <span><a href='https://monsterware.dev/monsterpit/monsterpit-mastodon' rel='noopener noreferrer' target='_blank'>MonsterWare</a></span>, github: <span><a href='https://github.com/glitch-soc/mastodon' rel='noopener noreferrer' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>, Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener noreferrer' target='_blank'>Mastodon</a> }} /> diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js index 2aab82751..eb4bc02d2 100644 --- a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js @@ -7,19 +7,21 @@ import Button from 'flavours/glitch/components/button'; import { closeModal } from 'flavours/glitch/actions/modal'; import { muteAccount } from 'flavours/glitch/actions/accounts'; import { toggleHideNotifications } from 'flavours/glitch/actions/mutes'; +import { toggleTimelinesOnly } from 'flavours/glitch/actions/mutes'; const mapStateToProps = state => { return { account: state.getIn(['mutes', 'new', 'account']), notifications: state.getIn(['mutes', 'new', 'notifications']), + timelinesOnly: state.getIn(['mutes', 'new', 'timelines_only']), }; }; const mapDispatchToProps = dispatch => { return { - onConfirm(account, notifications) { - dispatch(muteAccount(account.get('id'), notifications)); + onConfirm(account, notifications, timelinesOnly) { + dispatch(muteAccount(account.get('id'), notifications, timelinesOnly)); }, onClose() { @@ -29,6 +31,10 @@ const mapDispatchToProps = dispatch => { onToggleNotifications() { dispatch(toggleHideNotifications()); }, + + onToggleTimelinesOnly() { + dispatch(toggleTimelinesOnly()); + }, }; }; @@ -39,9 +45,11 @@ class MuteModal extends React.PureComponent { static propTypes = { account: PropTypes.object.isRequired, notifications: PropTypes.bool.isRequired, + timelinesOnly: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, onConfirm: PropTypes.func.isRequired, onToggleNotifications: PropTypes.func.isRequired, + onTimelinesOnly: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; @@ -51,7 +59,7 @@ class MuteModal extends React.PureComponent { handleClick = () => { this.props.onClose(); - this.props.onConfirm(this.props.account, this.props.notifications); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.timelinesOnly); } handleCancel = () => { @@ -66,8 +74,12 @@ class MuteModal extends React.PureComponent { this.props.onToggleNotifications(); } + toggleTimelinesOnly = () => { + this.props.onToggleTimelinesOnly(); + } + render () { - const { account, notifications } = this.props; + const { account, notifications, timelinesOnly } = this.props; return ( <div className='modal-root__modal mute-modal'> @@ -91,6 +103,13 @@ class MuteModal extends React.PureComponent { <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> </label> </div> + <div> + <label htmlFor='mute-modal__timelines-only-checkbox'> + <FormattedMessage id='mute_modal.timelines_only' defaultMessage='Hide from timelines only?' /> + {' '} + <Toggle id='mute-modal__timelines-only-checkbox' checked={timelinesOnly} onChange={this.toggleTimelinesOnly} /> + </label> + </div> </div> <div className='mute-modal__action-bar'> diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js index 9016b08d7..7473cfbe0 100644 --- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js @@ -30,7 +30,7 @@ const makeMapStateToProps = () => { account: getAccount(state, accountId), comment: state.getIn(['reports', 'new', 'comment']), forward: state.getIn(['reports', 'new', 'forward']), - statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), + statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), }; }; @@ -70,12 +70,12 @@ class ReportModal extends ImmutablePureComponent { } componentDidMount () { - this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true })); + this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { filter: ':replies' })); } componentWillReceiveProps (nextProps) { if (this.props.account !== nextProps.account && nextProps.account) { - this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true })); + this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { filter: ':replies' })); } } diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index bf76c0e57..ee1d898bb 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -214,8 +214,10 @@ class SwitchingColumnsArea extends React.PureComponent { <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> - <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} /> - <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} /> + <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} componentParams={{ filter: '' }} /> + <WrappedRoute path='/accounts/:accountId/mentions' component={AccountTimeline} content={children} componentParams={{ filter: ':mentions' }} /> + <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ filter: ':replies' }} /> + <WrappedRoute path='/accounts/:accountId/reblogs' component={AccountTimeline} content={children} componentParams={{ filter: ':reblogs' }} /> <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} /> <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} /> <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} /> diff --git a/app/javascript/flavours/glitch/locales/en-MP.js b/app/javascript/flavours/glitch/locales/en-MP.js new file mode 100644 index 000000000..a84552467 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/en-MP.js @@ -0,0 +1,4 @@ +import messages from 'flavours/glitch/locales/en'; +import messages_mp from 'mastodon/locales/en-MP.json'; + +export default Object.assign({}, messages, messages_mp); \ No newline at end of file diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index e081c31ad..e0ab9f9ab 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -66,6 +66,7 @@ const initialState = ImmutableMap({ do_not_federate: false, threaded_mode: false, }), + id: null, sensitive: false, elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends, spoiler: false, @@ -149,6 +150,7 @@ function apiStatusToTextHashtags (state, status) { function clearAll(state) { return state.withMutations(map => { + map.set('id', null); map.set('text', ''); if (defaultContentType) map.set('content_type', defaultContentType); map.set('spoiler', false); @@ -286,7 +288,9 @@ const expandMentions = status => { const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement; status.get('mentions').forEach(mention => { - fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`; + const selection = fragment.querySelector(`a[href="${mention.get('url')}"]`); + if (!selection) return; + selection.textContent = `@${mention.get('acct')}`; }); return fragment.innerHTML; @@ -403,9 +407,14 @@ export default function compose(state = initialState, action) { } }); case COMPOSE_REPLY_CANCEL: - state = state.setIn(['advanced_options', 'threaded_mode'], false); + return state.withMutations(map => { + map.set('id', null); + map.set('in_reply_to', null); + map.set('idempotencyKey', uuid()); + }); case COMPOSE_RESET: return state.withMutations(map => { + map.set('id', null); map.set('in_reply_to', null); if (defaultContentType) map.set('content_type', defaultContentType); map.set('text', ''); @@ -505,6 +514,7 @@ export default function compose(state = initialState, action) { let text = action.raw_text || unescapeHTML(expandMentions(action.status)); if (do_not_federate) text = text.replace(/ ?👁\ufe0f?\u200b?$/, ''); return state.withMutations(map => { + map.set('id', action.inplace ? action.status.get('id') : null); map.set('text', text); map.set('content_type', action.content_type || 'text/plain'); map.set('in_reply_to', action.status.get('in_reply_to_id')); diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js index 3d94d665c..9f383abae 100644 --- a/app/javascript/flavours/glitch/reducers/local_settings.js +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -10,18 +10,18 @@ const initialState = ImmutableMap({ stretch : true, navbar_under : false, swipe_to_change_columns: true, - side_arm : 'none', - side_arm_reply_mode : 'keep', - show_reply_count : false, - always_show_spoilers_field: false, - confirm_missing_media_description: false, + side_arm : 'private', + side_arm_reply_mode : 'restrict', + show_reply_count : true, + always_show_spoilers_field: true, + confirm_missing_media_description: true, confirm_boost_missing_media_description: false, confirm_before_clearing_draft: true, prepend_cw_re: true, preselect_on_reply: true, inline_preview_cards: true, - hicolor_privacy_icons: false, - show_content_type_choice: false, + hicolor_privacy_icons: true, + show_content_type_choice: true, filtering_behavior: 'hide', tag_misleading_links: true, rewrite_mentions: 'no', @@ -51,7 +51,7 @@ const initialState = ImmutableMap({ reveal_behind_cw : false, }), notifications : ImmutableMap({ - favicon_badge : false, + favicon_badge : true, tab_badge : true, }), }); diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js index 7111bb710..d170c2594 100644 --- a/app/javascript/flavours/glitch/reducers/mutes.js +++ b/app/javascript/flavours/glitch/reducers/mutes.js @@ -3,12 +3,14 @@ import Immutable from 'immutable'; import { MUTES_INIT_MODAL, MUTES_TOGGLE_HIDE_NOTIFICATIONS, + MUTES_TOGGLE_TIMELINES_ONLY, } from 'flavours/glitch/actions/mutes'; const initialState = Immutable.Map({ new: Immutable.Map({ account: null, notifications: true, + timelinesOnly: false, }), }); @@ -18,9 +20,12 @@ export default function mutes(state = initialState, action) { return state.withMutations((state) => { state.setIn(['new', 'account'], action.account); state.setIn(['new', 'notifications'], true); + state.setIn(['new', 'timelinesOnly'], false); }); case MUTES_TOGGLE_HIDE_NOTIFICATIONS: return state.updateIn(['new', 'notifications'], (old) => !old); + case MUTES_TOGGLE_TIMELINES_ONLY: + return state.updateIn(['new', 'timelines_only'], (old) => !old); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index 5db766b96..20822b4cb 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -10,6 +10,7 @@ import { import { STATUS_MUTE_SUCCESS, STATUS_UNMUTE_SUCCESS, + STATUS_PUBLISH_SUCCESS, } from 'flavours/glitch/actions/statuses'; import { TIMELINE_DELETE, @@ -56,6 +57,8 @@ export default function statuses(state = initialState, action) { return state.setIn([action.id, 'muted'], true); case STATUS_UNMUTE_SUCCESS: return state.setIn([action.id, 'muted'], false); + case STATUS_PUBLISH_SUCCESS: + return state.setIn([action.id, 'published'], true); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); default: diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index bb9180d12..3571aea3e 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -141,6 +141,11 @@ export const makeGetStatus = () => { } } + if (statusReblog) { + statusReblog = statusReblog.set('reblogSpoilerPresent', statusBase.get('spoiler_text').length > 0); + statusReblog = statusReblog.set('reblogSpoilerHtml', statusBase.get('spoilerHtml')); + } + return statusBase.withMutations(map => { map.set('reblog', statusReblog); map.set('account', accountBase); diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index d0be730ac..f80045505 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -847,7 +847,7 @@ width: 100%; border: none; padding: 10px; - font-family: 'mastodon-font-monospace', monospace; + font-family: 'roboto-mono', monospace; background: $ui-base-color; color: $primary-text-color; font-size: 14px; diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss index d1c6c33d7..eab6e480c 100644 --- a/app/javascript/flavours/glitch/styles/containers.scss +++ b/app/javascript/flavours/glitch/styles/containers.scss @@ -208,6 +208,18 @@ margin-bottom: 10px; } + @media screen and (max-width: 800px) { + .column-3 { + grid-column: 3 / 5; + grid-row: 3; + } + + .column-4 { + grid-column: 1/3; + grid-row: 3; + } + } + @media screen and (max-width: 738px) { grid-template-columns: minmax(0, 50%) minmax(0, 50%); @@ -656,7 +668,7 @@ box-sizing: border-box; flex: 0 0 auto; color: $darker-text-color; - padding: 10px; + margin: 15px 0px; border-right: 1px solid lighten($ui-base-color, 4%); cursor: default; text-align: center; @@ -707,6 +719,7 @@ .counter-label { font-size: 12px; + font-weight: bold; display: block; } diff --git a/app/javascript/flavours/glitch/styles/index.scss b/app/javascript/flavours/glitch/styles/index.scss index af73feb89..c1ed4a6f1 100644 --- a/app/javascript/flavours/glitch/styles/index.scss +++ b/app/javascript/flavours/glitch/styles/index.scss @@ -1,6 +1,7 @@ @import 'mixins'; @import 'variables'; @import 'styles/fonts/roboto'; +@import 'styles/fonts/opensans'; @import 'styles/fonts/roboto-mono'; @import 'styles/fonts/montserrat'; @@ -23,3 +24,5 @@ @import 'accessibility'; @import 'rtl'; @import 'dashboard'; + +@import 'monsterfork/index'; diff --git a/app/javascript/flavours/glitch/styles/monsterfork/about.scss b/app/javascript/flavours/glitch/styles/monsterfork/about.scss new file mode 100644 index 000000000..4ab9cfa7c --- /dev/null +++ b/app/javascript/flavours/glitch/styles/monsterfork/about.scss @@ -0,0 +1,9 @@ +.box-widget { + .simple_form p.lead { + color: $darker-text-color; + font-size: 15px; + line-height: 20px; + font-weight: bold; + margin-bottom: 25px; + } +} \ No newline at end of file diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/composer.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/composer.scss new file mode 100644 index 000000000..ba347b1cc --- /dev/null +++ b/app/javascript/flavours/glitch/styles/monsterfork/components/composer.scss @@ -0,0 +1,11 @@ +.composer--publisher { + .clear { + background: darken($ui-base-color, 8%); + color: $secondary-text-color; + margin: 0 2px; + padding: 0; + width: 36px; + text-align: center; + float: left; + } +} diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/formatting.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/formatting.scss new file mode 100644 index 000000000..44df7efc9 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/monsterfork/components/formatting.scss @@ -0,0 +1,175 @@ +.status__content__text, +.reply-indicator__content, +.composer--reply > .content, +.account__header__content, +.status__content > .e-content +{ + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + & > ul, + & > ol { + margin-bottom: 20px; + } + + h1, h2, h3, h4, h5 { + margin-top: 20px; + margin-bottom: 20px; + } + + h1, h2 { + font-weight: 700; + font-size: 1.2em; + } + + h2 { + font-size: 1.1em; + } + + h3, h4, h5 { + font-weight: 500; + } + + blockquote { + padding-left: 10px; + border-left: 3px solid $darker-text-color; + color: $darker-text-color; + white-space: normal; + + p:last-child { + margin-bottom: 0; + } + } + + b, strong { + font-weight: 700; + } + + em, i { + font-style: italic; + } + + sub { + font-size: smaller; + text-align: sub; + } + + sup { + font-size: smaller; + vertical-align: super; + } + + ul, ol { + margin-left: 1em; + + p { + margin: 0; + } + } + + ul { + list-style-type: disc; + } + + ol { + list-style-type: decimal; + } + + a { + color: $secondary-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + + .fa { + color: lighten($dark-text-color, 7%); + } + } + + &.mention { + &:hover { + text-decoration: underline; + + span { + text-decoration: none; + } + } + } + + .fa { + color: $dark-text-color; + } + } + + a.unhandled-link { + color: lighten($ui-highlight-color, 8%); + + .link-origin-tag { + color: $gold-star; + font-size: 0.8em; + } + } + + s { text-decoration: line-through; } + del { text-decoration: line-through; } + h6 { font-size: 8px; font-weight: bold; } + hr { border-color: lighten($dark-text-color, 10%); } + pre, code { + color: #6c6; + text-shadow: 0 0 4px #0f0; + + background: linear-gradient( + to bottom, + #121 1px, + #232 1px + ); + background-size: 100% 2px; + } + pre { + & > code { + background: transparent; + } + padding: 10px; + border: 2px solid darken($ui-base-color, 20%); + } + mark { + background-color: #ccff15; + color: black; + } + blockquote { + font-style: italic; + } + .center, .centered, center { + text-align: center; + } + summary { + color: lighten($primary-text-color, 33%); + font-weight: bold; + + &:focus, &:active { + outline: none; + } + } + details > p, details > span { + padding-top: 5px; + padding-left: 10px; + border-left: 3px solid $darker-text-color; + color: $darker-text-color; + white-space: normal; + + p:last-child { + margin-bottom: 0; + }; + } + p[data-name="footer"] { + color: lighten($dark-text-color, 10%); + font-style: italic; + font-size: 12px; + text-align: right; + margin-top: 0px; + } +} \ No newline at end of file diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/index.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/index.scss new file mode 100644 index 000000000..84da74f82 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/monsterfork/components/index.scss @@ -0,0 +1,3 @@ +@import 'composer'; +@import 'status'; +@import 'formatting'; diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss new file mode 100644 index 000000000..1d2f053c0 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss @@ -0,0 +1,243 @@ +.status__notice-wrapper:empty, +.status__footers:empty { + display: none; +} + +.status__notice { + display: flex; + align-items: center; + + & > span, & > a { + display: inline-flex; + align-items: center; + line-height: normal; + font-style: italic; + font-weight: bold; + font-size: 12px; + padding-left: 8px; + height: 1.5em; + } + + & > span { + color: $dark-text-color; + + & > time:before { + content: " "; + white-space: pre; + } + } + + & > i { + display: inline-flex; + align-items: center; + color: lighten($dark-text-color, 4%); + width: 1.1em; + height: 1.5em; + } +} + +.status__footers { + font-size: 12px; + margin-top: 1em; + + & > details { + & > summary { + &:focus, &:active { + outline: none; + } + } + + & > summary > span, + & > ul > li > span, + & > ul > li > a { + color: lighten($dark-text-color, 4%); + padding-left: 8px; + } + } + + .status__tags { + & > ul { + display: flex; + flex-direction: row; + flex-wrap: wrap; + } + + & > ul > li { + list-style: none; + display: inline-block; + width: 50%; + } + + & > summary > i, + & > ul > li > i { + color: #669999; + } + } + + .status__permissions { + & > summary > i { + color: #999966; + } + + & > ul > li { + &.permission-status > i { + color: #99cccc; + } + + &.permission-account > i { + color: #cc99cc; + } + + & > span { + & > span, & > code { + color: lighten($primary-text-color, 30%); + } + + & > span:first-child { + display: inline-block; + text-transform: capitalize; + min-width: 5em; + } + } + } + } +} + +.status, .detailed-status { + &.unpublished { + background: darken($ui-base-color, 4%); + + &:focus { + background: lighten($ui-base-color, 4%); + } + } + + &[data-local-only="true"] { + background: lighten($ui-base-color, 4%); + } +} + +div[data-nest-level] { + border-style: solid; +} + +@for $i from 0 through 15 { + div[data-nest-level="#{$i}"] { + border-left-width: #{$i * 3}px; + border-left-color: darken($ui-base-color, 8%); + } +} + +div[data-nest-deep="true"] { + border-left-width: 75px; + border-left-color: darken($ui-base-color, 8%); +} + +.status__content { + .status__content__text, + .e-content { + img:not(.emojione) { + max-width: 100%; + margin: 1em auto; + } + } + + p:first-child, + pre:first-child, + blockquote:first-child, + div.status__notice-wrapper + p { + margin-top: 0px; + } + + p, pre, blockquote { + margin-top: 1em; + margin-bottom: 0px; + } + + .status__content__spoiler--visible { + margin-top: 1em; + margin-bottom: 1em; + } + + .spoiler { + & > i { + width: 1.1em; + color: lighten($dark-text-color, 4%); + } + + & > span { + padding-left: 8px; + } + } + + .reblog-spoiler { + font-style: italic; + + & > span { + color: lighten($ui-highlight-color, 8%); + } + } +} + +div.media-caption { + background: $ui-base-color; + + strong { + font-weight: bold; + } + + p { + font-size: 12px !important; + padding: 0px 10px; + text-align: center; + } + a { + color: $secondary-text-color; + text-decoration: none; + font-weight: bold; + + &:hover { + text-decoration: underline; + + .fa { + color: lighten($dark-text-color, 7%); + } + } + + &.mention { + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + + .fa { + color: $dark-text-color; + } + } +} + +.status__prepend { + margin-left: 0px; + + .status__prepend-icon-wrapper { + left: 4px; + } + + & > span { + margin-left: 25px; + } +} + +.embed .status__prepend, +.public-layout .status__prepend { + margin: -10px 0px 0px 5px; +} + +.public-layout .status__prepend-icon-wrapper { + left: unset; + right: 4px; +} \ No newline at end of file diff --git a/app/javascript/flavours/glitch/styles/monsterfork/index.scss b/app/javascript/flavours/glitch/styles/monsterfork/index.scss new file mode 100644 index 000000000..9888adfe4 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/monsterfork/index.scss @@ -0,0 +1,2 @@ +@import 'components/index'; +@import 'about'; \ No newline at end of file diff --git a/app/javascript/flavours/glitch/styles/nightshade.scss b/app/javascript/flavours/glitch/styles/nightshade.scss new file mode 100644 index 000000000..bc8069e59 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/nightshade.scss @@ -0,0 +1,3 @@ +@import 'nightshade/variables'; +@import 'index'; +@import 'nightshade/diff'; diff --git a/app/javascript/flavours/glitch/styles/nightshade/diff.scss b/app/javascript/flavours/glitch/styles/nightshade/diff.scss new file mode 100644 index 000000000..de1278114 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/nightshade/diff.scss @@ -0,0 +1,440 @@ +// Notes! +// Sass color functions, "darken" and "lighten" are automatically replaced. + +.glitch.local-settings { + background: darken($ui-base-color, 80%); + + &__navigation { + background: darken($ui-base-color, 30%); + } + + &__navigation__item { + background: darken($ui-base-color, 50%); + + &:hover { + background: $ui-base-color; + color: $primary-text-color; + } + } +} + +.notification__dismiss-overlay { + .wrappy { + box-shadow: unset; + } + + .ckbox { + text-shadow: unset; + } +} + +.status.status-direct:not(.read) { + background: darken($ui-base-color, 8%); + border-bottom-color: darken($ui-base-color, 12%); + + &.collapsed> .status__content:after { + background: linear-gradient(rgba(darken($ui-base-color, 8%), 0), rgba(darken($ui-base-color, 8%), 1)); + } +} + +.focusable:focus.status.status-direct:not(.read) { + background: darken($ui-base-color, 4%); + + &.collapsed> .status__content:after { + background: linear-gradient(rgba(darken($ui-base-color, 4%), 0), rgba(darken($ui-base-color, 4%), 1)); + } +} + +// Change columns' default background colors +.column { + > .scrollable { + background: darken($ui-base-color, 13%); + } +} + +.status.collapsed .status__content:after { + background: linear-gradient(rgba(darken($ui-base-color, 13%), 0), rgba(darken($ui-base-color, 13%), 1)); +} + +.drawer__inner { + background: $ui-base-color; +} + +.drawer__inner__mastodon { + background: $ui-base-color url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color(darken($ui-base-color, 13%))}"/></svg>') no-repeat bottom / 100% auto !important; + + .mastodon { + filter: contrast(75%) brightness(75%) !important; + } +} + +// Change the default appearance of the content warning button +.status__content { + + .status__content__spoiler-link { + + background: darken($ui-base-color, 30%); + + &:hover { + background: lighten($ui-base-color, 35%); + color: $primary-text-color; + text-decoration: none; + } + + } + +} + +// Change the background colors of media and video spoilers +.media-spoiler, +.video-player__spoiler, +.account-gallery__item a { + background: $ui-base-color; +} + +// Change the colors used in the dropdown menu +.dropdown-menu { + background: $ui-base-color; +} + +.dropdown-menu__arrow { + + &.left { + border-left-color: $ui-base-color; + } + + &.top { + border-top-color: $ui-base-color; + } + + &.bottom { + border-bottom-color: $ui-base-color; + } + + &.right { + border-right-color: $ui-base-color; + } + +} + +.dropdown-menu__item { + a { + background: $ui-base-color; + color: $ui-secondary-color; + } +} + +// Change the default color of several parts of the compose form +.composer { + + .composer--spoiler input, .compose-form__autosuggest-wrapper textarea { + color: lighten($ui-base-color, 80%); + + &:disabled { background: lighten($simple-background-color, 10%) } + + &::placeholder { + color: lighten($ui-base-color, 70%); + } + } + + .compose-form__modifiers { + background: darken($ui-base-color, 60%); + + .autosuggest-input input, select { + background: darken($ui-base-color, 70%); + } + } + + .composer--options-wrapper { + background: lighten($ui-base-color, 10%); + } + + .composer--options > hr { + display: none; + } + + .composer--options--dropdown--content--item { + color: $ui-primary-color; + + strong { + color: $ui-primary-color; + } + + } + + header > .account.small { + color: $primary-text-color; + } + + .composer--reply > .content { + color: $primary-text-color; + } +} + +.composer--upload_form--actions .icon-button { + color: lighten($white, 7%); + + &:active, + &:focus, + &:hover { + color: $white; + } +} + +.composer--upload_form--item > div input { + color: lighten($white, 7%); + + &::placeholder { + color: lighten($white, 10%); + } +} + +.dropdown-menu__separator { + border-bottom-color: lighten($ui-base-color, 12%); +} + +.status__content, +.reply-indicator__content { + a { + color: $highlight-text-color; + } +} + +.emoji-mart-bar { + border-color: darken($ui-base-color, 4%); + + &:first-child { + background: lighten($ui-base-color, 10%); + } +} + +.emoji-mart-search input { + background: rgba($ui-base-color, 0.3); + border-color: $ui-base-color; +} + +.autosuggest-textarea__suggestions { + background: darken($ui-base-color, 40%) +} + +.autosuggest-textarea__suggestions__item { + &:hover, + &:focus, + &:active, + &.selected { + background: darken($ui-base-color, 4%); + color: $primary-text-color; + } +} + +.react-toggle-track { + background: $ui-secondary-color; +} + +.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { + background: lighten($ui-secondary-color, 10%); +} + +.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { + background: darken($ui-highlight-color, 10%); +} + +// Change the background colors of modals +.actions-modal, +.boost-modal, +.favourite-modal, +.confirmation-modal, +.mute-modal, +.block-modal, +.report-modal, +.embed-modal, +.error-modal, +.onboarding-modal, +.report-modal__comment .setting-text__wrapper, +.report-modal__comment .setting-text { + background: $primary-text-color; + border: 1px solid lighten($ui-base-color, 8%); +} + +.report-modal__comment { + border-right-color: lighten($ui-base-color, 8%); +} + +.report-modal__container { + border-top-color: lighten($ui-base-color, 8%); +} + +.boost-modal__action-bar, +.confirmation-modal__action-bar, +.mute-modal__action-bar, +.block-modal__action-bar, +.onboarding-modal__paginator, +.error-modal__footer { + background: darken($ui-base-color, 20%); + + .onboarding-modal__nav, + .error-modal__nav { + &:hover, + &:focus, + &:active { + background-color: darken($ui-base-color, 12%); + } + } +} + +// Change the default color used for the text in an empty column or on the error column +.empty-column-indicator, +.error-column { + color: darken($ui-base-color, 60%); +} + +// Change the default colors used on some parts of the profile pages +.activity-stream-tabs { + + background: $account-background-color; + + a { + &.active { + color: $ui-primary-color; + } + } + +} + +.activity-stream { + + .entry { + background: $account-background-color; + } + + .status.light { + + .status__content { + color: $primary-text-color; + } + + .display-name { + strong { + color: $primary-text-color; + } + } + + } + +} + +.accounts-grid { + .account-grid-card { + + .controls { + .icon-button { + color: $ui-secondary-color; + } + } + + .name { + a { + color: $primary-text-color; + } + } + + .username { + color: $ui-secondary-color; + } + + .account__header__content { + color: $primary-text-color; + } + + } +} + +.button.logo-button { + color: $white; + + svg { + fill: $white; + } +} + +.public-layout { + .header, + .public-account-header, + .public-account-bio { + box-shadow: none; + } + + .header { + background: lighten($ui-base-color, 12%); + } + + .public-account-header { + &__image { + background: lighten($ui-base-color, 12%); + + &::after { + box-shadow: none; + } + } + + &__tabs { + &__name { + h1, + h1 small { + color: $white; + } + } + } + } +} + +.account__section-headline a.active::after { + border-color: transparent transparent $white; +} + +.hero-widget, +.box-widget, +.contact-widget, +.landing-page__information.contact-widget, +.moved-account-widget, +.memoriam-widget, +.activity-stream, +.nothing-here, +.directory__tag > a, +.directory__tag > div { + box-shadow: none; +} + +.admin-wrapper { + .sidebar ul .simple-navigation-active-leaf a { + color: $black; + } +} + +.simple_form button, .button { + color: $black; +} + +.poll__input { + border: 1px solid pink; +} + +.poll .button.button-secondary { + background: $primary-text-color; + color: $black; +} + +button.icon-button { + color: $ui-secondary-color; +} + +button.icon-button i.fa-retweet { + background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="22" height="209"><path d="M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z" fill="#{hex-color($ui-secondary-color)}" stroke-width="0"/><path d="M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z" fill="#{hex-color($ui-secondary-color)}" stroke-width="0"/></svg>'); +} + +button.icon-button.active i.fa-retweet { + background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="22" height="209"><path d="M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z" fill="pink" stroke-width="0"/><path d="M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z" fill="pink" stroke-width="0"/></svg>'); + box-shadow: 0px 0px 5px pink, inset 0px 0px 5px pink; + border-radius: 20px; +} + diff --git a/app/javascript/flavours/glitch/styles/nightshade/variables.scss b/app/javascript/flavours/glitch/styles/nightshade/variables.scss new file mode 100644 index 000000000..46f055a8f --- /dev/null +++ b/app/javascript/flavours/glitch/styles/nightshade/variables.scss @@ -0,0 +1,41 @@ +// Dependent colors +$black: #000000; +$white: #ffffff; + +$classic-base-color: #c8b7c1; +$classic-primary-color: #4C3A45; +$classic-secondary-color: #2C2028; +$classic-highlight-color: #bca9b4; + +$ui-base-color: $classic-secondary-color !default; +$ui-base-lighter-color: darken($ui-base-color, 57%); +$ui-highlight-color: $classic-highlight-color !default; +$ui-primary-color: $classic-primary-color !default; +$ui-secondary-color: $classic-base-color !default; + +$primary-text-color: #e9e2e6 !default; +$darker-text-color: $classic-base-color !default; +$dark-text-color: #a68c9c; +$action-button-color: #606984; + +$success-green: #80b38b; +$error-red: #b38080; +$warning-red: #b38c80; + +$base-overlay-background: $black !default; + +$inverted-text-color: #291822 !default; +$lighter-text-color: $classic-base-color !default; +$light-text-color: #6A5160; + +$account-background-color: #4C3A45 !default; + +@function darken($color, $amount) { + @return hsl(hue($color), saturation($color), lightness($color) + $amount); +} + +@function lighten($color, $amount) { + @return hsl(hue($color), saturation($color), lightness($color) - $amount); +} + +//$emojis-requiring-inversion: 'chains'; diff --git a/app/javascript/flavours/glitch/styles/variables.scss b/app/javascript/flavours/glitch/styles/variables.scss index 1ed1a5778..9ddabe6f4 100644 --- a/app/javascript/flavours/glitch/styles/variables.scss +++ b/app/javascript/flavours/glitch/styles/variables.scss @@ -49,11 +49,11 @@ $media-modal-media-max-width: 100%; // put margins on top and bottom of image to avoid the screen covered by image. $media-modal-media-max-height: 80%; -$no-gap-breakpoint: 415px; +$no-gap-breakpoint: 700px; -$font-sans-serif: 'mastodon-font-sans-serif' !default; -$font-display: 'mastodon-font-display' !default; -$font-monospace: 'mastodon-font-monospace' !default; +$font-sans-serif: 'opensans' !default; +$font-display: 'montserrat' !default; +$font-monospace: 'roboto-mono' !default; // Avatar border size (8% default, 100% for rounded avatars) $ui-avatar-border-size: 8%; diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss index 531425573..da136da03 100644 --- a/app/javascript/flavours/glitch/styles/widgets.scss +++ b/app/javascript/flavours/glitch/styles/widgets.scss @@ -556,7 +556,6 @@ $fluid-breakpoint: $maximum-width + 20px; .table-of-contents { background: darken($ui-base-color, 4%); - min-height: 100%; font-size: 14px; border-radius: 4px; diff --git a/app/javascript/fonts/opensans/LICENSE.txt b/app/javascript/fonts/opensans/LICENSE.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/app/javascript/fonts/opensans/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/app/javascript/fonts/opensans/OpenSans-Bold.ttf b/app/javascript/fonts/opensans/OpenSans-Bold.ttf new file mode 100644 index 000000000..efdd5e84a --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-Bold.ttf Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Bold.woff2 b/app/javascript/fonts/opensans/OpenSans-Bold.woff2 new file mode 100644 index 000000000..e98487337 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-Bold.woff2 Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-BoldItalic.ttf b/app/javascript/fonts/opensans/OpenSans-BoldItalic.ttf new file mode 100644 index 000000000..9bf9b4e97 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-BoldItalic.ttf Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-BoldItalic.woff2 b/app/javascript/fonts/opensans/OpenSans-BoldItalic.woff2 new file mode 100644 index 000000000..68666ea6f --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-BoldItalic.woff2 Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-ExtraBold.ttf b/app/javascript/fonts/opensans/OpenSans-ExtraBold.ttf new file mode 100644 index 000000000..67fcf0fb2 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-ExtraBold.ttf Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-ExtraBold.woff2 b/app/javascript/fonts/opensans/OpenSans-ExtraBold.woff2 new file mode 100644 index 000000000..abdc7b7ca --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-ExtraBold.woff2 Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.ttf b/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.ttf new file mode 100644 index 000000000..086722809 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.ttf Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.woff2 b/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.woff2 new file mode 100644 index 000000000..6e8337523 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-ExtraBoldItalic.woff2 Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Italic.ttf b/app/javascript/fonts/opensans/OpenSans-Italic.ttf new file mode 100644 index 000000000..117856707 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-Italic.ttf Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Italic.woff2 b/app/javascript/fonts/opensans/OpenSans-Italic.woff2 new file mode 100644 index 000000000..9398fd5da --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-Italic.woff2 Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Light.ttf b/app/javascript/fonts/opensans/OpenSans-Light.ttf new file mode 100644 index 000000000..6580d3a16 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-Light.ttf Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Light.woff2 b/app/javascript/fonts/opensans/OpenSans-Light.woff2 new file mode 100644 index 000000000..8496eb0f9 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-Light.woff2 Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-LightItalic.ttf b/app/javascript/fonts/opensans/OpenSans-LightItalic.ttf new file mode 100644 index 000000000..1e0c33198 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-LightItalic.ttf Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-LightItalic.woff2 b/app/javascript/fonts/opensans/OpenSans-LightItalic.woff2 new file mode 100644 index 000000000..3ccefa9cb --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-LightItalic.woff2 Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Regular.ttf b/app/javascript/fonts/opensans/OpenSans-Regular.ttf new file mode 100644 index 000000000..29bfd35a2 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-Regular.ttf Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-Regular.woff2 b/app/javascript/fonts/opensans/OpenSans-Regular.woff2 new file mode 100644 index 000000000..a8b531989 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-Regular.woff2 Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-SemiBold.ttf b/app/javascript/fonts/opensans/OpenSans-SemiBold.ttf new file mode 100644 index 000000000..54e7059cf --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-SemiBold.ttf Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-SemiBold.woff2 b/app/javascript/fonts/opensans/OpenSans-SemiBold.woff2 new file mode 100644 index 000000000..90d827308 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-SemiBold.woff2 Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.ttf b/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.ttf new file mode 100644 index 000000000..aebcf1421 --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.ttf Binary files differdiff --git a/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.woff2 b/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.woff2 new file mode 100644 index 000000000..ca7c2011a --- /dev/null +++ b/app/javascript/fonts/opensans/OpenSans-SemiBoldItalic.woff2 Binary files differdiff --git a/app/javascript/locales/locale-data/en-MP.js b/app/javascript/locales/locale-data/en-MP.js new file mode 100644 index 000000000..a2defe09a --- /dev/null +++ b/app/javascript/locales/locale-data/en-MP.js @@ -0,0 +1,8 @@ +/*eslint eqeqeq: "off"*/ +/*eslint no-nested-ternary: "off"*/ +/*eslint quotes: "off"*/ + +export default [{ + locale: 'en-MP', + parentLocale: 'en', +}]; \ No newline at end of file diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index dca44917a..d0a55538f 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -53,9 +53,12 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.poll = status.poll.id; } + const oldUpdatedAt = normalOldStatus ? normalOldStatus.updated_at || normalOldStatus.created_at : null; + const newUpdatedAt = normalStatus ? normalStatus.updated_at || normalStatus.created_at : null; + // Only calculate these values when status first encountered // Otherwise keep the ones already in the reducer - if (normalOldStatus) { + if (normalOldStatus && oldUpdatedAt === newUpdatedAt) { normalStatus.search_index = normalOldStatus.get('search_index'); normalStatus.contentHtml = normalOldStatus.get('contentHtml'); normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index beb5c6a4a..1adc1b815 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -18,6 +18,7 @@ import { } from './announcements'; import { fetchFilters } from './filters'; import { getLocale } from '../locales'; +import { resetCompose } from '../actions/compose'; const { messages } = getLocale(); @@ -96,6 +97,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'announcement.delete': dispatch(deleteAnnouncement(data.payload)); break; + case 'refresh': + dispatch(resetCompose()); + window.location.reload(); + break; } }, }; diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index b7babd4ad..88fde4ee0 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -250,10 +250,8 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); menu.push(null); - if (status.getIn(['account', 'id']) === me || withDismiss) { - menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); - menu.push(null); - } + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + menu.push(null); if (status.getIn(['account', 'id']) === me) { if (publicStatus) { diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 3200f2d82..df05d8515 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import { isRtl } from '../rtl'; import { FormattedMessage } from 'react-intl'; import Permalink from './permalink'; +import RelativeTimestamp from './relative_timestamp'; import classnames from 'classnames'; import PollContainer from 'mastodon/containers/poll_container'; import Icon from 'mastodon/components/icon'; @@ -180,6 +181,20 @@ export default class StatusContent extends React.PureComponent { return null; } + const edited = (status.get('edited') === 0) ? null : ( + <div className='status__edit-notice'> + <FormattedMessage + id='status.edited' + defaultMessage='{count, plural, one {# edit} other {# edits}} · last update: {updated_at}' + key={`edit-${status.get('id')}`} + values={{ + count: status.get('edited'), + updated_at: <RelativeTimestamp timestamp={status.get('updated_at')} />, + }} + /> + </div> + ); + const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const renderReadMore = this.props.onClick && status.get('collapsed'); const renderViewThread = this.props.showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); @@ -232,6 +247,7 @@ export default class StatusContent extends React.PureComponent { <button tabIndex='0' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick}>{toggleText}</button> </p> + {edited} {mentionsPlaceholder} <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> @@ -244,6 +260,8 @@ export default class StatusContent extends React.PureComponent { } else if (this.props.onClick) { const output = [ <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'> + {edited} + <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} /> {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} @@ -260,6 +278,8 @@ export default class StatusContent extends React.PureComponent { } else { return ( <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}> + {edited} + <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} /> {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js index 88894ae59..72ffeff09 100644 --- a/app/javascript/mastodon/features/compose/components/poll_form.js +++ b/app/javascript/mastodon/features/compose/components/poll_form.js @@ -89,7 +89,7 @@ class Option extends React.PureComponent { <AutosuggestInput placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} - maxLength={100} + maxlength={202} value={title} onChange={this.handleOptionTitleChange} suggestions={this.props.suggestions} @@ -157,7 +157,7 @@ class PollForm extends ImmutablePureComponent { </ul> <div className='poll__footer'> - <button disabled={options.size >= 5} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> + <button disabled={options.size >= 33} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> {/* eslint-disable-next-line jsx-a11y/no-onchange */} <select value={expiresIn} onChange={this.handleSelectDuration}> diff --git a/app/javascript/mastodon/locales/en-MP.json b/app/javascript/mastodon/locales/en-MP.json new file mode 100644 index 000000000..ca175119e --- /dev/null +++ b/app/javascript/mastodon/locales/en-MP.json @@ -0,0 +1,176 @@ +{ + "account.add_account_note": "Add note for @{name}", + "account.disclaimer_full": "You're viewing the cached version of a profile from another server.", + "account.followers.empty": "No one follows this creature yet.", + "account.follows.empty": "This creature doesn't follow anyone yet.", + "account.follows": "Follows", + "account.locked_info": "This creature manually reviews who can follow them.", + "account.media": "Media", + "account.mentions": "Mentions", + "account.posts_with_replies": "Replies", + "account.posts": "Blog", + "account.reblogs": "Boosts", + "account.statuses_counter": "{count, plural, one {{counter} Roar} other {{counter} Roars}}", + "account.threads": "Threads", + "account.view_full_profile": "View the original", + "advanced_options.local-only.long": "Do not post to other servers", + "column_header.profile": "Creature", + "column.blocks": "Blocked creatures", + "column.community": "Monsterpit", + "column.directory": "Creature directory", + "column.favourites": "Admirations", + "column.mutes": "Muted creatures", + "column.pins": "Pins", + "column.public": "Fediverse", + "column.toot": "Roars & Growls", + "community.column_settings.local_only": "Monsterpit only", + "community.column_settings.remote_only": "Rowdy tavern mode", + "compose_form.clear": "Double-click to clear", + "compose_form.direct_message_warning": "This roar will only be sent to the mentioned creatures.", + "compose_form.hashtag_warning": "This roar won't be listed under any hashtag as it is unlisted. Only public roars can be searched by hashtag.", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.placeholder": "Roar shamelessly!", + "compose_form.publish": "Roar", + "compose_form.spoiler_placeholder": "Enter content notes here", + "compose_form.spoiler.marked": "Text is hidden behind content notes", + "compose_form.spoiler": "Enter content notes here", + "confirmations.delete.message": "Are you sure you want to delete this roar?", + "confirmations.mute.explanation": "This will hide roars from them and roars mentioning them, but it will still allow them to see your roars and follow you.", + "confirmations.publish.confirm": "Publish", + "confirmations.publish.message": "Are you ready to publish your roar?", + "confirmations.redraft.message": "Are you sure you want to delete and redraft this roar? Admirations and boosts will be lost, and replies to the original roar will be orphaned.", + "content-type.change": "Content type", + "directory.federated": "From Fediverse", + "directory.local": "From Monsterpit", + "embed.instructions": "Embed this roar on your website by copying the code below.", + "empty_column.account_timeline": "No roars here!", + "empty_column.blocks": "You haven't blocked any creatures yet.", + "empty_column.bookmarked_statuses": "You don't have any bookmarked roars yet. When you bookmark one, it will show up here.", + "empty_column.community": "The Monsterpit timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.favourited_statuses": "You don't have any admired roars yet. When you admire one, it will show up here.", + "empty_column.favourites": "No one has admired this roar yet. When someone does, they will show up here.", + "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other creatures.", + "empty_column.list": "There is nothing in this list yet. When members of this list post new roars, they will appear here.", + "empty_column.mutes": "You haven't muted any creatures yet.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow creatures from other servers to fill it up", + "error.unexpected_crash.next_steps": "Try refreshing the page. If that does not help, you may still be able to use Monsterpit through a different browser or native app.", + "follow_request.authorize": "Accept", + "getting_started.directory": "Creature directory", + "getting_started.invite": "Invite creatures", + "getting_started.open_source_notice": "Monsterfork is open source software. If you'd like to explore its code, you may visit the repository on {monsterware}.", + "introduction.federation.federated.headline": "Fediverse", + "introduction.federation.federated.text": "Public roars from other servers will appear in the Fediverse timeline.", + "introduction.federation.home.text": "Roars from creatures you follow will appear in your home feed.", + "introduction.federation.local.headline": "Monsterpit", + "introduction.federation.local.text": "Public roars from people on Monsterpit will appear in the Monsterpit timeline.", + "introduction.interactions.action": "Finish tutorial", + "introduction.interactions.favourite.headline": "Admire", + "introduction.interactions.favourite.text": "You can save a roar for later, and let the author know that you liked it, by admiring it.", + "introduction.interactions.reblog.text": "You can share other creature's roars with your followers by boosting them.", + "introduction.interactions.reply.text": "You can reply to other creature's and your own roars, which will chain them together in a conversation.", + "keyboard_shortcuts.blocked": "to open blocked creatures list", + "keyboard_shortcuts.column": "to focus a roar in one of the columns", + "keyboard_shortcuts.enter": "to open roar", + "keyboard_shortcuts.favourite": "to admire", + "keyboard_shortcuts.favourites": "to open admirations list", + "keyboard_shortcuts.federated": "to open Fediverse timeline", + "keyboard_shortcuts.local": "to open Monsterpit timeline", + "keyboard_shortcuts.muted": "to open muted creatures list", + "keyboard_shortcuts.pinned": "to open pinned roars list", + "keyboard_shortcuts.spoilers": "to show/hide content note field", + "keyboard_shortcuts.toggle_hidden": "to show/hide text behind content notes", + "keyboard_shortcuts.toot": "to start a new roar", + "lists.search": "Search among creatures you follow", + "mute_modal.hide_notifications": "Hide notifications from this creature?", + "navigation_bar.blocks": "Blocked creatures", + "navigation_bar.community_timeline": "Monsterpit", + "navigation_bar.compose": "Compose new roar", + "navigation_bar.favourites": "Admirations", + "navigation_bar.logout": "Sleep", + "navigation_bar.mutes": "Muted creatures", + "navigation_bar.pins": "Pins", + "navigation_bar.public_timeline": "Fediverse", + "notification_purge.start": "Enter notification cleaning mode", + "notification.favourite": "{name} admired your roar", + "notification.follow_request": "{name} wants to follow you", + "notification.follow": "{name} followed you", + "notification.mention": "{name} mentioned you", + "notification.own_poll": "Your poll has ended", + "notification.poll": "A poll you have voted in has ended", + "notification.reblog": "{name} boosted your roar", + "notifications.clear": "Clear notifications", + "notifications.column_settings.favourite": "Admirations:", + "notifications.filter.favourites": "Admirations", + "poll.total_people": "{count, plural, one {# creature} other {# creatures}}", + "privacy.change": "Adjust roar privacy", + "privacy.direct.long": "Visible for mentioned creatures only", + "report.forward_hint": "The creature is from another server. Send an anonymized copy of the report there as well?", + "report.hint": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this creature below:", + "search_popout.tips.full_text": "Simple text returns roars you have written, admired, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", + "search_popout.tips.status": "roar", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "creature", + "search_results.accounts": "Creatures", + "search_results.statuses_fts_disabled": "Searching roars by their content is not enabled on this Mastodon server.", + "search_results.statuses": "Roars", + "settings.always_show_spoilers_field": "Always show content notes field", + "settings.auto_collapse_lengthy": "Lengthy roars", + "settings.auto_collapse_media": "Media", + "settings.collapsed_statuses": "Collapsed roars", + "settings.confirm_boost_missing_media_description": "Show confirmation dialog before boosting roars lacking media descriptions", + "settings.confirm_missing_media_description": "Show confirmation dialog before sending roars lacking media descriptions", + "settings.content_warnings_filter": "Avoid expanding roars with content notes containing:", + "settings.content_warnings": "Content notes", + "settings.enable_collapsed": "Enable collapsed roars", + "settings.enable_content_warnings_auto_unfold": "Auto-expand roars with content notes", + "settings.filtering_behavior.cw": "Add the filtered phrase to the roar's content notes", + "settings.image_backgrounds_media": "Preview collapsed media", + "settings.image_backgrounds_users": "Give collapsed roars an image background", + "settings.prepend_cw_re": "Prepend \"re:\" to content notes when replying", + "settings.rewrite_mentions": "Rewrite mentions in roars:", + "settings.show_action_bar": "Show action buttons in collapsed roars", + "settings.show_content_type_choice": "Show content-type choice when authoring roars", + "settings.side_arm_reply_mode.copy": "Copy privacy setting of the roar being replied to", + "settings.side_arm_reply_mode.keep": "Keep secondary roar button to set privacy", + "settings.side_arm_reply_mode.restrict": "Restrict privacy setting to that of the roar being replied to", + "settings.side_arm_reply_mode": "When replying to a roar:", + "settings.side_arm": "Secondary roar button:", + "status.admin_account": "Moderate @{name}", + "status.admin_status": "Moderate roar", + "status.article": "Article", + "status.cannot_reblog": "This roar cannot be boosted", + "status.copy": "Copy link to roar", + "status.edit": "Edit", + "status.edited": "{count, plural, one {# edit} other {# edits}} · last update: {updated_at}", + "status.favourite": "Admire", + "status.has_pictures": "Features attached pictures", + "status.in_reply_to": "This roar is a reply", + "status.is_poll": "This roar is a poll", + "status.local_only": "Monsterpit-only", + "status.media.description": "Attachment #{index}: ", + "status.media.descriptions": "Attachments {list}: ", + "status.open": "Open this roar", + "status.permissions.title": "Show extended permissions...", + "status.permissions.visibility.account": "{visibility} 🡲 {domain}", + "status.permissions.visibility.status": "{visibility} 🡲 {domain}", + "status.pinned": "Pinned", + "status.publish": "Publish", + "status.reblogged_by": "{name}", + "status.reblogs.empty": "No one has boosted this roar yet. When someone does, they will show up here.", + "status.show_article": "Show article", + "status.show_less_all": "Hide all", + "status.show_less": "Hide", + "status.show_more_all": "Reveal all", + "status.show_more": "Reveal", + "status.show_thread": "Reveal thread", + "status.tags": "Show all tags...", + "status.unpublished": "Unpublished", + "tabs_bar.federated_timeline": "Fediverse", + "tabs_bar.local_timeline": "Monsterpit", + "timeline_hint.resources.statuses": "Older roars", + "trends.counter_by_accounts": "{count, plural, one {{counter} creature} other {{counter} creatures}} talking", + "ui.beforeunload": "Your draft will be lost if you leave the web page.", + "upload_form.edit": "Add description text", + "upload_modal.edit_media": "Add description text", + "video.expand": "Open video" +} diff --git a/app/javascript/mastodon/locales/locale-data/en-MP.js b/app/javascript/mastodon/locales/locale-data/en-MP.js new file mode 100644 index 000000000..a2defe09a --- /dev/null +++ b/app/javascript/mastodon/locales/locale-data/en-MP.js @@ -0,0 +1,8 @@ +/*eslint eqeqeq: "off"*/ +/*eslint no-nested-ternary: "off"*/ +/*eslint quotes: "off"*/ + +export default [{ + locale: 'en-MP', + parentLocale: 'en', +}]; \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_en-MP.json b/app/javascript/mastodon/locales/whitelist_en-MP.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_en-MP.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 4c0ba1c36..67ce96feb 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -205,7 +205,9 @@ const expandMentions = status => { const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement; status.get('mentions').forEach(mention => { - fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`; + const selection = fragment.querySelector(`a[href="${mention.get('url')}"]`); + if (!selection) return; + selection.textContent = `@${mention.get('acct')}`; }); return fragment.innerHTML; diff --git a/app/javascript/skins/glitch/nightshade/common.scss b/app/javascript/skins/glitch/nightshade/common.scss new file mode 100644 index 000000000..ada0fd156 --- /dev/null +++ b/app/javascript/skins/glitch/nightshade/common.scss @@ -0,0 +1 @@ +@import 'flavours/glitch/styles/nightshade'; diff --git a/app/javascript/skins/glitch/nightshade/names.yml b/app/javascript/skins/glitch/nightshade/names.yml new file mode 100644 index 000000000..db7010ec5 --- /dev/null +++ b/app/javascript/skins/glitch/nightshade/names.yml @@ -0,0 +1,5 @@ +en: + skins: + glitch: + nightshade: Nightshade + diff --git a/app/javascript/styles/fonts/montserrat.scss b/app/javascript/styles/fonts/montserrat.scss index 80c2329b0..103dee529 100644 --- a/app/javascript/styles/fonts/montserrat.scss +++ b/app/javascript/styles/fonts/montserrat.scss @@ -1,5 +1,5 @@ @font-face { - font-family: 'mastodon-font-display'; + font-family: 'montserrat'; src: local('Montserrat'), url('~fonts/montserrat/Montserrat-Regular.woff2') format('woff2'), url('~fonts/montserrat/Montserrat-Regular.woff') format('woff'), @@ -9,7 +9,7 @@ } @font-face { - font-family: 'mastodon-font-display'; + font-family: 'montserrat'; src: local('Montserrat Medium'), url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype'); font-weight: 500; diff --git a/app/javascript/styles/fonts/opensans.scss b/app/javascript/styles/fonts/opensans.scss new file mode 100644 index 000000000..6da41e30a --- /dev/null +++ b/app/javascript/styles/fonts/opensans.scss @@ -0,0 +1,134 @@ +@font-face { + font-family: 'opensans'; + src: local('Open Sans ExtraBold'), + url('~fonts/opensans/OpenSans-ExtraBold.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-ExtraBold.ttf') format('truetype'); + font-weight: bolder; + font-style: normal; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Bold'), + url('~fonts/opensans/OpenSans-Bold.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-Bold.ttf') format('truetype'); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Bold Italic'), + url('~fonts/opensans/OpenSans-BoldItalic.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-BoldItalic.ttf') format('truetype'); + font-weight: bold; + font-style: italic; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans SemiBold'), + url('~fonts/opensans/OpenSans-SemiBold.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-SemiBold.ttf') format('truetype'); + font-weight: 500; + font-style: normal; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans SemiBold Italic'), + url('~fonts/opensans/OpenSans-SemiBoldItalic.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-SemiBoldItalic.ttf') format('truetype'); + font-weight: 500; + font-style: italic; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Regular'), + url('~fonts/opensans/OpenSans-Regular.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-Regular.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Italic'), + url('~fonts/opensans/OpenSans-Italic.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-Italic.ttf') format('truetype'); + font-weight: normal; + font-style: italic; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Regular'), + url('~fonts/opensans/OpenSans-Regular.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-Regular.ttf') format('truetype'); + font-weight: lighter; + font-style: normal; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Italic'), + url('~fonts/opensans/OpenSans-Italic.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-Italic.ttf') format('truetype'); + font-weight: lighter; + font-style: italic; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Light'), + url('~fonts/opensans/OpenSans-Light.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-Light.ttf') format('truetype'); + font-weight: 300; + font-style: normal; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Light Italic'), + url('~fonts/opensans/OpenSans-LightItalic.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-LightItalic.ttf') format('truetype'); + font-weight: 300; + font-style: italic; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Light'), + url('~fonts/opensans/OpenSans-Light.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-Light.ttf') format('truetype'); + font-weight: 200; + font-style: normal; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Light Italic'), + url('~fonts/opensans/OpenSans-LightItalic.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-LightItalic.ttf') format('truetype'); + font-weight: 200; + font-style: italic; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Light'), + url('~fonts/opensans/OpenSans-Light.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-Light.ttf') format('truetype'); + font-weight: 100; + font-style: normal; +} + +@font-face { + font-family: 'opensans'; + src: local('Open Sans Light Italic'), + url('~fonts/opensans/OpenSans-LightItalic.woff2') format('woff2'), + url('~fonts/opensans/OpenSans-LightItalic.ttf') format('truetype'); + font-weight: 100; + font-style: italic; +} \ No newline at end of file diff --git a/app/javascript/styles/fonts/roboto-mono.scss b/app/javascript/styles/fonts/roboto-mono.scss index c793aa6ed..b689c87fe 100644 --- a/app/javascript/styles/fonts/roboto-mono.scss +++ b/app/javascript/styles/fonts/roboto-mono.scss @@ -1,5 +1,5 @@ @font-face { - font-family: 'mastodon-font-monospace'; + font-family: 'roboto-mono'; src: local('Roboto Mono'), url('~fonts/roboto-mono/robotomono-regular-webfont.woff2') format('woff2'), url('~fonts/roboto-mono/robotomono-regular-webfont.woff') format('woff'), diff --git a/app/javascript/styles/fonts/roboto.scss b/app/javascript/styles/fonts/roboto.scss index b75fdb927..a34cc693c 100644 --- a/app/javascript/styles/fonts/roboto.scss +++ b/app/javascript/styles/fonts/roboto.scss @@ -1,5 +1,5 @@ @font-face { - font-family: 'mastodon-font-sans-serif'; + font-family: 'roboto'; src: local('Roboto Italic'), url('~fonts/roboto/roboto-italic-webfont.woff2') format('woff2'), url('~fonts/roboto/roboto-italic-webfont.woff') format('woff'), @@ -10,7 +10,7 @@ } @font-face { - font-family: 'mastodon-font-sans-serif'; + font-family: 'roboto'; src: local('Roboto Bold'), url('~fonts/roboto/roboto-bold-webfont.woff2') format('woff2'), url('~fonts/roboto/roboto-bold-webfont.woff') format('woff'), @@ -21,7 +21,7 @@ } @font-face { - font-family: 'mastodon-font-sans-serif'; + font-family: 'roboto'; src: local('Roboto Medium'), url('~fonts/roboto/roboto-medium-webfont.woff2') format('woff2'), url('~fonts/roboto/roboto-medium-webfont.woff') format('woff'), @@ -32,7 +32,7 @@ } @font-face { - font-family: 'mastodon-font-sans-serif'; + font-family: 'roboto'; src: local('Roboto'), url('~fonts/roboto/roboto-regular-webfont.woff2') format('woff2'), url('~fonts/roboto/roboto-regular-webfont.woff') format('woff'), diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss index e25a80c04..3b3ca000d 100644 --- a/app/javascript/styles/mailer.scss +++ b/app/javascript/styles/mailer.scss @@ -1,5 +1,7 @@ @import 'mastodon/variables'; +@import 'fonts/opensans'; @import 'fonts/roboto'; +@import 'fonts/roboto-mono'; table, td, diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss index 8602c3dde..1b2499aa6 100644 --- a/app/javascript/styles/mastodon/variables.scss +++ b/app/javascript/styles/mastodon/variables.scss @@ -51,6 +51,6 @@ $media-modal-media-max-height: 80%; $no-gap-breakpoint: 415px; -$font-sans-serif: 'mastodon-font-sans-serif' !default; -$font-display: 'mastodon-font-display' !default; -$font-monospace: 'mastodon-font-monospace' !default; +$font-sans-serif: 'roboto' !default; +$font-display: 'montserrat' !default; +$font-monospace: 'roboto-mono' !default; diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 94aee7939..337f64d53 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -4,8 +4,8 @@ class ActivityPub::Activity include JsonLdHelper include Redisable - SUPPORTED_TYPES = %w(Note Question).freeze - CONVERTED_TYPES = %w(Image Audio Video Article Page Event).freeze + SUPPORTED_TYPES = %w(Note Question Article).freeze + CONVERTED_TYPES = %w(Image Audio Video Page Event).freeze def initialize(json, account, **options) @json = json @@ -190,7 +190,7 @@ class ActivityPub::Activity end def first_local_follower - @account.followers.local.first + @account.followers.local.random.first end def follow_request_from_object @@ -204,9 +204,9 @@ class ActivityPub::Activity def fetch_remote_original_status if object_uri.start_with?('http') return if ActivityPub::TagManager.instance.local_uri?(object_uri) - ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first) + ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: signed_fetch_account) elsif @object['url'].present? - ::FetchRemoteStatusService.new.call(@object['url']) + ::FetchRemoteStatusService.new.call(@object['url'], nil, signed_fetch_account) end end diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb index 688ab00b3..03b584302 100644 --- a/app/lib/activitypub/activity/add.rb +++ b/app/lib/activitypub/activity/add.rb @@ -9,6 +9,6 @@ class ActivityPub::Activity::Add < ActivityPub::Activity return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status) - StatusPin.create!(account: @account, status: status) + StatusPin.create(account: @account, status: status) end end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 349e8f77e..327def623 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -2,7 +2,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity def perform - return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity? + return reject_payload! if delete_arrived_first?(@json['id']) RedisLock.acquire(lock_options) do |lock| if lock.acquired? @@ -50,7 +50,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity elsif audience_to.include?(@account.followers_url) :private else - :direct + :limited end end @@ -58,18 +58,6 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity status.account_id == @account.id || status.distributable? end - def related_to_local_activity? - followed_by_local_accounts? || requested_through_relay? || reblog_of_local_status? - end - - def requested_through_relay? - super || Relay.find_by(inbox_url: @account.inbox_url)&.enabled? - end - - def reblog_of_local_status? - status_from_uri(object_uri)&.account&.local? - end - def lock_options { redis: Redis.current, key: "announce:#{@object['id']}" } end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 3a9f83978..9d03e5247 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true +# rubocop:disable Metrics/ClassLength class ActivityPub::Activity::Create < ActivityPub::Activity + include ImgProxyHelper + def perform dereference_object! @@ -43,7 +46,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def create_status - return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity? + return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity? || twitter_retweet? RedisLock.acquire(lock_options) do |lock| if lock.acquired? @@ -51,7 +54,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @status = find_existing_status - if @status.nil? + if @status.nil? || @options[:update] process_status elsif @options[:delivered_to_account_id].present? postprocess_audience_and_deliver @@ -72,17 +75,33 @@ class ActivityPub::Activity::Create < ActivityPub::Activity as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) } end + def object_uri + @object['id'] || super + end + def process_status @tags = [] @mentions = [] @params = {} - process_status_params + unless @status.nil? + reblog_uri.blank? ? process_status_update_params : process_reblog_update_params + process_tags + process_audience + + @status = UpdateStatusService.new.call(@status, @params, @mentions, @tags) + resolve_thread(@status) + fetch_replies(@status) + return @status + end + + reblog_uri.blank? ? process_status_params : process_reblog_params process_tags process_audience ApplicationRecord.transaction do @status = Status.create!(@params) + process_inline_images! attach_tags(@status) end @@ -108,7 +127,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity text: text_from_content || '', language: detected_language, spoiler_text: converted_object_type? ? '' : (text_from_summary || ''), + title: text_from_title, + reblog: reblogged_status, created_at: @object['published'], + expires_at: @object['expires'], override_timestamps: @options[:override_timestamps], reply: @object['inReplyTo'].present?, sensitive: @object['sensitive'] || false, @@ -121,6 +143,55 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end end + def process_status_update_params + @params = begin + { + text: text_from_content || '', + language: detected_language, + spoiler_text: converted_object_type? ? '' : (text_from_summary || ''), + title: text_from_title, + sensitive: @object['sensitive'] || false, + visibility: visibility_from_audience, + expires_at: @object['expires'], + media_attachment_ids: process_attachments.take(4).map(&:id), + } + end + end + + def process_reblog_params + @params = begin + { + uri: object_uri, + url: object_url || object_uri, + account: @account, + text: text_from_content || '', + language: detected_language, + spoiler_text: converted_object_type? ? '' : (text_from_summary || ''), + title: text_from_title, + reblog: reblogged_status, + created_at: @object['published'], + override_timestamps: @options[:override_timestamps], + reply: @object['inReplyTo'].present?, + sensitive: @object['sensitive'] || false, + visibility: visibility_from_audience, + thread: replied_to_status, + } + end + end + + def process_reblog_update_params + @params = begin + { + text: text_from_content || '', + language: detected_language, + spoiler_text: converted_object_type? ? '' : (text_from_summary || ''), + title: text_from_title, + sensitive: @object['sensitive'] || false, + visibility: visibility_from_audience, + } + end + end + def process_audience (audience_to + audience_cc).uniq.each do |audience| next if audience == ActivityPub::TagManager::COLLECTIONS[:public] @@ -240,7 +311,21 @@ class ActivityPub::Activity::Create < ActivityPub::Activity begin href = Addressable::URI.parse(attachment['url']).normalize.to_s - media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil) + media_attachment = MediaAttachment.find_by(account: @account, remote_url: href) + + if media_attachment.nil? + media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil) + else + updated_description = attachment['summary'].presence || media_attachment[:description].presence || attachment['name'].presence || media_attachment[:name].presence + updated_focus = attachment['focalPoint'].presence || media_attachment['focalPoint'].presence + updated_blurhash = supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : media_attachment[:blurhash] + + media_attachment.update(description: updated_description, focus: updated_focus, blurhash: updated_blurhash) + + media_attachments << media_attachment + next + end + media_attachments << media_attachment next if unsupported_media_type?(attachment['mediaType']) || skip_download? @@ -330,22 +415,43 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def fetch_replies(status) + FetchReplyWorker.perform_async(@object['root']) unless invalid_root_uri? + collection = @object['replies'] return if collection.nil? - replies = ActivityPub::FetchRepliesService.new.call(status, collection, false) - return unless replies.nil? - - uri = value_or_id(collection) - ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil? + if collection.is_a?(Hash) + ActivityPub::FetchRepliesService.new.call(status, collection) + else + uri = value_or_id(collection) + ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil? + end end def conversation_from_uri(uri) return nil if uri.nil? - return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri) + + conversation = OStatus::TagManager.instance.local_id?(uri) ? Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) : nil begin - Conversation.find_or_create_by!(uri: uri) + conversation = Conversation.find_by(uri: uri) if conversation.blank? + + if @object['inReplyTo'].blank? && replied_to_status.blank? + params = { + uri: uri, + root: object_uri, + account: @account, + }.freeze + if conversation.blank? + conversation = Conversation.create!(params) + elsif conversation.root.blank? + conversation.update!(params) + end + elsif conversation.blank? + conversation = Conversation.create!(uri: uri, account_id: nil) + end + + conversation rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique retry end @@ -377,7 +483,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def replied_to_status return @replied_to_status if defined?(@replied_to_status) - if in_reply_to_uri.blank? + if in_reply_to_uri.blank? || in_reply_to_uri == object_uri @replied_to_status = nil else @replied_to_status = status_from_uri(in_reply_to_uri) @@ -390,13 +496,28 @@ class ActivityPub::Activity::Create < ActivityPub::Activity value_or_id(@object['inReplyTo']) end + def reblogged_status + FetchRemoteStatusService.new.call(reblog_uri) if reblog_uri.present? + end + + def reblog_uri + return @reblog_uri if defined?(@reblog_uri) + + @reblog_uri = @object['reblog'].presence || @object['_misskey_quote'].presence + end + + def twitter_retweet? + text_from_content.present? && (text_from_content.include?('<p>🐦🔗') || text_from_content.include?('<p>RT @')) + end + def text_from_content - return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type? + return @status_text if defined?(@status_text) + return @status_text = Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type? if @object['content'].present? - @object['content'] + @status_text = @object['type'] == 'Article' ? Formatter.instance.format_article(@object['content']) : @object['content'] elsif content_language_map? - @object['contentMap'].values.first + @status_text = @object['contentMap'].values.first end end @@ -408,6 +529,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end end + def text_from_title + if @object['title'].present? + @object['title'] + elsif title_language_map? + @object['titleMap'].values.first + end + end + def text_from_name if @object['name'].present? @object['name'] @@ -444,6 +573,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty? end + def title_language_map? + @object['titleMap'].is_a?(Hash) && !@object['titleMap'].empty? + end + def content_language_map? @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty? end @@ -490,6 +623,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity Account.local.where(username: local_usernames).exists? end + def invalid_root_uri? + @object['root'].blank? || [object_uri, @object['url']].include?(@object['root']) || status_from_uri(@object['root']) + end + def tombstone_exists? Tombstone.exists?(uri: object_uri) end @@ -524,3 +661,4 @@ class ActivityPub::Activity::Create < ActivityPub::Activity { redis: Redis.current, key: "vote:#{replied_to_status.poll_id}:#{@account.id}" } end end +# rubocop:enable Metrics/ClassLength diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index dc9ff580c..1420c6aff 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -51,15 +51,12 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity def replied_to_status return @replied_to_status if defined?(@replied_to_status) - @replied_to_status = @status.thread - end - def reply_to_local? - !replied_to_status.nil? && replied_to_status.account.local? + @replied_to_status = @status.thread end def forward_for_reply - return unless @json['signature'].present? && reply_to_local? + return if @json['signature'].blank? || replied_to_status.blank? inboxes = replied_to_status.account.followers.inboxes - [@account.preferred_inbox_url] diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb index 018e2df54..d1dba5196 100644 --- a/app/lib/activitypub/activity/update.rb +++ b/app/lib/activitypub/activity/update.rb @@ -2,6 +2,7 @@ class ActivityPub::Activity::Update < ActivityPub::Activity SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze + SUPPORTED_OBJECT_TYPES = (ActivityPub::Activity::SUPPORTED_TYPES + ActivityPub::Activity::CONVERTED_TYPES).freeze def perform dereference_object! @@ -10,6 +11,9 @@ class ActivityPub::Activity::Update < ActivityPub::Activity update_account elsif equals_or_includes_any?(@object['type'], %w(Question)) update_poll + elsif equals_or_includes_any?(@object['type'], SUPPORTED_OBJECT_TYPES) + @options[:update] = true + ActivityPub::Activity::Create.new(@json, @account, @options).perform end end diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 4e406b41d..93fd2d910 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -8,6 +8,18 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base CONTEXT_EXTENSION_MAP = { direct_message: { 'litepub': 'http://litepub.social/ns#', 'directMessage': 'litepub:directMessage' }, + edited: { 'mp' => 'https://the.monsterpit.net/ns#', 'edited' => 'mp:edited' }, + require_dereference: { 'mp' => 'https://the.monsterpit.net/ns#', 'requireDereference' => 'mp:requireDereference' }, + show_replies: { 'mp' => 'https://the.monsterpit.net/ns#', 'showReplies' => 'mp:showReplies' }, + show_unlisted: { 'mp' => 'https://the.monsterpit.net/ns#', 'showUnlisted' => 'mp:showUnlisted' }, + private: { 'mp' => 'https://the.monsterpit.net/ns#', 'private' => 'mp:private' }, + require_auth: { 'mp' => 'https://the.monsterpit.net/ns#', 'requireAuth' => 'mp:requireAuth' }, + metadata: { 'mp' => 'https://the.monsterpit.net/ns#', 'metadata' => { '@id' => 'mp:metadata', '@type' => '@id' } }, + server_metadata: { 'mp' => 'https://the.monsterpit.net/ns#', 'serverMetadata' => { '@id' => 'mp:serverMetadata', '@type' => '@id' } }, + root: { 'mp' => 'https://the.monsterpit.net/ns#', 'root' => { '@id' => 'mp:root', '@type' => '@id' } }, + reblog: { 'mp' => 'https://the.monsterpit.net/ns#', 'reblog' => { '@id' => 'mp:reblog', '@type' => '@id' }, + 'misskey' => 'https://misskey.io/ns#', '_misskey_quote' => { '@id' => 'misskey:_misskey_quote', '@type' => '@id' } }, + expires: { 'mp' => 'https://the.monsterpit.net/ns#', 'expires' => 'mp:expires' }, manually_approves_followers: { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers' }, sensitive: { 'sensitive' => 'as:sensitive' }, hashtag: { 'Hashtag' => 'as:Hashtag' }, @@ -15,7 +27,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base also_known_as: { 'alsoKnownAs' => { '@id' => 'as:alsoKnownAs', '@type' => '@id' } }, emoji: { 'toot' => 'http://joinmastodon.org/ns#', 'Emoji' => 'toot:Emoji' }, featured: { 'toot' => 'http://joinmastodon.org/ns#', 'featured' => { '@id' => 'toot:featured', '@type' => '@id' }, 'featuredTags' => { '@id' => 'toot:featuredTags', '@type' => '@id' } }, - property_value: { 'schema' => 'http://schema.org#', 'PropertyValue' => 'schema:PropertyValue', 'value' => 'schema:value' }, + property_value: { 'schema' => 'http://schema.org', 'PropertyValue' => 'schema:PropertyValue', 'value' => 'schema:value' }, atom_uri: { 'ostatus' => 'http://ostatus.org#', 'atomUri' => 'ostatus:atomUri' }, conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' }, focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } }, diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb index 7f716f862..7f31fabda 100644 --- a/app/lib/activitypub/case_transform.rb +++ b/app/lib/activitypub/case_transform.rb @@ -14,8 +14,10 @@ module ActivityPub::CaseTransform when String camel_lower_cache[value] ||= if value.start_with?('_:') '_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower) - else + elsif value != '_misskey_quote' value.underscore.camelize(:lower) + else + value end else value end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 3f98dad2e..c26301f7e 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -60,8 +60,8 @@ class ActivityPub::TagManager # Public statuses go out to primarily the public collection # Unlisted and private statuses go out primarily to the followers collection # Others go out only to the people they mention - def to(status) - case status.visibility + def to(status, target_domain: nil) + case status.visibility_for_domain(target_domain) when 'public' [COLLECTIONS[:public]] when 'unlisted', 'private' @@ -92,19 +92,39 @@ class ActivityPub::TagManager # Unlisted statuses go to the public as well # Both of those and private statuses also go to the people mentioned in them # Direct ones don't have a secondary audience - def cc(status) + def cc(status, target_domain: nil) cc = [] cc << uri_for(status.reblog.account) if status.reblog? - case status.visibility + visibility = status.visibility_for_domain(target_domain) + + case visibility when 'public' cc << account_followers_url(status.account) when 'unlisted' cc << COLLECTIONS[:public] + when 'limited' + if status.account.silenced? + # Only notify followers if the account is locally silenced + account_ids = status.silent_mentions.pluck(:account_id) + cc.concat(status.account.followers.where(id: account_ids).each_with_object([]) do |account, result| + result << uri_for(account) + result << account_followers_url(account) if account.group? + end) + cc.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).each_with_object([]) do |request, result| + result << uri_for(request.account) + result << account_followers_url(request.account) if request.account.group? + end) + else + cc.concat(status.silent_mentions.each_with_object([]) do |mention, result| + result << uri_for(mention.account) + result << account_followers_url(mention.account) if mention.account.group? + end) + end end - unless status.direct_visibility? || status.limited_visibility? + unless %w(direct limited).include?(visibility) if status.account.silenced? # Only notify followers if the account is locally silenced account_ids = status.active_mentions.pluck(:account_id) diff --git a/app/lib/command_tag/command/account_tools.rb b/app/lib/command_tag/command/account_tools.rb new file mode 100644 index 000000000..ac38f19a1 --- /dev/null +++ b/app/lib/command_tag/command/account_tools.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +module CommandTag::Command::AccountTools + def handle_account_at_start(args) + return if args[0].blank? + + case args[0].downcase + when 'set' + handle_account_set(args[1..-1]) + end + end + + alias handle_acct_at_start handle_account_at_start + + private + + def handle_account_set(args) + return if args[0].blank? + + case args[0].downcase + when 'v', 'p', 'visibility', 'privacy', 'default-visibility', 'default-privacy' + args[1] = read_visibility_from(args[1]) + return if args[1].blank? + + if args[2].blank? + @account.user.settings.default_privacy = args[1] + elsif args[1] == 'public' + domains = args[2..-1].map { |domain| normalize_domain(domain) unless domain == '*' }.uniq.compact + @account.domain_permissions.where(domain: domains, sticky: false).destroy_all if domains.present? + elsif args[1] != 'cc' + args[2..-1].flat_map(&:split).uniq.each do |domain| + domain = normalize_domain(domain) unless domain == '*' + @account.domain_permissions.create_or_update(domain: domain, visibility: args[1]) if domain.present? + end + end + end + end +end diff --git a/app/lib/command_tag/command/footer_tools.rb b/app/lib/command_tag/command/footer_tools.rb new file mode 100644 index 000000000..73e2f05bd --- /dev/null +++ b/app/lib/command_tag/command/footer_tools.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +module CommandTag::Command::FooterTools + def handle_999_footertools_startup + @status.footer = var('persist:footer:default')[0] + end + + def handle_footer_before_save(args) + return if args.blank? + + name = normalize(args.shift) + return (@status.footer = nil) if read_falsy_from(name) + + var_name = "persist:footer:#{name}" + return @status.footer = var(var_name)[0] if args.blank? + + if read_falsy_from(normalize(args[0])) + @status.footer = nil if ['default', var(var_name)[0]].include?(name) + @vars.delete(var_name) + return + end + + if name == 'default' + name = normalize(args.shift) + var_name = "persist:footer:#{name}" + @vars[var_name] = [args.join(' ').strip] if args.present? + @vars['persist:footer:default'] = var(var_name) + elsif %w(default DEFAULT).include?(args[0]) + @vars['persist:footer:default'] = var(var_name) + else + @vars[var_name] = [args.join(' ').strip] + end + + @status.footer = var(var_name)[0] + end + + # Monsterfork v1 familiarity. + def handle_i_before_save(args) + return if args.blank? + + handle_footer_before_save(args[1..-1]) if %w(am are).include?(normalize(args[0])) + end + + alias handle_we_before_save handle_i_before_save + alias handle_signature_before_save handle_footer_before_save + alias handle_signed_before_save handle_footer_before_save + alias handle_sign_before_save handle_footer_before_save + alias handle_sig_before_save handle_footer_before_save + alias handle_am_before_save handle_footer_before_save + alias handle_are_before_save handle_footer_before_save +end diff --git a/app/lib/command_tag/command/hello_world.rb b/app/lib/command_tag/command/hello_world.rb new file mode 100644 index 000000000..ab10b495b --- /dev/null +++ b/app/lib/command_tag/command/hello_world.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module CommandTag::Command::HelloWorld + def handle_helloworld_startup + @vars['hello_world'] = ['Hello, world!'] + end + + def handle_hello_world_with_return(_) + 'Hello, world!' + end +end diff --git a/app/lib/command_tag/command/parent_status_tools.rb b/app/lib/command_tag/command/parent_status_tools.rb new file mode 100644 index 000000000..2fdee2fb8 --- /dev/null +++ b/app/lib/command_tag/command/parent_status_tools.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true +module CommandTag::Command::ParentStatusTools + def handle_publish_once_at_end(_) + is_blank = status_text_blank? + return PublishStatusService.new.call(@status) if @parent.blank? || !is_blank + return unless is_blank && author_of_parent? && !@parent.published? + + PublishStatusService.new.call(@parent) + end + + alias handle_publish_post_once_at_end handle_publish_once_at_end + alias handle_publish_roar_once_at_end handle_publish_once_at_end + alias handle_publish_toot_once_at_end handle_publish_once_at_end + + def handle_edit_once_before_save(_) + return unless author_of_parent? + + params = @parent.slice(*UpdateStatusService::ALLOWED_ATTRIBUTES).with_indifferent_access.compact + params[:text] = @text + UpdateStatusService.new.call(@parent, params) + destroy_status! + end + + alias handle_edit_post_once_before_save handle_edit_once_before_save + alias handle_edit_roar_once_before_save handle_edit_once_before_save + alias handle_edit_toot_once_before_save handle_edit_once_before_save + alias handle_edit_parent_once_before_save handle_edit_once_before_save + + def handle_mute_once_at_end(_) + return if author_of_parent? + + MuteStatusService.new.call(@account, @parent) + end + + alias handle_mute_post_once_at_end handle_mute_once_at_end + alias handle_mute_roar_once_at_end handle_mute_once_at_end + alias handle_mute_toot_once_at_end handle_mute_once_at_end + alias handle_mute_parent_once_at_end handle_mute_once_at_end + alias handle_hide_once_at_end handle_mute_once_at_end + alias handle_hide_post_once_at_end handle_mute_once_at_end + alias handle_hide_roar_once_at_end handle_mute_once_at_end + alias handle_hide_toot_once_at_end handle_mute_once_at_end + alias handle_hide_parent_once_at_end handle_mute_once_at_end + + def handle_unmute_once_at_end(_) + return if author_of_parent? + + @account.unmute_status!(@parent) + end + + alias handle_unmute_post_once_at_end handle_unmute_once_at_end + alias handle_unmute_roar_once_at_end handle_unmute_once_at_end + alias handle_unmute_toot_once_at_end handle_unmute_once_at_end + alias handle_unmute_parent_once_at_end handle_unmute_once_at_end + alias handle_unhide_once_at_end handle_unmute_once_at_end + alias handle_unhide_post_once_at_end handle_unmute_once_at_end + alias handle_unhide_roar_once_at_end handle_unmute_once_at_end + alias handle_unhide_toot_once_at_end handle_unmute_once_at_end + alias handle_unhide_parent_once_at_end handle_unmute_once_at_end + + def handle_mute_thread_once_at_end(_) + return if author_of_parent? + + MuteConversationService.new.call(@account, @conversation) + end + + alias handle_mute_conversation_once_at_end handle_mute_thread_once_at_end + alias handle_hide_thread_once_at_end handle_mute_thread_once_at_end + alias handle_hide_conversation_once_at_end handle_mute_thread_once_at_end + + def handle_unmute_thread_once_at_end(_) + return if author_of_parent? || @conversation.blank? + + @account.unmute_conversation!(@conversation) + end + + alias handle_unmute_conversation_once_at_end handle_unmute_thread_once_at_end + alias handle_unhide_thread_once_at_end handle_unmute_thread_once_at_end + alias handle_unhide_conversation_once_at_end handle_unmute_thread_once_at_end +end diff --git a/app/lib/command_tag/command/status_tools.rb b/app/lib/command_tag/command/status_tools.rb new file mode 100644 index 000000000..b2ddca422 --- /dev/null +++ b/app/lib/command_tag/command/status_tools.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true +module CommandTag::Command::StatusTools + def handle_boost_once_at_start(args) + return unless @parent.present? && StatusPolicy.new(@account, @parent).reblog? + + status = ReblogService.new.call( + @account, @parent, + visibility: @status.visibility, + spoiler_text: args.join(' ').presence || @status.spoiler_text + ) + end + + alias handle_reblog_at_start handle_boost_once_at_start + alias handle_rb_at_start handle_boost_once_at_start + alias handle_rt_at_start handle_boost_once_at_start + + def handle_article_before_save(args) + return unless author_of_status? && args.present? + + case args.shift.downcase + when 'title', 'name', 't' + status.title = args.join(' ') + when 'summary', 'abstract', 'cw', 'cn', 's', 'a' + @status.title = @status.spoiler_text if @status.title.blank? + @status.spoiler_text = args.join(' ') + end + end + + def handle_title_before_save(args) + args.unshift('title') + handle_article_before_save(args) + end + + def handle_summary_before_save(args) + args.unshift('summary') + handle_article_before_save(args) + end + + alias handle_abstract_before_save handle_summary_before_save + + def handle_visibility_before_save(args) + return unless author_of_status? && args[0].present? + + args[0] = read_visibility_from(args[0]) + return if args[0].blank? + + if args[1].blank? + @status.visibility = args[0].to_sym + elsif args[0] == @status.visibility.to_s + domains = args[1..-1].map { |domain| normalize_domain(domain) unless domain == '*' }.uniq.compact + @status.domain_permissions.where(domain: domains).destroy_all if domains.present? + elsif args[0] == 'cc' + expect_list = false + args[1..-1].uniq.each do |target| + if expect_list + expect_list = false + address_to_list(target) + elsif %w(list list:).include?(target.downcase) + expect_list = true + else + mention(resolve_mention(target)) + end + end + elsif args[0] == 'community' + @status.visibility = :public + @status.domain_permissions.create_or_update(domain: '*', visibility: :unlisted) + else + args[1..-1].flat_map(&:split).uniq.each do |domain| + domain = normalize_domain(domain) unless domain == '*' + @status.domain_permissions.create_or_update(domain: domain, visibility: args[0]) if domain.present? + end + end + end + + alias handle_v_before_save handle_visibility_before_save + alias handle_p_before_save handle_visibility_before_save + alias handle_privacy_before_save handle_visibility_before_save + + def handle_local_only_before_save(args) + @status.local_only = args.present? ? read_boolean_from(args[0]) : true + @status.originally_local_only = @status.local_only? + end + + def handle_federate_before_save(args) + @status.local_only = args.present? ? !read_boolean_from(args[0]) : false + @status.originally_local_only = @status.local_only? + end + + def handle_notify_before_save(args) + return if args[0].blank? + + @status.notify = read_boolean_from(args[0]) + end + + alias handle_notice_before_save handle_notify_before_save + + def handle_tags_before_save(args) + return if args.blank? + + cmd = args.shift.downcase + args.select! { |tag| tag =~ /\A(#{Tag::HASHTAG_NAME_RE})\z/i } + + case cmd + when 'add', 'a', '+' + ProcessHashtagsService.new.call(@status, args) + when 'del', 'remove', 'rm', 'r', 'd', '-' + RemoveHashtagsService.new.call(@status, args) + end + end + + def handle_tag_before_save(args) + args.unshift('add') + handle_tags_before_save(args) + end + + def handle_untag_before_save(args) + args.unshift('del') + handle_tags_before_save(args) + end + + def handle_delete_before_save(args) + unless args + RemovalWorker.perform_async(@parent.id, immediate: true) if author_of_parent? && status_text_blank? + return + end + + args.flat_map(&:split).uniq.each do |id| + if id.match?(/\A\d+\z/) + object = @account.statuses.find_by(id: id) + elsif id.start_with?('https://') + begin + object = ActivityPub::TagManager.instance.uri_to_resource(id, Status) + if object.blank? && ActivityPub::TagManager.instance.local_uri?(id) + id = Addressable::URI.parse(id)&.normalized_path&.sub(/\A.*\/([^\/]*)\/*/, '\1') + next unless id.present? && id.match?(/\A\d+\z/) + + object = find_status_or_create_stub(id) + end + rescue Addressable::URI::InvalidURIError + next + end + end + + next if object.blank? || object.account_id != @account.id + + RemovalWorker.perform_async(object.id, immediate: true, unpublished: true) + end + end + + alias handle_destroy_before_save handle_delete_before_save + alias handle_redraft_before_save handle_delete_before_save + + def handle_expires_before_save(args) + return if args.blank? + + @status.expires_at = Time.now.utc + to_datetime(args) + end + + alias handle_expires_in_before_save handle_expires_before_save + alias handle_delete_in_before_save handle_expires_before_save + alias handle_unpublish_in_before_save handle_expires_before_save + + def handle_publish_before_save(args) + return if args.blank? + + @status.published = false + @status.publish_at = Time.now.utc + to_datetime(args) + end + + alias handle_publish_in_before_save handle_publish_before_save + + private + + def resolve_mention(mention_text) + return unless (match = mention_text.match(Account::MENTION_RE)) + + username, domain = match[1].split('@') + domain = begin + if TagManager.instance.local_domain?(domain) + nil + else + TagManager.instance.normalize_domain(domain) + end + end + + Account.find_remote(username, domain) + end + + def mention(target_account) + return if target_account.blank? || target_account.mentions.where(status: @status).exists? + + target_account.mentions.create(status: @status, silent: true) + end + + def address_to_list(list_name) + return if list_name.blank? + + list_accounts = ListAccount.joins(:list).where(lists: { account: @account }).where('LOWER(lists.title) = ?', list_name.mb_chars.downcase).includes(:account).map(&:account) + list_accounts.each { |target_account| mention(target_account) } + end + + def find_status_or_create_stub(id) + status_params = { + id: id, + account: @account, + text: '(Deleted)', + local: true, + visibility: :public, + local_only: false, + published: false, + } + Status.where(id: id).first_or_create(status_params) + end + + def to_datetime(args) + total = 0.seconds + args.reject { |arg| arg.blank? || %w(in at , and).include?(arg) }.in_groups_of(2) { |i, unit| total += to_duration(i.to_i, unit) } + total + end + + def to_duration(amount, unit) + case unit + when nil, 's', 'sec', 'secs', 'second', 'seconds' + amount.seconds + when 'm', 'min', 'mins', 'minute', 'minutes' + amount.minutes + when 'h', 'hr', 'hrs', 'hour', 'hours' + amount.hours + when 'd', 'day', 'days' + amount.days + when 'w', 'wk', 'wks', 'week', 'weeks' + amount.weeks + when 'mo', 'mos', 'mn', 'mns', 'month', 'months' + amount.months + when 'y', 'yr', 'yrs', 'year', 'years' + amount.years + end + end +end diff --git a/app/lib/command_tag/command/text_tools.rb b/app/lib/command_tag/command/text_tools.rb new file mode 100644 index 000000000..2c44167b4 --- /dev/null +++ b/app/lib/command_tag/command/text_tools.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module CommandTag::Command::TextTools + def handle_code_at_start(args) + return if args.count < 2 + + name = normalize(args[0]) + value = args.last.presence || '' + @vars[name] = case @status.content_type + when 'text/markdown' + ["```\n#{value}\n```"] + when 'text/html' + ["<pre><code>#{html_encode(value).gsub("\n", '<br/>')}</code></pre>"] + else + ["----------\n#{value}\n----------"] + end + end + + def handle_code_with_return(args) + return if args.count > 1 + + value = args.last.presence || '' + case @status.content_type + when 'text/markdown' + ["```\n#{value}\n```"] + when 'text/html' + ["<pre><code>#{html_encode(value).gsub("\n", '<br/>')}</code></pre>"] + else + ["----------\n#{value}\n----------"] + end + end + + def handle_prepend_before_save(args) + args.each { |arg| @text = "#{arg}\n#{text}" } + end + + def handle_append_before_save(args) + args.each { |arg| @text << "\n#{arg}" } + end + + def handle_replace_before_save(args) + @text.gsub!(args[0], args[1] || '') + end + + alias handle_sub_before_save handle_replace_before_save + + def handle_regex_replace_before_save(args) + flags = normalize(args[2]) + re_opts = (flags.include?('i') ? Regexp::IGNORECASE : 0) + re_opts |= (flags.include?('x') ? Regexp::EXTENDED : 0) + re_opts |= (flags.include?('m') ? Regexp::MULTILINE : 0) + + @text.gsub!(Regexp.new(args[0], re_opts), args[1] || '') + end + + alias handle_resub_before_save handle_replace_before_save + alias handle_regex_sub_before_save handle_replace_before_save +end diff --git a/app/lib/command_tag/command/variables.rb b/app/lib/command_tag/command/variables.rb new file mode 100644 index 000000000..6ba32ea41 --- /dev/null +++ b/app/lib/command_tag/command/variables.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module CommandTag::Command::Variables + def handle_000_variables_startup + @vars.merge!(persistent_vars_from(@account.metadata.fields)) if @account.metadata.present? + end + + def handle_999_variables_shutdown + @account.metadata.update!(fields: nonpersistent_vars_from(@account.metadata.fields).merge(persistent_vars_from(@vars))) + end + + def handle_set_at_start(args) + return if args.blank? + + args[0] = normalize(args[0]) + + case args.count + when 1 + @vars.delete(args[0]) + else + @vars[args[0]] = args[1..-1] + end + end + + def do_unset_at_start(args) + args.each do |arg| + @vars.delete(normalize(arg)) + end + end + + private + + def persistent_vars_from(vars) + vars.select { |key, value| key.start_with?('persist:') && value.present? && value.is_a?(Array) } + end + + def nonpersistent_vars_from(vars) + vars.reject { |key, value| key.start_with?('persist:') || value.blank? } + end +end diff --git a/app/lib/command_tag/commands.rb b/app/lib/command_tag/commands.rb new file mode 100644 index 000000000..f27486427 --- /dev/null +++ b/app/lib/command_tag/commands.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +Dir[File.join(__dir__, 'command', '*.rb')].sort.each { |file| require file } + +module CommandTag::Commands + def self.included(base) + CommandTag::Command.constants.map(&CommandTag::Command.method(:const_get)).grep(Module) do |mod| + base.include(mod) + end + end +end diff --git a/app/lib/command_tag/processor.rb b/app/lib/command_tag/processor.rb new file mode 100644 index 000000000..8461e902f --- /dev/null +++ b/app/lib/command_tag/processor.rb @@ -0,0 +1,335 @@ +# frozen_string_literal: true + +# .~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~. # +################### Cthulhu Code! ################### +# `~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~` # +# - Interprets and executes user input. THIS CAN BE VERY DANGEROUS! # +# - Has a high complexity level and needs tests. # +# - May destroy objects passed to it. # +# - Incurs a high performance penalty. # +# # +############################################################################### + +require_relative 'commands' + +class CommandTag::Break < Mastodon::Error + def initialize(msg = 'A handler stopped execution.') + super + end +end + +class CommandTag::Processor + include Redisable + include ImgProxyHelper + include CommandTag::Commands + + MENTIONS_OR_HASHTAGS_RE = /(?:(?:#{Account::MENTION_RE}|#{Tag::HASHTAG_RE})\s*)+/.freeze + PARSEABLE_RE = /^\s*(?:#{MENTIONS_OR_HASHTAGS_RE})?#!|%%.+?%%/.freeze + STATEMENT_RE = /^\s*#!\s*[^\n]+ (?:start|begin|do)$.*?\n\s*#!\s*(?:end|stop|done)\s*$|^\s*#!\s*.*?\s*$/im.freeze + STATEMENT_PARSE_RE = /'([^']*)'|"([^"]*)"|(\S+)|\s+(?:start|begin|do)\s*$\n+(.*)\n\s*#!\s*(?:end|stop|done)\s*\z/im.freeze + TEMPLATE_RE = /%%\s*(\S+.*?)\s*%%/.freeze + ESCAPE_MAP = { + '\n' => "\n", + '\r' => "\r", + '\t' => "\t", + '\\\\' => '\\', + '\%' => '%', + }.freeze + + def initialize(account, status) + @account = account + @status = status + @parent = status.thread + @conversation = status.conversation + @text = status.text + @run_once = Set[] + @vars = { 'statement_uuid' => [nil] } + @statements = {} + + return unless @account.present? && @account.local? && @status.present? + end + + def process! + reset_status_caches + all_handlers!(:startup) + + unless @text.match?(PARSEABLE_RE) + process_inline_images! + @status.save! + return + end + + @text = parse_statements_from!(@text, @statements) + + execute_statements(:at_start) + execute_statements(:with_return, true) + @text = replace_templates(@text) + execute_statements(:before_save) + + if status_text_blank? + execute_statements(:when_blank) + + unless (@status.published? && !@status.edited.zero?) || @text.present? + execute_statements(:before_destroy) + @status.update(published: false) + @status.destroy + execute_statements(:after_destroy) + end + elsif @status.destroyed? + execute_statements(:after_destroy) + else + @status.text = @text + process_inline_images! + if @status.save + execute_statements(:after_save) + else + execute_statements(:after_save_fail) + end + end + + execute_statements(:at_end) + all_handlers!(:shutdown) + rescue CommandTag::Break + nil + rescue StandardError + @status.update(published: false) + @status.destroy + raise + ensure + reset_status_caches + end + + private + + def all_handlers!(affix) + self.class.instance_methods.grep(/\Ahandle_\w+_#{affix}\z/).sort.each do |name| + public_send(name) + end + end + + # Moves command tags placed after hashtags and mentions to their own line. + def prepare_input(text) + text.gsub(/\r\n|\n\r|\r/, "\n").gsub(/^\s*(#{MENTIONS_OR_HASHTAGS_RE})#!/, "\\1\n#!") + end + + # Translates %%...%% templates. + def replace_templates(text) + text.gsub(TEMPLATE_RE) do + template = unescape_literals(Regexp.last_match(1)) + next if template.blank? + next template[1..-2] if template.match?(/\A'.*'\z/) + + template = template.match?(/\A".*"\z/) ? template[1..-2] : "\#{#{template}}" + template.gsub(/#\{\s*(.*?)\s*\}/) do + next if Regexp.last_match(1).blank? + + parts = Regexp.last_match(1).scan(/'([^']*)'|"([^"]*)"|(\S+)/).flatten.compact + name = normalize(parts[0]) + separator = "\n" + + if parts.count > 2 + if %w(: by: with: using: sep: separator: delim: delimiter:).include?(parts[-2].downcase) + separator = parts[-1] + parts = parts[0..-3] + elsif !parts[-1].match?(/\A[-+]?[0-9]+\z/) + separator = parts[-1] + parts.pop + end + end + + index_start = to_integer(parts[1]) + index_end = to_integer(parts[2]) + + if ['all', '[]'].include?(parts[1]) + var(name).join(separator) + elsif index_end.zero? + var(name)[index_start].presence || '' + else + var(name)[index_start..index_end].presence || '' + end + end + end.rstrip + end + + # Parses statements from text and merges them into statement queues. + # Mutates statement queues hash! + def parse_statements_from!(text, statement_queues) + @run_once.clear + + text = prepare_input(text) + text.gsub!(STATEMENT_RE) do + statement = unescape_literals(Regexp.last_match(0).strip[2..-1]) + next if statement.blank? + + statement_array = statement.scan(STATEMENT_PARSE_RE).flatten.compact.map { |arg| arg.gsub('\#!', '#!') } + statement_array[0] = statement_array[0].strip.tr(':.\- ', '_').gsub(/__+/, '_').downcase + next unless statement_array[0].match?(/\A[\w_]+\z/) + + statement_array[-1].rstrip! if statement_array.count > 1 + add_statement_handlers_for!(statement_array, statement_queues) + end + + @run_once.clear + text + end + + # Yields all possible handler names for a command. + def potential_handlers_for(name) + ['_once', ''].each_with_index do |count_affix, index| + %w(at_start with_return when_blank at_end).each do |when_affix| + yield ["#{count_affix}_#{when_affix}", "handle_#{name}#{count_affix}_#{when_affix}", index.zero?] + end + + %w(destroy save postprocess save_fail).each do |event_affix| + %w(before after).each do |when_affix| + yield ["#{count_affix}_#{when_affix}_#{event_affix}", "handle_#{name}#{count_affix}_#{when_affix}_#{event_affix}", index.zero?] + end + end + end + end + + # Expands a statement to a handler method call, arguments, and template UUID for each handler affix. + # Mutates statement queues hash! + def add_statement_handlers_for!(statement_array, statement_queues = {}) + statement_uuid = SecureRandom.uuid + + potential_handlers_for(statement_array[0]) do |when_affix, handler, once| + if !(once && @run_once.include?(handler)) && respond_to?(handler) + statement_queues[when_affix] ||= [] + statement_queues[when_affix] << [handler, statement_array[1..-1], statement_uuid] + @run_once << handler if once + end + end + + # Template for statement return value. + "%% statement:#{statement_uuid} all %%" + end + + # Calls all handlers for a queue of statements in order. + def execute_statements(event, with_return = false, statements: nil) + statements = @statements if statements.blank? + + ["_#{event}", "_once_#{event}"].each do |when_affix| + next if statements[when_affix].blank? + + statements[when_affix].each do |handler, arguments, uuid| + @vars['statement_uuid'][0] = uuid + if with_return + @vars["statement:#{uuid}"] = [public_send(handler, arguments)] + else + public_send(handler, arguments) + end + end + end + end + + # Expire cached statuses after potentially updating them. + def reset_status_caches(statuses = nil) + statuses = [@status, @parent] if statuses.blank? + statuses.each do |status| + next unless @account.id == status&.account_id + + Rails.cache.delete_matched("statuses/#{status.id}-*") + Rails.cache.delete("statuses/#{status.id}") + Rails.cache.delete(status) + Rails.cache.delete_matched("format:#{status.id}:*") + redis.zremrangebyscore("spam_check:#{status.account.id}", status.id, status.id) + end + end + + def author_of_status? + @account.id == @status.account_id + end + + def author_of_parent? + @account.id == @parent&.account_id + end + + def status_text_blank? + @text.blank? || @text.gsub(MENTIONS_OR_HASHTAGS_RE, '').strip.blank? + end + + def destroy_status! + return if @status.destroyed? + + @status.update(published: false) + @status.destroy + end + + def replace_status!(new_status) + return if new_status.blank? + + destroy_status! + @status = new_status + end + + def normalize(text) + text.to_s.strip.downcase + end + + def to_integer(text) + text&.strip.to_i + end + + def unescape_literals(text) + ESCAPE_MAP.each { |escaped, unescaped| text.gsub!(escaped, unescaped) } + text + end + + def html_encode(text) + (@html_entities ||= HTMLEntities.new).encode(text) + end + + def var(name) + @vars[name].presence || [] + end + + def read_visibility_from(arg) + return if arg.strip.blank? + + arg = case arg.strip + when 'p', 'pu', 'all', 'world' + 'public' + when 'u', 'ul' + 'unlisted' + when 'f', 'follower', 'followers', 'packmates', 'follower-only', 'followers-only', 'packmates-only' + 'private' + when 'd', 'dm', 'pm', 'directmessage' + 'direct' + when 'default', 'reset' + @account.user.setting_default_privacy + when 'to', 'allow', 'allow-from', 'from' + 'cc' + when 'm', 'l', 'mp', 'monsterpit', 'local' + 'community' + else + arg.strip + end + + %w(public unlisted private limited direct cc community).include?(arg) ? arg : nil + end + + def read_falsy_from(arg) + %w(f n false no off disable).include?(arg) + end + + def read_truthy_from(arg) + %w(t y true yes on enable).include?(arg) + end + + def read_boolean_from(arg) + arg.present? && (read_truthy_from(arg) || !read_falsy_from(arg)) + end + + def normalize_domain(domain) + return if domain&.strip.blank? || !domain.include?('.') + + domain.split('.').map(&:strip).reject(&:blank?).join('.').downcase + end + + def federating_with_domain?(domain) + return false if domain.blank? + + DomainAllow.where(domain: domain).exists? || Account.where(domain: domain, suspended_at: nil).exists? + end +end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 915f3fa58..4659f6c4d 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -2,14 +2,15 @@ require 'singleton' +# rubocop:disable Metrics/ClassLength class FeedManager include Singleton include Redisable - MAX_ITEMS = 400 + MAX_ITEMS = 1000 # Must be <= MAX_ITEMS or the tracking sets will grow forever - REBLOG_FALLOFF = 40 + REBLOG_FALLOFF = 50 def with_active_accounts(&block) Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each(&block) @@ -22,8 +23,8 @@ class FeedManager end def filter?(timeline_type, status, receiver_id) - if timeline_type == :home - filter_from_home?(status, receiver_id, build_crutches(receiver_id, [status])) + if [:home, :list].include?(timeline_type) + filter_from_home?(status, receiver_id, build_crutches(receiver_id, [status]), filter_options_for(receiver_id)) elsif timeline_type == :mentions filter_from_mentions?(status, receiver_id) elsif timeline_type == :direct @@ -49,8 +50,11 @@ class FeedManager end def push_to_list(list, status) + return false if status.reblog? + if status.reply? && status.in_reply_to_account_id != status.account_id should_filter = status.in_reply_to_account_id != list.account_id + should_filter &&= status.account_id == list.account_id should_filter &&= !list.show_all_replies? should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) return false if should_filter @@ -72,6 +76,7 @@ class FeedManager def push_to_direct(account, status) return false unless add_to_feed(:direct, account.id, status) + trim(:direct, account.id) PushUpdateWorker.perform_async(account.id, status.id, "timeline:direct:#{account.id}") true @@ -79,9 +84,29 @@ class FeedManager def unpush_from_direct(account, status) return false unless remove_from_feed(:direct, account.id, status) + redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) end + def unpush_status(account, status) + return if account.blank? || status.blank? + + unpush_from_home(account, status) + unpush_from_direct(account, status) if status.direct_visibility? + + account.lists_for_local_distribution.select(:id, :account_id).each do |list| + unpush_from_list(list, status) + end + end + + def unpush_conversation(account, conversation) + return if account.blank? || conversation.blank? + + conversation.statuses.reorder(nil).find_each do |status| + unpush_status(account, status) + end + end + def trim(type, account_id) timeline_key = key(type, account_id) reblog_key = key(type, account_id, 'reblogs') @@ -119,9 +144,10 @@ class FeedManager statuses = query.to_a crutches = build_crutches(into_account.id, statuses) + filter_options = filter_options_for(into_account.id) statuses.each do |status| - next if filter_from_home?(status, into_account.id, crutches) + next if filter_from_home?(status, into_account.id, crutches, filter_options) add_to_feed(:home, into_account.id, status, aggregate) end @@ -174,11 +200,12 @@ class FeedManager next if last_status_score < oldest_home_score end - statuses = target_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(limit) + statuses = target_account.statuses.published.without_replies.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(limit) crutches = build_crutches(account.id, statuses) + filter_options = filter_options_for(account.id) statuses.each do |status| - next if filter_from_home?(status, account.id, crutches) + next if filter_from_home?(status, account.id, crutches, filter_options) add_to_feed(:home, account.id, status, aggregate) end @@ -199,6 +226,7 @@ class FeedManager statuses.each do |status| next if filter_from_direct?(status, account) + added += 1 if add_to_feed(:direct, account.id, status) end @@ -219,36 +247,73 @@ class FeedManager (context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?) end - def filter_from_home?(status, receiver_id, crutches) + def filter_from_home?(status, receiver_id, crutches, filter_options) + conversation = status.conversation + reblog_conversation = status.reblog&.conversation + return false if receiver_id == status.account_id + return true unless status.published? + return true if crutches[:hiding_thread][status.conversation_id] if conversation.present? return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) return true if phrase_filtered?(status, receiver_id, :home) check_for_blocks = crutches[:active_mentions][status.id] || [] - check_for_blocks.concat([status.account_id]) + check_for_blocks.concat([status.account_id, conversation&.account_id]) + check_for_blocks.concat([status.in_reply_to_account_id]) if status.reply? if status.reblog? - check_for_blocks.concat([status.reblog.account_id]) + check_for_blocks.concat([status.reblog.account_id, reblog_conversation&.account_id]) check_for_blocks.concat(crutches[:active_mentions][status.reblog_of_id] || []) + check_for_blocks.concat([status.reblog.in_reply_to_account_id]) if status.reblog.reply? end + check_for_blocks.uniq! + check_for_blocks.compact! return true if check_for_blocks.any? { |target_account_id| crutches[:blocking][target_account_id] || crutches[:muting][target_account_id] } - if status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply - should_filter = !crutches[:following][status.in_reply_to_account_id] # and I'm not following the person it's a reply to - should_filter &&= receiver_id != status.in_reply_to_account_id # and it's not a reply to me - should_filter &&= status.account_id != status.in_reply_to_account_id # and it's not a self-reply + # Filter if... + if status.reply? # ...it's a reply and... + # ...you're not following the author... + should_filter = !crutches[:following][status.in_reply_to_account_id] + # (optional) ...or the owner(s) of the thread... + should_filter ||= !crutches[:following][conversation.account_id] if filter_options[:to_unknown] && conversation&.account_id.present? + # ...and the author isn't replying to a post you wrote... + should_filter &&= receiver_id != status.in_reply_to_account_id + # ...and the author isn't mentioning you. + should_filter &&= !crutches[:active_mentions][receiver_id] return !!should_filter - elsif status.reblog? # Filter out a reblog - should_filter = crutches[:hiding_reblogs][status.account_id] # if the reblogger's reblogs are suppressed - should_filter ||= crutches[:blocked_by][status.reblog.account_id] # or if the author of the reblogged status is blocking me - should_filter ||= crutches[:domain_blocking][status.reblog.account.domain] # or the author's domain is blocked + elsif status.reblog? # ...it's a boost and... + should_filter = false + + # ...it's a reply... + if status.reblog.reply? && !status.reblog.in_reply_to_account_id.nil? + # ...and you don't follow the author if: + # - you're filtering replies to parent authors you don't follow + # - they're silenced on this server + should_filter ||= !crutches[:following][status.reblog.in_reply_to_account_id] if filter_options[:to_unknown] || status.reblog.in_reply_to_account.silenced? + # - you're filtering replies to threads whose owners you don't follow + should_filter ||= !crutches[:following][reblog_conversation.account_id] if filter_options[:to_unknown] && reblog_conversation&.account_id.present? + # ...or you're blocking their domain... + should_filter ||= crutches[:domain_blocking][status.reblog.thread.account.domain] if status.reblog.thread.present? + end + + # ...or it's a post from a thread's trunk and you don't follow the author if: + # - you're filtering boosts of authors you don't follow + # - they're silenced on this server + should_filter ||= !crutches[:following][status.reblog.account_id] if filter_options[:from_unknown] || status.reblog.account.silenced? + + # ..or you're hiding boosts from them... + should_filter ||= crutches[:hiding_reblogs][status.account_id] + # ...or they're blocking you... + should_filter ||= crutches[:blocked_by][status.reblog.account_id] + # ...or you're blocking their domain... + should_filter ||= crutches[:domain_blocking][status.reblog.account.domain] return !!should_filter end - false + crutches[:following][status.account_id] end def filter_from_mentions?(status, receiver_id) @@ -261,14 +326,21 @@ class FeedManager check_for_blocks = status.active_mentions.pluck(:account_id) check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil? - should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted) - should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them + should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) + should_filter ||= (status.account.silenced? && !relationship_exists?(receiver_id, status.account_id)) should_filter end + def relationship_exists?(account_id, target_account_id) + Follow.where(account_id: account_id, target_account_id: target_account_id) + .or(Follow.where(account_id: target_account_id, target_account_id: account_id)) + .exists? + end + def filter_from_direct?(status, receiver_id) return false if receiver_id == status.account_id + filter_from_mentions?(status, receiver_id) end @@ -388,6 +460,17 @@ class FeedManager redis.zrem(timeline_key, status.id) end + def filter_options_for(receiver_id) + Rails.cache.fetch("filter_settings:#{receiver_id}", expires_in: 1.month) do + return {} if (settings = User.find_by(account_id: receiver_id)&.settings).blank? + + { + to_unknown: settings.filter_to_unknown, + from_unknown: settings.filter_from_unknown, + } + end + end + def build_crutches(receiver_id, statuses) crutches = {} @@ -411,7 +494,9 @@ class FeedManager crutches[:muting] = Mute.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true } crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.map { |s| s.reblog&.account&.domain }.compact).pluck(:domain).each_with_object({}) { |domain, mapping| mapping[domain] = true } crutches[:blocked_by] = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| s.reblog&.account_id }.compact).pluck(:account_id).each_with_object({}) { |id, mapping| mapping[id] = true } + crutches[:hiding_thread] = ConversationMute.where(account_id: receiver_id, conversation_id: statuses.map(&:conversation_id).compact, hidden: true).pluck(:conversation_id).each_with_object({}) { |id, mapping| mapping[id] = true } crutches end end +# rubocop:enable Metrics/ClassLength diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index c0f7866bf..159e3ec54 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -24,6 +24,7 @@ class HTMLRenderer < Redcarpet::Render::HTML end end +# rubocop:disable Metrics/ClassLength class Formatter include Singleton include RoutingHelper @@ -31,50 +32,95 @@ class Formatter include ActionView::Helpers::TextHelper def format(status, **options) - if status.reblog? - prepend_reblog = status.reblog.account.acct - status = status.proper - else - prepend_reblog = false + Rails.cache.fetch(formatter_cache_key(status, options), expires_in: 1.hour) do + uncached_format(status, options) end + end - raw_content = status.text + def uncached_format(status, options) + summary = nil + raw_content = status.proper.text + summary_mode = false + + if status.title.present? + summary = status.spoiler_text.presence || status.text + summary_mode = !options[:article_content] + raw_content = summary_mode ? summary : status.text + end if options[:inline_poll_options] && status.preloadable_poll raw_content = raw_content + "\n\n" + status.preloadable_poll.options.map { |title| "[ ] #{title}" }.join("\n") end return '' if raw_content.blank? + return format_remote_content(raw_content, status.emojis, summary: summary, **options) unless status.local? - unless status.local? - html = reformat(raw_content) - html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify] - return html.html_safe # rubocop:disable Rails/OutputSafety + if status.reblog? + html = "🔁 @#{status.reblog.account.acct}\n🔗 #{ActivityPub::TagManager.instance.url_for(status.reblog)}" + html += "\nℹ️ #{status.reblog.spoiler_text}" if status.reblog.spoiler_text.present? + else + html = raw_content end - linkable_accounts = status.active_mentions.map(&:account) + html = "📄 #{html}" if summary_mode + return html if options[:plaintext] + + linkable_accounts = status.mentions.map(&:account) linkable_accounts << status.account - html = raw_content - html = "RT @#{prepend_reblog} #{html}" if prepend_reblog - html = format_markdown(html) if status.content_type == 'text/markdown' - html = encode_and_link_urls(html, linkable_accounts, keep_html: %w(text/markdown text/html).include?(status.content_type)) - html = reformat(html, true) if %w(text/markdown text/html).include?(status.content_type) + keep_html = !summary_mode && %w(text/markdown text/html).include?(status.content_type) + + html = format_markdown(html) if !summary_mode && status.content_type == 'text/markdown' + html = encode_and_link_urls(html, linkable_accounts, keep_html: keep_html) + html = reformat(html, true) if keep_html html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify] - unless %w(text/markdown text/html).include?(status.content_type) + unless keep_html html = simple_format(html, {}, sanitize: false) - html = html.delete("\n") + html.delete!("\n") end + html = summary_mode ? format_article_summary(html, status) : format_article_content(summary, html) if summary.present? + html = format_footer(html, status.footer, linkable_accounts, status.emojis, **options) if status.footer.present? + html.html_safe # rubocop:disable Rails/OutputSafety + end + + def format_remote_content(html, emojis, **options) + html = reformat(html, options[:outgoing]) + html = encode_custom_emojis(html, emojis, options[:autoplay]) if options[:custom_emojify] + html = format_article_content(options[:summary], html) if options[:article_content] && options[:summary].present? html.html_safe # rubocop:disable Rails/OutputSafety end + def format_footer(html, footer, linkable_accounts, emojis, **options) + footer = encode_and_link_urls(footer, linkable_accounts) + footer = encode_custom_emojis(footer, emojis, options[:autoplay]) if options[:custom_emojify] + footer = "<span class=\"invisible\">– </span>#{footer}" + footer = simple_format(footer, { 'data-name': 'footer' }, sanitize: false) + footer.delete!("\n") + + "#{html}#{footer}" + end + def format_markdown(html) html = markdown_formatter.render(html) html.delete("\r").delete("\n") end + def format_article(text) + text = text.gsub(/>[\r\n]+</, '><') + text.html_safe # rubocop:disable Rails/OutputSafety + end + + def format_article_summary(html, status) + status_url = ActivityPub::TagManager.instance.url_for(status) + "#{html}\n<p data-name=\"permalink\">#{link_url(status_url)}</p>" + end + + def format_article_content(summary, html) + "<blockquote data-name=\"summary\">#{format_summary(summary, html)}</blockquote>#{html}" + end + def reformat(html, outgoing = false) sanitize(html, Sanitize::Config::MASTODON_STRICT.merge(outgoing: outgoing)) rescue ArgumentError @@ -89,7 +135,11 @@ class Formatter end def simplified_format(account, **options) - html = account.local? ? linkify(account.note) : reformat(account.note) + return reformat(account.note) unless account.local? + + html = format_markdown(account.note) + html = encode_and_link_urls(html, keep_html: true) + html = reformat(html, true) html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify] html.html_safe # rubocop:disable Rails/OutputSafety end @@ -98,8 +148,12 @@ class Formatter Sanitize.fragment(html, config) end + def format_summary(summary, fallback) + summary&.strip.presence || fallback[/(?:<p>.*?<\/p>)/im].presence || '🗎❓' + end + def format_spoiler(status, **options) - html = encode(status.spoiler_text) + html = encode(status.title.presence || status.spoiler_text) html = encode_custom_emojis(html, status.emojis, options[:autoplay]) html.html_safe # rubocop:disable Rails/OutputSafety end @@ -122,8 +176,8 @@ class Formatter html.html_safe # rubocop:disable Rails/OutputSafety end - def linkify(text) - html = encode_and_link_urls(text) + def linkify(text, accounts = nil, options = {}) + html = encode_and_link_urls(text, accounts, options) html = simple_format(html, {}, sanitize: false) html = html.delete("\n") @@ -154,7 +208,7 @@ class Formatter renderer = HTMLRenderer.new({ filter_html: false, escape_html: false, - no_images: true, + no_images: false, no_styles: true, safe_links_only: true, hard_wrap: true, @@ -390,4 +444,17 @@ class Formatter def mention_html(account) "<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(account.username)}</span></a></span>" end + + def formatter_cache_key(status, options) + [ + 'format', + status.id.to_s, + options[:article_content] ? '1' : '0', + options[:inline_poll_options] ? '1' : '0', + options[:plaintext] ? '1' : '0', + options[:autoplay] ? '1' : '0', + options[:custom_emojify] ? '1' : '0', + ].join(':') + end end +# rubocop:enable Metrics/ClassLength diff --git a/app/lib/img_tag_handler.rb b/app/lib/img_tag_handler.rb new file mode 100644 index 000000000..0263e1cbd --- /dev/null +++ b/app/lib/img_tag_handler.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ImgTagHandler < ::Ox::Sax + attr_reader :srcs + attr_reader :alts + + def initialize + @stack = [] + @srcs = [] + @alts = {} + end + + def start_element(element_name) + @stack << [element_name, {}] + end + + def end_element(_) + self_name, self_attributes = @stack[-1] + if self_name == :img && !self_attributes[:src].nil? + @srcs << self_attributes[:src] + @alts[self_attributes[:src]] = self_attributes[:alt]&.strip + end + @stack.pop + end + + def attr(attribute_name, attribute_value) + _name, attributes = @stack.last + attributes[attribute_name] = attribute_value&.strip + end +end diff --git a/app/lib/rss/serializer.rb b/app/lib/rss/serializer.rb index fd56c568c..334726885 100644 --- a/app/lib/rss/serializer.rb +++ b/app/lib/rss/serializer.rb @@ -10,6 +10,7 @@ class RSS::Serializer .link(ActivityPub::TagManager.instance.url_for(status)) .pub_date(status.created_at) .description(status.spoiler_text.presence || Formatter.instance.format(status, inline_poll_options: true).to_str) + .content(Formatter.instance.format(status, inline_poll_options: true, article_content: true).to_str) status.media_attachments.each do |media| item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) diff --git a/app/lib/rss_builder.rb b/app/lib/rss_builder.rb index 63ddba2e8..a74b4e035 100644 --- a/app/lib/rss_builder.rb +++ b/app/lib/rss_builder.rb @@ -35,6 +35,12 @@ class RSSBuilder self end + def content(str) + @item << (Ox::Element.new('content:encoded') << str) + + self + end + def enclosure(url, type, size) @item << Ox::Element.new('enclosure').tap do |enclosure| enclosure['url'] = url diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb index ccc3f4642..adbbd2168 100644 --- a/app/lib/sanitize_config.rb +++ b/app/lib/sanitize_config.rb @@ -30,28 +30,23 @@ class Sanitize next true if e =~ /^(h|p|u|dt|e)-/ # microformats classes next true if e =~ /^(mention|hashtag)$/ # semantic classes next true if e =~ /^(ellipsis|invisible)$/ # link formatting classes + next true if %w(center centered abstract).include?(e) end node['class'] = class_list.join(' ') end - IMG_TAG_TRANSFORMER = lambda do |env| + DATA_NAME_ALLOWLIST_TRANSFORMER = lambda do |env| node = env[:node] + name_list = node['data-name']&.split(/[\t\n\f\r ]/) - return unless env[:node_name] == 'img' + return unless name_list - node.name = 'a' - - node['href'] = node['src'] - if node['alt'].present? - node.content = "[🖼 #{node['alt']}]" - else - url = node['href'] - prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s - text = url[prefix.length, 30] - text = text + "…" if url[prefix.length..-1].length > 30 - node.content = "[🖼 #{text}]" + name_list.keep_if do |name| + next true if %w(summary abstract permalink footer).include?(name) end + + node['data-name'] = name_list.join(' ') end LINK_REL_TRANSFORMER = lambda do |env| @@ -83,15 +78,17 @@ class Sanitize end MASTODON_STRICT ||= freeze_config( - elements: %w(p br span a abbr del pre blockquote code b strong u sub sup i em h1 h2 h3 h4 h5 ul ol li), + elements: %w(p br span a abbr del pre blockquote code b strong u sub sup i em h1 h2 h3 h4 h5 ul ol li img h6 s center details summary), attributes: { 'a' => %w(href rel class title), 'span' => %w(class), 'abbr' => %w(title), - 'blockquote' => %w(cite), + 'blockquote' => %w(cite data-name), 'ol' => %w(start reversed), 'li' => %w(value), + 'img' => %w(src alt title), + 'p' => %w(data-name), }, add_attributes: { @@ -107,7 +104,7 @@ class Sanitize transformers: [ CLASS_WHITELIST_TRANSFORMER, - IMG_TAG_TRANSFORMER, + DATA_NAME_ALLOWLIST_TRANSFORMER, UNSUPPORTED_HREF_TRANSFORMER, LINK_REL_TRANSFORMER, ] diff --git a/app/lib/status_filter.rb b/app/lib/status_filter.rb index b6c80b801..eb31dcad6 100644 --- a/app/lib/status_filter.rb +++ b/app/lib/status_filter.rb @@ -3,15 +3,17 @@ class StatusFilter attr_reader :status, :account - def initialize(status, account, preloaded_relations = {}) + def initialize(status, account, filter_silenced, preloaded_relations = {}) @status = status @account = account @preloaded_relations = preloaded_relations + @filter_silenced = filter_silenced end def filtered? return false if !account.nil? && account.id == status.account_id - blocked_by_policy? || (account_present? && filtered_status?) || silenced_account? + + blocked_by_policy? || (account_present? && filtered_status?) || (@filter_silenced && silenced_account?) end private @@ -53,6 +55,8 @@ class StatusFilter end def policy_allows_show? - StatusPolicy.new(account, status, @preloaded_relations).show? + return false unless StatusPolicy.new(account, status, @preloaded_relations).show? + + status.reblog? ? StatusPolicy.new(account, status.reblog, @preloaded_relations).show? : true end end diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index 2f9cfe3ad..386b1dcf6 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true +require 'w3c_validators' + class UserSettingsDecorator + include W3CValidators + attr_reader :user, :settings def initialize(user) @@ -31,18 +35,34 @@ class UserSettingsDecorator user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui') user.settings['system_emoji_font'] = system_emoji_font_preference if change?('setting_system_emoji_font') user.settings['noindex'] = noindex_preference if change?('setting_noindex') - user.settings['hide_followers_count']= hide_followers_count_preference if change?('setting_hide_followers_count') + user.settings['hide_followers_count'] = hide_followers_count_preference if change?('setting_hide_followers_count') user.settings['flavour'] = flavour_preference if change?('setting_flavour') user.settings['skin'] = skin_preference if change?('setting_skin') user.settings['hide_network'] = hide_network_preference if change?('setting_hide_network') user.settings['aggregate_reblogs'] = aggregate_reblogs_preference if change?('setting_aggregate_reblogs') user.settings['show_application'] = show_application_preference if change?('setting_show_application') user.settings['advanced_layout'] = advanced_layout_preference if change?('setting_advanced_layout') - user.settings['default_content_type']= default_content_type_preference if change?('setting_default_content_type') + user.settings['default_content_type'] = default_content_type_preference if change?('setting_default_content_type') user.settings['use_blurhash'] = use_blurhash_preference if change?('setting_use_blurhash') user.settings['use_pending_items'] = use_pending_items_preference if change?('setting_use_pending_items') user.settings['trends'] = trends_preference if change?('setting_trends') user.settings['crop_images'] = crop_images_preference if change?('setting_crop_images') + + user.settings['manual_publish'] = manual_publish_preference if change?('setting_manual_publish') + user.settings['style_dashed_nest'] = style_dashed_nest_preference if change?('setting_style_dashed_nest') + user.settings['style_underline_a'] = style_underline_a_preference if change?('setting_style_underline_a') + user.settings['style_css_profile'] = style_css_profile_preference if change?('setting_style_css_profile') + user.settings['style_css_webapp'] = style_css_webapp_preference if change?('setting_style_css_webapp') + user.settings['style_wide_media'] = style_wide_media_preference if change?('setting_style_wide_media') + user.settings['publish_in'] = publish_in_preference if change?('setting_publish_in') + user.settings['unpublish_in'] = unpublish_in_preference if change?('setting_unpublish_in') + user.settings['unpublish_delete'] = unpublish_delete_preference if change?('setting_unpublish_delete') + user.settings['boost_every'] = boost_every_preference if change?('setting_boost_every') + user.settings['boost_jitter'] = boost_jitter_preference if change?('setting_boost_jitter') + user.settings['boost_random'] = boost_random_preference if change?('setting_boost_random') + user.settings['filter_to_unknown'] = filter_to_unknown_preference if change?('setting_filter_to_unknown') + user.settings['filter_from_unknown'] = filter_from_unknown_preference if change?('setting_filter_from_unknown') + user.settings['unpublish_on_delete'] = unpublish_on_delete_preference if change?('setting_unpublish_on_delete') end def merged_notification_emails @@ -157,6 +177,70 @@ class UserSettingsDecorator boolean_cast_setting 'setting_crop_images' end + def manual_publish_preference + boolean_cast_setting 'setting_manual_publish' + end + + def style_dashed_nest_preference + boolean_cast_setting 'setting_style_dashed_nest' + end + + def style_underline_a_preference + boolean_cast_setting 'setting_style_underline_a' + end + + def style_css_profile_preference + css = settings['setting_style_css_profile'].to_s.strip.delete("\r").gsub(/\n\n\n+/, "\n\n") + user.settings['style_css_profile_errors'] = validate_css(css) + css + end + + def style_css_webapp_preference + css = settings['setting_style_css_webapp'].to_s.strip.delete("\r").gsub(/\n\n\n+/, "\n\n") + user.settings['style_css_webapp_errors'] = validate_css(css) + css + end + + def style_wide_media_preference + boolean_cast_setting 'setting_style_wide_media' + end + + def publish_in_preference + settings['setting_publish_in'].to_i + end + + def unpublish_in_preference + settings['setting_unpublish_in'].to_i + end + + def unpublish_delete_preference + boolean_cast_setting 'setting_unpublish_delete' + end + + def boost_every_preference + settings['setting_boost_every'].to_i + end + + def boost_jitter_preference + settings['setting_boost_jitter'].to_i + end + + def boost_random_preference + boolean_cast_setting 'setting_boost_random' + end + + def filter_to_unknown_preference + boolean_cast_setting 'setting_filter_to_unknown' + end + + def filter_from_unknown_preference + boolean_cast_setting 'setting_filter_from_unknown' + end + + def unpublish_on_delete_preference + boolean_cast_setting 'setting_unpublish_on_delete' + end + def boolean_cast_setting(key) ActiveModel::Type::Boolean.new.cast(settings[key]) end @@ -172,4 +256,10 @@ class UserSettingsDecorator def change?(key) !settings[key].nil? end + + def validate_css(css) + @validator ||= CSSValidator.new + results = @validator.validate_text(css) + results.errors.map { |e| e.to_s.strip } + end end diff --git a/app/models/account.rb b/app/models/account.rb index 0b3c48543..c7bf7bf80 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -50,6 +50,12 @@ # avatar_storage_schema_version :integer # header_storage_schema_version :integer # devices_url :string +# require_dereference :boolean default(FALSE), not null +# show_replies :boolean default(TRUE), not null +# show_unlisted :boolean default(TRUE), not null +# private :boolean default(FALSE), not null +# require_auth :boolean default(FALSE), not null +# last_synced_at :datetime # class Account < ApplicationRecord @@ -115,6 +121,7 @@ class Account < ApplicationRecord scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) } scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) } scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) } + scope :random, -> { reorder(Arel.sql('RANDOM()')).limit(1) } delegate :email, :unconfirmed_email, @@ -357,6 +364,38 @@ class Account < ApplicationRecord shared_inbox_url.presence || inbox_url end + def max_visibility_for_domain(domain) + return 'public' if domain.blank? + + domain_permissions.find_by(domain: [domain, '*'])&.visibility || 'public' + end + + def visibility_for_domain(domain) + v = visibility.to_s + return v if domain.blank? + + case max_visibility_for_domain(domain) + when 'public' + v + when 'unlisted' + v == 'public' ? 'unlisted' : v + when 'private' + %w(public unlisted).include?(v) ? 'private' : v + when 'direct' + 'direct' + else + v != 'direct' ? 'limited' : 'direct' + end + end + + def public_domain_permissions? + domain_permissions.where(visibility: [:public, :unlisted]).exists? + end + + def private_domain_permissions? + domain_permissions.where(visibility: [:private, :direct, :limited]).exists? + end + class Field < ActiveModelSerializers::Model attributes :name, :value, :verified_at, :account, :errors @@ -525,6 +564,8 @@ class Account < ApplicationRecord before_validation :prepare_username, on: :create before_destroy :clean_feed_manager + after_create_commit :set_metadata, if: :local? + private def prepare_contents @@ -568,4 +609,8 @@ class Account < ApplicationRecord end end end + + def set_metadata + self.metadata = AccountMetadata.new(account_id: id, fields: {}) if metadata.nil? + end end diff --git a/app/models/account_domain_permission.rb b/app/models/account_domain_permission.rb new file mode 100644 index 000000000..9e77950f2 --- /dev/null +++ b/app/models/account_domain_permission.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: account_domain_permissions +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# domain :string default(""), not null +# visibility :integer default("public"), not null +# sticky :boolean default(FALSE), not null +# + +class AccountDomainPermission < ApplicationRecord + include Paginable + include Cacheable + + validates :domain, presence: true, uniqueness: { scope: :account_id } + validates :visibility, presence: true + + belongs_to :account, inverse_of: :domain_permissions + enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility + + default_scope { order(domain: :desc) } + + cache_associated :account + + class << self + def create_by_domains(permissions_list) + Array(permissions_list).map(&method(:normalize)).map do |permissions| + where(**permissions).first_or_create + end + end + + def create_by_domains!(permissions_list) + Array(permissions_list).map(&method(:normalize)).map do |permissions| + where(**permissions).first_or_create! + end + end + + def create_or_update(domain_permissions) + domain_permissions = normalize(domain_permissions) + permissions = find_by(domain: domain_permissions[:domain]) + if permissions.present? + permissions.update(**domain_permissions) unless permissions.sticky? && %w(direct limited private).include?(domain_permissions[:visibility].to_s) + else + create(**domain_permissions) + end + permissions + end + + def create_or_update!(domain_permissions) + domain_permissions = normalize(domain_permissions) + permissions = find_by(domain: domain_permissions[:domain]) + if permissions.present? + permissions.update!(**domain_permissions) unless permissions.sticky? && %w(direct limited private).include?(domain_permissions[:visibility].to_s) + else + create!(**domain_permissions) + end + permissions + end + + private + + def normalize(hash) + hash.symbolize_keys! + hash[:domain] = hash[:domain].strip.downcase + hash.compact + end + end +end diff --git a/app/models/account_metadata.rb b/app/models/account_metadata.rb new file mode 100644 index 000000000..bb0f7676e --- /dev/null +++ b/app/models/account_metadata.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: account_metadata +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# fields :jsonb not null +# + +class AccountMetadata < ApplicationRecord + include Cacheable + + belongs_to :account, inverse_of: :metadata + cache_associated :account + + def fields + self[:fields].presence || {} + end + + def fields_json + fields.select { |name, _| name.start_with?('custom:') } + .map do |name, value| + { + '@context': { + schema: 'http://schema.org/', + name: 'schema:name', + value: 'schema:value', + }, + type: 'PropertyValue', + name: name, + value: value.is_a?(Array) ? value.join("\r\n") : value, + } + end + end + + def cached_fields_json + Rails.cache.fetch("custom_metadata:#{account_id}", expires_in: 1.hour) do + fields_json + end + end + + class << self + def create_or_update(fields) + create(fields).presence || update(fields) + end + + def create_or_update!(fields) + create(fields).presence || update!(fields) + end + end +end diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb new file mode 100644 index 000000000..24aaf66d4 --- /dev/null +++ b/app/models/collection_item.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: collection_items +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# uri :string not null +# processed :boolean default(FALSE), not null +# retries :integer default(0), not null +# + +class CollectionItem < ApplicationRecord + belongs_to :account, inverse_of: :collection_items, optional: true + + default_scope { order(id: :desc) } + scope :unprocessed, -> { where(processed: false) } + scope :joins_on_collection_pages, -> { joins('LEFT OUTER JOIN collection_pages ON collection_pages.account_id = collection_items.account_id') } + scope :inactive, -> { joins_on_collection_pages.where('collection_pages.account_id IS NULL') } + scope :active, -> { joins_on_collection_pages.where('collection_pages.account_id IS NOT NULL') } +end diff --git a/app/models/collection_page.rb b/app/models/collection_page.rb new file mode 100644 index 000000000..e974e58a2 --- /dev/null +++ b/app/models/collection_page.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: collection_pages +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# uri :string not null +# next :string +# + +class CollectionPage < ApplicationRecord + belongs_to :account, inverse_of: :collection_pages, optional: true + + default_scope { order(id: :desc) } + scope :current, -> { where(next: nil) } +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index cca3a17fa..a8b024346 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -60,5 +60,23 @@ module AccountAssociations # Hashtags has_and_belongs_to_many :tags has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account + + # Threads + has_many :threads, class_name: 'Conversation', inverse_of: :account, dependent: :nullify + + # Domain permissions + has_many :domain_permissions, class_name: 'AccountDomainPermission', inverse_of: :account, dependent: :destroy + + # Custom metadata + has_one :metadata, class_name: 'AccountMetadata', inverse_of: :account, dependent: :destroy + + # Queued boosts + has_many :queued_boosts, inverse_of: :account, dependent: :destroy + + # Collection pages + has_many :collection_pages, inverse_of: :account, dependent: :destroy + + # Collection items + has_many :collection_items, inverse_of: :account, dependent: :destroy end end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index be7211f2c..538e92f41 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -25,7 +25,7 @@ module AccountInteractions end def muting_map(target_account_ids, account_id) - Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping| + Mute.where(target_account_id: target_account_ids, account_id: account_id, timelines_only: false).each_with_object({}) do |mute, mapping| mapping[mute.target_account_id] = { notifications: mute.hide_notifications?, } @@ -90,9 +90,10 @@ module AccountInteractions has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account has_many :muted_by_relationships, class_name: 'Mute', foreign_key: :target_account_id, dependent: :destroy has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account - has_many :conversation_mutes, dependent: :destroy + has_many :conversation_mutes, inverse_of: :account, dependent: :destroy has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy has_many :announcement_mutes, dependent: :destroy + has_many :status_mutes, inverse_of: :account, dependent: :destroy end def follow!(other_account, reblogs: nil, uri: nil, rate_limit: false) @@ -125,21 +126,22 @@ module AccountInteractions .find_or_create_by!(target_account: other_account) end - def mute!(other_account, notifications: nil) + def mute!(other_account, notifications: nil, timelines_only: nil) notifications = true if notifications.nil? - mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account) + timelines_only = false if timelines_only.nil? + mute = mute_relationships.create_with(hide_notifications: notifications, timelines_only: timelines_only).find_or_create_by!(target_account: other_account) remove_potential_friendship(other_account) # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. - if mute.hide_notifications? != notifications - mute.update!(hide_notifications: notifications) - end + mute.update!(hide_notifications: notifications, timelines_only: timelines_only) if mute.hide_notifications? != notifications mute end - def mute_conversation!(conversation) - conversation_mutes.find_or_create_by!(conversation: conversation) + def mute_conversation!(conversation, hidden: false) + mute = conversation_mutes.find_or_create_by!(conversation: conversation) + mute.update(hidden: hidden) if hidden.present? && mute.hidden? != hidden + mute end def block_domain!(other_domain) @@ -171,6 +173,15 @@ module AccountInteractions block&.destroy end + def mute_status!(status) + status_mutes.find_or_create_by!(status: status) + end + + def unmute_status!(status) + mute = status_mutes.find_by(status: status) + mute&.destroy + end + def following?(other_account) active_relationships.where(target_account: other_account).exists? end @@ -184,13 +195,17 @@ module AccountInteractions end def muting?(other_account) - mute_relationships.where(target_account: other_account).exists? + mute_relationships.where(target_account: other_account, timelines_only: false).exists? end def muting_conversation?(conversation) conversation_mutes.where(conversation: conversation).exists? end + def hiding_conversation?(conversation) + conversation_mutes.where(conversation: conversation, hidden: true).exists? + end + def muting_notifications?(other_account) mute_relationships.where(target_account: other_account, hide_notifications: true).exists? end @@ -199,6 +214,10 @@ module AccountInteractions active_relationships.where(target_account: other_account, show_reblogs: false).exists? end + def muting_status?(status) + status_mutes.where(status: status).exists? + end + def requested?(other_account) follow_requests.where(target_account: other_account).exists? end diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb index a0ead1995..50d081811 100644 --- a/app/models/concerns/status_threading_concern.rb +++ b/app/models/concerns/status_threading_concern.rb @@ -86,7 +86,7 @@ module StatusThreadingConcern domains = statuses.map(&:account_domain).compact.uniq relations = relations_map_for_account(account, account_ids, domains) - statuses.reject! { |status| StatusFilter.new(status, account, relations).filtered? } + statuses.reject! { |status| StatusFilter.new(status, account, false, relations).filtered? } # Order ancestors/descendants by tree path statuses.sort_by! { |status| ids.index(status.id) } diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 4dfaea889..e065c34c8 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -7,12 +7,17 @@ # uri :string # created_at :datetime not null # updated_at :datetime not null +# account_id :bigint(8) +# public :boolean default(FALSE), not null +# root :string # class Conversation < ApplicationRecord validates :uri, uniqueness: true, if: :uri? has_many :statuses + has_many :mutes, class_name: 'ConversationMute', inverse_of: :conversation, dependent: :destroy + belongs_to :account, inverse_of: :threads, optional: true def local? uri.nil? diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb index 52c1a33e0..5d56a3172 100644 --- a/app/models/conversation_mute.rb +++ b/app/models/conversation_mute.rb @@ -6,9 +6,10 @@ # id :bigint(8) not null, primary key # conversation_id :bigint(8) not null # account_id :bigint(8) not null +# hidden :boolean default(FALSE), not null # class ConversationMute < ApplicationRecord - belongs_to :account - belongs_to :conversation + belongs_to :account, inverse_of: :conversation_mutes + belongs_to :conversation, inverse_of: :mutes end diff --git a/app/models/domain_allow.rb b/app/models/domain_allow.rb index 5fe0e3a29..70f559f49 100644 --- a/app/models/domain_allow.rb +++ b/app/models/domain_allow.rb @@ -8,10 +8,12 @@ # domain :string default(""), not null # created_at :datetime not null # updated_at :datetime not null +# hidden :boolean default(FALSE), not null # class DomainAllow < ApplicationRecord include DomainNormalizable + include Paginable validates :domain, presence: true, uniqueness: true, domain: true diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 2b18e01fa..743e21a29 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -16,6 +16,7 @@ class DomainBlock < ApplicationRecord include DomainNormalizable + include Paginable enum severity: [:silence, :suspend, :noop] diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index 3325e264c..cdf0f4bda 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -29,7 +29,10 @@ class FollowRequest < ApplicationRecord def authorize! account.follow!(target_account, reblogs: show_reblogs, uri: uri) - MergeWorker.perform_async(target_account.id, account.id) if account.local? + if account.local? + MergeWorker.perform_async(target_account.id, account.id) + ActivityPub::SyncAccountWorker.perform_async(target_account.id, every_page: true) unless target_account.local? + end destroy! end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index fcec3e686..e36974519 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -4,6 +4,8 @@ class Form::AdminSettings include ActiveModel::Model KEYS = %i( + show_domain_allows + site_contact_username site_contact_email site_title @@ -76,6 +78,8 @@ class Form::AdminSettings attr_accessor(*KEYS) + validates :show_domain_allows, inclusion: { in: %w(disabled users all) } + validates :site_short_description, :site_description, html: { wrap_with: :p } validates :site_extended_description, :site_terms, :closed_registrations_message, html: true validates :registrations_mode, inclusion: { in: %w(open approved none) } diff --git a/app/models/inline_media_attachment.rb b/app/models/inline_media_attachment.rb new file mode 100644 index 000000000..faa8ca1ac --- /dev/null +++ b/app/models/inline_media_attachment.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: inline_media_attachments +# +# id :bigint(8) not null, primary key +# status_id :bigint(8) +# media_attachment_id :bigint(8) +# + +class InlineMediaAttachment < ApplicationRecord + include Cacheable + + validates :status_id, uniqueness: { scope: :media_attachment_id } + + belongs_to :status, inverse_of: :inlined_attachments + belongs_to :media_attachment, inverse_of: :inlines + + cache_associated :status, :media_attachment +end diff --git a/app/models/invite.rb b/app/models/invite.rb index 29d25eae8..4695b4ebb 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -35,7 +35,7 @@ class Invite < ApplicationRecord def set_code loop do - self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(8).join + self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(16).join break if Invite.find_by(code: code).nil? end end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index cc81b648c..a1fe76589 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -26,6 +26,7 @@ # thumbnail_file_size :integer # thumbnail_updated_at :datetime # thumbnail_remote_url :string +# inline :boolean default(FALSE), not null # class MediaAttachment < ApplicationRecord @@ -34,7 +35,7 @@ class MediaAttachment < ApplicationRecord enum type: [:image, :gifv, :video, :unknown, :audio] enum processing: [:queued, :in_progress, :complete, :failed], _prefix: true - MAX_DESCRIPTION_LENGTH = 1_500 + MAX_DESCRIPTION_LENGTH = 2_000 IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze @@ -59,12 +60,12 @@ class MediaAttachment < ApplicationRecord IMAGE_STYLES = { original: { - pixels: 1_638_400, # 1280x1280px + pixels: 16_777_216, # 4096x4096px file_geometry_parser: FastGeometryParser, }.freeze, small: { - pixels: 160_000, # 400x400px + pixels: 250_000, # 500x500px file_geometry_parser: FastGeometryParser, blurhash: BLURHASH_OPTIONS, }.freeze, @@ -81,8 +82,8 @@ class MediaAttachment < ApplicationRecord 'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'', 'vsync' => 'cfr', 'c:v' => 'h264', - 'maxrate' => '1300K', - 'bufsize' => '1300K', + 'maxrate' => '2M', + 'bufsize' => '2M', 'frames:v' => 60 * 60 * 3, 'crf' => 18, 'map_metadata' => '-1', @@ -112,7 +113,7 @@ class MediaAttachment < ApplicationRecord convert_options: { output: { 'loglevel' => 'fatal', - vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease', + vf: 'scale=\'min(500\, iw):min(500\, ih)\':force_original_aspect_ratio=decrease', }.freeze, }.freeze, format: 'png', @@ -131,7 +132,7 @@ class MediaAttachment < ApplicationRecord convert_options: { output: { 'loglevel' => 'fatal', - 'q:a' => 2, + 'q:a' => 0, }.freeze, }.freeze, }.freeze, @@ -147,7 +148,7 @@ class MediaAttachment < ApplicationRecord }.freeze GLOBAL_CONVERT_OPTIONS = { - all: '-quality 90 -strip +set modify-date +set create-date', + all: '-quality 95 -strip +set modify-date +set create-date', }.freeze IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 10.megabytes).to_i @@ -160,6 +161,8 @@ class MediaAttachment < ApplicationRecord belongs_to :status, inverse_of: :media_attachments, optional: true belongs_to :scheduled_status, inverse_of: :media_attachments, optional: true + has_many :inlines, class_name: 'InlineMediaAttachment', inverse_of: :media_attachment, dependent: :destroy + has_attached_file :file, styles: ->(f) { file_styles f }, processors: ->(f) { file_processors f }, @@ -189,13 +192,16 @@ class MediaAttachment < ApplicationRecord validates :file, presence: true, if: :local? validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? } - scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) } - scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) } - scope :local, -> { where(remote_url: '') } - scope :remote, -> { where.not(remote_url: '') } + scope :attached, -> { all_media.where.not(status_id: nil).or(all_media.where.not(scheduled_status_id: nil)) } + scope :unattached, -> { all_media.where(status_id: nil, scheduled_status_id: nil) } + scope :uninlined, -> { where(inline: false) } + scope :inlined, -> { rewhere(inline: true) } + scope :all_media, -> { unscope(where: :inline) } + scope :local, -> { all_media.where(remote_url: '') } + scope :remote, -> { all_media.where.not(remote_url: '') } scope :cached, -> { remote.where.not(file_file_name: nil) } - default_scope { order(id: :asc) } + default_scope { uninlined.order(id: :asc) } def local? remote_url.blank? diff --git a/app/models/mute.rb b/app/models/mute.rb index 639120f7d..11f833d8e 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -9,6 +9,7 @@ # hide_notifications :boolean default(TRUE), not null # account_id :bigint(8) not null # target_account_id :bigint(8) not null +# timelines_only :boolean default(FALSE), not null # class Mute < ApplicationRecord diff --git a/app/models/queued_boost.rb b/app/models/queued_boost.rb new file mode 100644 index 000000000..6eca3725f --- /dev/null +++ b/app/models/queued_boost.rb @@ -0,0 +1,15 @@ +# == Schema Information +# +# Table name: queued_boosts +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# status_id :bigint(8) not null +# + +class QueuedBoost < ApplicationRecord + belongs_to :account, inverse_of: :queued_boosts + belongs_to :status, inverse_of: :queued_boosts + + validates :account_id, uniqueness: { scope: :status_id } +end diff --git a/app/models/status.rb b/app/models/status.rb index 594ae98c0..3d524dec5 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -21,13 +21,23 @@ # account_id :bigint(8) not null # application_id :bigint(8) # in_reply_to_account_id :bigint(8) -# local_only :boolean -# full_status_text :text default(""), not null +# local_only :boolean default(FALSE), not null # poll_id :bigint(8) # content_type :string # deleted_at :datetime +# edited :integer default(0), not null +# nest_level :integer default(0), not null +# published :boolean default(TRUE), not null +# title :text +# semiprivate :boolean default(FALSE), not null +# original_text :text +# footer :text +# expires_at :datetime +# publish_at :datetime +# originally_local_only :boolean default(FALSE), not null # +# rubocop:disable Metrics/ClassLength class Status < ApplicationRecord before_destroy :unlink_from_conversations @@ -52,7 +62,7 @@ class Status < ApplicationRecord belongs_to :application, class_name: 'Doorkeeper::Application', optional: true belongs_to :account, inverse_of: :statuses - belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true + belongs_to :in_reply_to_account, class_name: 'Account', optional: true belongs_to :conversation, optional: true belongs_to :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true @@ -65,8 +75,15 @@ class Status < ApplicationRecord has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread has_many :mentions, dependent: :destroy, inverse_of: :status has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status + has_many :silent_mentions, -> { silent }, class_name: 'Mention', inverse_of: :status has_many :media_attachments, dependent: :nullify + has_many :inlined_attachments, class_name: 'InlineMediaAttachment', inverse_of: :status, dependent: :destroy + has_many :mutes, class_name: 'StatusMute', inverse_of: :status, dependent: :destroy + belongs_to :conversation_mute, primary_key: 'conversation_id', foreign_key: 'conversation_id', inverse_of: :conversation, dependent: :destroy, optional: true + has_many :domain_permissions, class_name: 'StatusDomainPermission', inverse_of: :status, dependent: :destroy + has_many :queued_boosts, inverse_of: :status, dependent: :destroy + has_and_belongs_to_many :tags has_and_belongs_to_many :preview_cards @@ -93,7 +110,8 @@ class Status < ApplicationRecord scope :with_accounts, ->(ids) { where(id: ids).includes(:account) } scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') } scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') } - scope :with_public_visibility, -> { where(visibility: :public) } + scope :with_public_visibility, -> { where(visibility: :public, published: true) } + scope :distributable, -> { where(visibility: [:public, :unlisted], published: true) } scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) } scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) } scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) } @@ -113,6 +131,22 @@ class Status < ApplicationRecord scope :not_local_only, -> { where(local_only: [false, nil]) } + scope :including_unpublished, -> { unscope(where: :published) } + scope :unpublished, -> { rewhere(published: false) } + scope :published, -> { where(published: true) } + scope :without_semiprivate, -> { where(semiprivate: false) } + scope :reblogs, -> { where('statuses.reblog_of_id IS NOT NULL') } + scope :locally_reblogged, -> { where(id: Status.unscoped.local.reblogs.select(:reblog_of_id)) } + scope :conversations_by, ->(account) { joins(:conversation).where(conversations: { account: account }) } + scope :mentioning_account, ->(account) { joins(:mentions).where(mentions: { account: account }) } + scope :replies, -> { where(reply: true).where('statuses.in_reply_to_account_id != statuses.account_id') } + scope :expired, -> { published.where('statuses.expires_at IS NOT NULL AND statuses.expires_at < ?', Time.now.utc) } + scope :ready_to_publish, -> { unpublished.where('statuses.publish_at IS NOT NULL AND statuses.publish_at < ?', Time.now.utc) } + + scope :not_hidden_by_account, ->(account) do + left_outer_joins(:mutes, :conversation_mute).where('(status_mutes.account_id IS NULL OR status_mutes.account_id != ?) AND (conversation_mutes.account_id IS NULL OR (conversation_mutes.account_id != ? AND conversation_mutes.hidden = TRUE))', account.id, account.id) + end + cache_associated :application, :media_attachments, :conversation, @@ -136,8 +170,20 @@ class Status < ApplicationRecord thread: { account: :account_stat } delegate :domain, to: :account, prefix: true + delegate :max_visibility_for_domain, to: :account REAL_TIME_WINDOW = 6.hours + SORTED_VISIBILITY = { + direct: 0, + limited: 1, + private: 2, + unlisted: 3, + public: 4, + }.with_indifferent_access.freeze + TIMER_VALUES = [ + 0, 1, 2, 3, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880, 4320, 7200, + 10_080, 20_160, 30_240, 60_480, 120_960, 181_440, 241_920, 362_880, 524_160 + ].freeze def searchable_by(preloaded = nil) ids = [] @@ -204,7 +250,7 @@ class Status < ApplicationRecord end def hidden? - !distributable? + !published? || !distributable? end def distributable? @@ -228,7 +274,7 @@ class Status < ApplicationRecord def emojis return @emojis if defined?(@emojis) - fields = [spoiler_text, text] + fields = [spoiler_text, text, footer || ''] fields += preloadable_poll.options unless preloadable_poll.nil? @emojis = CustomEmoji.from_text(fields.join(' '), account.domain) @@ -262,24 +308,99 @@ class Status < ApplicationRecord update_status_stat!(key => [public_send(key) - 1, 0].max) end + def notify=(value) + Redis.current.set("status:#{id}:notify", value ? 1 : 0, ex: 1.hour) + @notify = value + end + + def notify + return @notify if defined?(@notify) + + value = Redis.current.get("status:#{id}:notify") + @notify = value.nil? ? true : value.to_i == 1 + end + + alias notify? notify + + def less_private_than?(other_visibility) + return false if other_visibility.blank? + + SORTED_VISIBILITY[visibility] > SORTED_VISIBILITY[other_visibility] + end + + def more_private_than?(other_visibility) + return false if other_visibility.blank? + + SORTED_VISIBILITY[visibility] < SORTED_VISIBILITY[other_visibility] + end + + def visibility_for_domain(domain) + return visibility.to_s if domain.blank? + + v = domain_permissions.find_by(domain: [domain, '*'])&.visibility || visibility.to_s + + case max_visibility_for_domain(domain) + when 'public' + v + when 'unlisted' + v == 'public' ? 'unlisted' : v + when 'private' + %w(public unlisted).include?(v) ? 'private' : v + when 'direct' + 'direct' + else + v != 'direct' ? 'limited' : 'direct' + end + end + + def public_domain_permissions? + return @public_permissions if defined?(@public_permissions) + return @public_permissions = false unless account.local? + + @public_permissions = domain_permissions.where(visibility: [:public, :unlisted]).exists? + end + + def private_domain_permissions? + return @private_permissions if defined?(@private_permissions) + return @private_permissions = false unless account.local? + + @private_permissions = domain_permissions.where(visibility: [:private, :direct, :limited]).exists? + end + + def should_be_semiprivate? + return @should_be_semiprivate if defined?(@should_be_semiprivate) + return @should_be_semiprivate = true if distributable? && (private_domain_permissions? || account.private_domain_permissions?) + + @should_be_semiprivate = !distributable? && (public_domain_permissions? || account.public_domain_permissions?) + end + + def should_limit_visibility? + less_private_than?(thread&.visibility) + end + after_create_commit :increment_counter_caches after_destroy_commit :decrement_counter_caches after_create_commit :store_uri, if: :local? + after_create_commit :store_url, if: :local? after_create_commit :update_statistics, if: :local? around_create Mastodon::Snowflake::Callbacks before_create :set_locality + before_create :set_nest_level before_validation :prepare_contents, if: :local? before_validation :set_reblog - before_validation :set_visibility - before_validation :set_conversation + before_validation :set_conversation_perms before_validation :set_local after_create :set_poll_id + after_save :set_domain_permissions, if: :local? + after_save :set_semiprivate, if: :local? + after_save :set_conversation_root + class << self def selectable_visibilities visibilities.keys - %w(direct limited) @@ -338,7 +459,7 @@ class Status < ApplicationRecord end def as_tag_timeline(tag, account = nil, local_only = false) - query = timeline_scope(local_only).tagged_with(tag) + query = timeline_scope(local_only, include_unlisted: true).tagged_with(tag) apply_timeline_filters(query, account, local_only) end @@ -363,6 +484,14 @@ class Status < ApplicationRecord ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true } end + def hidden_conversations_map(conversation_ids, account_id) + ConversationMute.select('conversation_id').where(conversation_id: conversation_ids, hidden: true).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true } + end + + def hidden_statuses_map(status_ids, account_id) + StatusMute.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.status_id] = true } + end + def pins_map(status_ids, account_id) StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true } end @@ -379,7 +508,7 @@ class Status < ApplicationRecord return if account_ids.empty? - accounts = Account.where(id: account_ids).includes(:account_stat).each_with_object({}) { |a, h| h[a.id] = a } + accounts = Account.where(id: account_ids).includes(:account_stat).index_by(&:id) cached_items.each do |item| item.account = accounts[item.account_id] @@ -387,26 +516,26 @@ class Status < ApplicationRecord end end - def permitted_for(target_account, account) + def permitted_for(target_account, account, **options) visibility = [:public, :unlisted] - if account.nil? - where(visibility: visibility).not_local_only - elsif target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) # get rid of blocked peeps - none - elsif account.id == target_account.id # author can see own stuff - all - else - # followers can see followers-only stuff, but also things they are mentioned in. - # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in. + if account.present? + return none if target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) + return apply_category_filters(all, target_account, account, **options) if account.id == target_account.id + visibility.push(:private) if account.following?(target_account) + end - scope = left_outer_joins(:reblog) + visibility = :public if options[:public] || (account.blank? && !target_account.show_unlisted?) - scope.where(visibility: visibility) - .or(scope.where(id: account.mentions.select(:status_id))) - .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids }))) - end + scope = where(visibility: visibility) + apply_category_filters(scope, target_account, account, **options) + end + + def mentions_between(account, target_account) + return none if account.blank? || target_account.blank? + + account.statuses.mentioning_account(target_account).or(target_account.statuses.mentioning_account(account)) end def from_text(text) @@ -426,21 +555,75 @@ class Status < ApplicationRecord private - def timeline_scope(scope = false) + # TODO: Cast cleanup spell. + # rubocop:disable Metrics/PerceivedComplexity + def apply_category_filters(query, target_account, account, **options) + options[:without_account_filters] ||= target_account.id == account&.id + query = apply_account_filters(query, account, **options) + return query if options[:without_category_filters] + + query = query.published unless options[:include_unpublished] + query = query.without_semiprivate unless options[:include_semiprivate] + + if options[:only_reblogs] + query = query.joins(:reblog) + if account.present? && account.excluded_from_timeline_account_ids.present? + query = query.where.not( + reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids } + ) + end + elsif target_account.id == account&.id + query = query.without_replies unless options[:include_replies] || options[:only_replies] + query = query.without_reblogs unless options[:include_reblogs] || options[:only_reblogs] + query = query.reblogs if options[:only_reblogs] + query = query.replies if options[:only_replies] + else + if options[:include_reblogs] && account.present? && account.excluded_from_timeline_account_ids.present? + query = query.left_outer_joins(:reblog).where( + '(statuses.reblog_of_id IS NULL OR reblogs_statuses.account_id NOT IN (?))', + account.excluded_from_timeline_account_ids + ) + elsif !options[:include_reblogs] + query = query.without_reblogs + end + + query = if options[:include_replies] + query = query.replies if options[:only_replies] + query.conversations_by(target_account) + else + query.without_replies + end + end + + return query if options[:tag].blank? + + (tag = Tag.find_normalized(options[:tag])) ? query.merge(Status.tagged_with(tag.id)) : none + end + # rubocop:enable Metrics/PerceivedComplexity + + def apply_account_filters(query, account, **options) + return query.not_local_only if account.blank? + return (!options[:exclude_local_only] && account.local? ? query : query.not_local_only) if options[:without_account_filters] + + query = query.not_local_only unless !options[:exclude_local_only] && account.local? + query = query.not_hidden_by_account(account) + query = query.in_chosen_languages(account) if account.chosen_languages.present? + query + end + + def timeline_scope(scope = false, include_unlisted: false) starting_scope = case scope when :local, true Status.local when :remote Status.remote + when :local_reblogs + Status.locally_reblogged else Status end - starting_scope = starting_scope.with_public_visibility - if Setting.show_reblogs_in_public_timelines - starting_scope - else - starting_scope.without_reblogs - end + starting_scope = include_unlisted ? starting_scope.distributable : starting_scope.with_public_visibility + scope != :local_reblogs ? starting_scope.without_reblogs : starting_scope end def apply_timeline_filters(query, account, local_only) @@ -455,6 +638,7 @@ class Status < ApplicationRecord query = query.not_excluded_by_account(account) query = query.not_domain_blocked_by_account(account) unless local_only query = query.in_chosen_languages(account) if account.chosen_languages.present? + query = query.not_hidden_by_account(account) query.merge(account_silencing_filter(account)) end @@ -497,9 +681,15 @@ class Status < ApplicationRecord update_column(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil? end + def store_url + update_column(:url, ActivityPub::TagManager.instance.url_for(self)) if url.nil? + end + def prepare_contents text&.strip! spoiler_text&.strip! + title&.strip! + language&.gsub!('en-MP', 'en') end def set_reblog @@ -510,31 +700,38 @@ class Status < ApplicationRecord update_column(:poll_id, poll.id) unless poll.nil? end - def set_visibility - self.visibility = reblog.visibility if reblog? && visibility.nil? - self.visibility = (account.locked? ? :private : :public) if visibility.nil? - self.sensitive = false if sensitive.nil? - end - def set_locality if account.domain.nil? && !attribute_changed?(:local_only) - self.local_only = marked_local_only? + self.local_only = true if marked_local_only? end + self.local_only = true if thread&.local_only? && local_only.nil? + self.local_only = reblog.local_only if reblog? + + self.originally_local_only = local_only if attribute_changed?(:local_only) && !attribute_changed?(:originally_local_only) end - def set_conversation + def set_conversation_perms self.thread = thread.reblog if thread&.reblog? - self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply + self.visibility = reblog.visibility if reblog? && visibility.nil? + self.visibility = (account.locked? ? :private : :public) if visibility.nil? + self.visibility = thread.visibility if should_limit_visibility? + self.sensitive = false if sensitive.nil? if reply? && !thread.nil? self.in_reply_to_account_id = carried_over_reply_to_account_id self.conversation_id = thread.conversation_id if conversation_id.nil? elsif conversation_id.nil? - self.conversation = Conversation.new + self.conversation = reply? ? Conversation.new(account_id: nil) : Conversation.new(account_id: account_id) + elsif !reply? && account_id != conversation.account_id + conversation.update!(account_id: account_id) end end + def set_conversation_root + conversation.update!(root: uri, account_id: account_id) if !reply && conversation.root.blank? + end + def carried_over_reply_to_account_id if thread.account_id == account_id && thread.reply? thread.in_reply_to_account_id @@ -547,6 +744,32 @@ class Status < ApplicationRecord self.local = account.local? end + def set_nest_level + return if attribute_changed?(:nest_level) + + self.nest_level = if reply? + [thread&.account_id == account_id ? thread&.nest_level.to_i : thread&.nest_level.to_i + 1, 127].min + else + 0 + end + end + + def set_domain_permissions + return unless saved_change_to_visibility? + + domain_permissions.transaction do + existing_domains = domain_permissions.select(:domain) + permissions = account.domain_permissions.where.not(domain: existing_domains) + permissions.find_each do |permission| + domain_permissions.create!(domain: permission.domain, visibility: permission.visibility) if less_private_than?(permission.visibility) + end + end + end + + def set_semiprivate + update_column(:semiprivate, should_be_semiprivate?) if semiprivate != should_be_semiprivate? + end + def update_statistics return unless distributable? @@ -580,3 +803,4 @@ class Status < ApplicationRecord end end end +# rubocop:enable Metrics/ClassLength diff --git a/app/models/status_domain_permission.rb b/app/models/status_domain_permission.rb new file mode 100644 index 000000000..be767a2b6 --- /dev/null +++ b/app/models/status_domain_permission.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: status_domain_permissions +# +# id :bigint(8) not null, primary key +# status_id :bigint(8) not null +# domain :string default(""), not null +# visibility :integer default("public"), not null +# + +class StatusDomainPermission < ApplicationRecord + include Paginable + include Cacheable + + validates :domain, presence: true, uniqueness: { scope: :status_id } + validates :visibility, presence: true + + belongs_to :status, inverse_of: :domain_permissions + enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility + + default_scope { order(domain: :desc) } + + cache_associated :status + + class << self + def create_by_domains(permissions_list) + Array(permissions_list).map(&method(:normalize)).map do |permissions| + where(**permissions).first_or_create + end + end + + def create_by_domains!(permissions_list) + Array(permissions_list).map(&method(:normalize)).map do |permissions| + where(**permissions).first_or_create! + end + end + + def create_or_update(domain_permissions) + domain_permissions = normalize(domain_permissions) + permissions = find_by(domain: domain_permissions[:domain]) + if permissions.present? + permissions.update(**domain_permissions) + else + create(**domain_permissions) + end + permissions + end + + def create_or_update!(domain_permissions) + domain_permissions = normalize(domain_permissions) + permissions = find_by(domain: domain_permissions[:domain]) + if permissions.present? + permissions.update!(**domain_permissions) + else + create!(**domain_permissions) + end + permissions + end + + private + + def normalize(hash) + hash.symbolize_keys! + hash[:domain] = hash[:domain].strip.downcase + hash.compact + end + end +end diff --git a/app/models/status_mute.rb b/app/models/status_mute.rb new file mode 100644 index 000000000..1e01f0278 --- /dev/null +++ b/app/models/status_mute.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: status_mutes +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# status_id :bigint(8) not null +# + +class StatusMute < ApplicationRecord + include Cacheable + + validates :account_id, uniqueness: { scope: :status_id } + + belongs_to :account, inverse_of: :status_mutes + belongs_to :status, inverse_of: :mutes + + cache_associated :account, :status +end diff --git a/app/models/user.rb b/app/models/user.rb index 4467362e1..6fa6bb369 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,6 +41,8 @@ # sign_in_token :string # sign_in_token_sent_at :datetime # webauthn_id :string +# username :string +# kobold :string # class User < ApplicationRecord @@ -89,7 +91,7 @@ class User < ApplicationRecord validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create scope :recent, -> { order(id: :desc) } - scope :pending, -> { where(approved: false) } + scope :pending, -> { where(approved: false).where.not(kobold: '') } scope :approved, -> { where(approved: true) } scope :confirmed, -> { where.not(confirmed_at: nil) } scope :enabled, -> { where(disabled: false) } @@ -116,6 +118,12 @@ class User < ApplicationRecord :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, :default_content_type, :system_emoji_font, + :manual_publish, :style_dashed_nest, :style_underline_a, :style_css_profile, + :style_css_profile_errors, :style_css_webapp, :style_css_webapp_errors, + :style_wide_media, + :publish_in, :unpublish_in, :unpublish_delete, :boost_every, :boost_jitter, + :boost_random, + :filter_to_unknown, :filter_from_unknown, :unpublish_on_delete, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code, :sign_in_token_attempt @@ -149,7 +157,7 @@ class User < ApplicationRecord if new_user && approved? prepare_new_user! - elsif new_user + elsif new_user && user_might_not_be_a_spam_bot notify_staff_about_pending_account! end end @@ -307,6 +315,17 @@ class User < ApplicationRecord super end + def send_confirmation_instructions + unless approved? || user_might_not_be_a_spam_bot + invite_request&.destroy + account&.destroy + destroy + return false + end + + super + end + def reset_password!(new_password, new_password_confirmation) return false if encrypted_password.blank? @@ -433,4 +452,17 @@ class User < ApplicationRecord def validate_email_dns? email_changed? && !(Rails.env.test? || Rails.env.development?) end + + def user_might_not_be_a_spam_bot + username == account.username && invite_request&.text.present? && kobold_hash_matches? + end + + def kobold_hash_matches? + kobold.present? && kobold == kobold_hash + end + + def kobold_hash + value = [account.username, username.downcase, email, invite_request.text].compact.map(&:downcase).join("\u{F0666}") + Digest::SHA512.hexdigest(value).upcase + end end diff --git a/app/policies/account_domain_permission_policy.rb b/app/policies/account_domain_permission_policy.rb new file mode 100644 index 000000000..b50857f9f --- /dev/null +++ b/app/policies/account_domain_permission_policy.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AccountDomainPermissionPolicy < ApplicationPolicy + def update? + owned? + end + + def destroy? + owned? + end + + private + + def owned? + record.account_id == current_account&.id + end +end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index fa5c0dd9c..9f851feb3 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -12,19 +12,20 @@ class StatusPolicy < ApplicationPolicy end def show? - return false if local_only? && (current_account.nil? || !current_account.local?) + return false if local_only? && !current_account&.local? + return false unless published? || owned? if requires_mention? owned? || mention_exists? elsif private? - owned? || following_author? || mention_exists? + owned? || following_owners? || mention_exists? else - current_account.nil? || (!author_blocking? && !author_blocking_domain?) + current_account.nil? || !blocked_by_owners? end end def reblog? - !requires_mention? && (!private? || owned?) && show? && !blocking_author? + published? && !requires_mention? && (!private? || owned?) && show? && !blocking_author? end def favourite? @@ -44,7 +45,7 @@ class StatusPolicy < ApplicationPolicy private def requires_mention? - record.direct_visibility? || record.limited_visibility? + %w(direct limited).include?(visibility_for_remote_domain) end def owned? @@ -52,7 +53,7 @@ class StatusPolicy < ApplicationPolicy end def private? - record.private_visibility? + visibility_for_remote_domain == 'private' end def mention_exists? @@ -71,6 +72,12 @@ class StatusPolicy < ApplicationPolicy author.domain_blocking?(current_account.domain) end + def conversation_author_blocking_domain? + return false if current_account.nil? || current_account.domain.nil? || conversation_owner.nil? + + conversation_owner.domain_blocking?(current_account.domain) + end + def blocking_author? return false if current_account.nil? @@ -78,22 +85,63 @@ class StatusPolicy < ApplicationPolicy end def author_blocking? - return false if current_account.nil? + return author.require_auth? if current_account.nil? @preloaded_relations[:blocked_by] ? @preloaded_relations[:blocked_by][author.id] : author.blocking?(current_account) end + def conversation_author_blocking? + return false if conversation_owner.nil? + + @preloaded_relations[:blocked_by] ? @preloaded_relations[:blocked_by][conversation_owner.id] : conversation_owner.blocking?(current_account) + end + + def blocked_by_owners? + return author_blocking? || author_blocking_domain? if conversation_owner&.id == author.id + return true if conversation_author_blocking? || author_blocking? + + conversation_author_blocking_domain? || author_blocking_domain? + end + def following_author? return false if current_account.nil? @preloaded_relations[:following] ? @preloaded_relations[:following][author.id] : current_account.following?(author) end + def following_conversation_owner? + return false if current_account.nil? || conversation_owner.nil? + + @preloaded_relations[:following] ? @preloaded_relations[:following][conversation_owner.id] : current_account.following?(conversation_owner) + end + + def following_owners? + return following_author? if conversation_owner&.id == author.id + + following_conversation_owner? && following_author? + end + def author - record.account + @author ||= record.account end - + + def conversation_owner + @conversation_owner ||= record.conversation&.account + end + def local_only? record.local_only? end + + def published? + record.published? + end + + def reply? + record.reply? && record.in_reply_to_account_id != author.id + end + + def visibility_for_remote_domain + @visibility_for_domain ||= record.visibility_for_domain(current_account&.domain) + end end diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb index 5d174767f..7a19cc96a 100644 --- a/app/presenters/activitypub/activity_presenter.rb +++ b/app/presenters/activitypub/activity_presenter.rb @@ -4,24 +4,30 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model attributes :id, :type, :actor, :published, :to, :cc, :virtual_object class << self - def from_status(status) + def from_status(status, update: false, embed: true) new.tap do |presenter| + default_activity = update && status.edited.positive? ? 'Update' : 'Create' presenter.id = ActivityPub::TagManager.instance.activity_uri_for(status) - presenter.type = status.reblog? ? 'Announce' : 'Create' + presenter.type = (status.reblog? && status.spoiler_text.blank? ? 'Announce' : default_activity) presenter.actor = ActivityPub::TagManager.instance.uri_for(status.account) presenter.published = status.created_at presenter.to = ActivityPub::TagManager.instance.to(status) presenter.cc = ActivityPub::TagManager.instance.cc(status) + unless embed || !status.account.require_dereference + presenter.virtual_object = ActivityPub::TagManager.instance.uri_for(status.proper) + next + end + presenter.virtual_object = begin - if status.reblog? + if status.reblog? && status.spoiler_text.blank? if status.account == status.proper.account && status.proper.private_visibility? && status.local? status.proper else ActivityPub::TagManager.instance.uri_for(status.proper) end else - status.proper + status end end end diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 3cc905a75..260ea48fe 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -4,6 +4,8 @@ class StatusRelationshipsPresenter attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, :bookmarks_map + attr_reader :hidden_conversations_map, :hidden_statuses_map + def initialize(statuses, current_account_id = nil, **options) if current_account_id.nil? @reblogs_map = {} @@ -11,6 +13,9 @@ class StatusRelationshipsPresenter @bookmarks_map = {} @mutes_map = {} @pins_map = {} + + @hidden_conversations_map = {} + @hidden_statuses_map = {} else statuses = statuses.compact status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact @@ -22,6 +27,9 @@ class StatusRelationshipsPresenter @bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {}) @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) + + @hidden_conversations_map = Status.hidden_conversations_map(conversation_ids, current_account_id).merge(options[:hidden_conversations_map] || {}) + @hidden_statuses_map = Status.hidden_statuses_map(status_ids, current_account_id).merge(options[:hidden_statuses_map] || {}) end end end diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 5d2741b17..a56626532 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -24,6 +24,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer attribute :moved_to, if: :moved? attribute :also_known_as, if: :also_known_as? + context_extensions :require_dereference, :show_replies, :private, :require_auth, :metadata, :server_metadata + attributes :require_dereference, :show_replies, :show_unlisted, :private, :require_auth + attributes :metadata, :server_metadata + class EndpointsSerializer < ActivityPub::Serializer include RoutingHelper @@ -137,6 +141,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer object.fields + object.identity_proofs.active end + def metadata + object.metadata.cached_fields_json + end + + def server_metadata + Mastodon::Version.server_metadata_json + end + def moved_to ActivityPub::TagManager.instance.uri_for(object.moved_to_account) end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index a0965790e..b973f69ec 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -3,16 +3,25 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message + context_extensions :edited, :server_metadata, :root, :reblog, :expires + attributes :id, :type, :summary, :in_reply_to, :published, :url, :attributed_to, :to, :cc, :sensitive, :atom_uri, :in_reply_to_atom_uri, :conversation + attributes :updated, :root + attribute :title, key: :name, if: :title_present? + attribute :reblog, if: :reblog_present? + attribute :renote, key: '_misskey_quote', if: :reblog_present? + attribute :expires_at, key: :expires, if: :expires_at_present? + attribute :content attribute :content_map, if: :language? attribute :direct_message, if: :non_public? + attribute :server_metadata has_many :media_attachments, key: :attachment has_many :virtual_tags, key: :tag @@ -29,14 +38,28 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer def id raise Mastodon::NotPermittedError, 'Local-only statuses should not be serialized' if object.local_only? && !instance_options[:allow_local_only] + raise Mastodon::NotPermittedError, 'Unpublished statuses should not be serialized' unless object.published? || instance_options[:allow_local_only] + ActivityPub::TagManager.instance.uri_for(object) end def type - object.preloadable_poll ? 'Question' : 'Note' + if object.preloadable_poll + 'Question' + elsif title_present? + 'Article' + else + 'Note' + end + end + + def root + object.conversation&.root end def summary + return Formatter.instance.format(object, plaintext: true) || Setting.outgoing_spoilers.presence if title_present? + object.spoiler_text.presence || (instance_options[:allow_local_only] ? nil : Setting.outgoing_spoilers.presence) end @@ -53,11 +76,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end def content - Formatter.instance.format(object) + Formatter.instance.format(object, article_content: true) end def content_map - { object.language => Formatter.instance.format(object) } + { object.language => Formatter.instance.format(object, article_content: true) } end def replies @@ -94,6 +117,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer object.created_at.iso8601 end + def updated + object.updated_at.iso8601 + end + def url ActivityPub::TagManager.instance.url_for(object) end @@ -103,11 +130,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end def to - ActivityPub::TagManager.instance.to(object) + ActivityPub::TagManager.instance.to(object, target_domain: instance_options[:target_domain]) end def cc - ActivityPub::TagManager.instance.cc(object) + ActivityPub::TagManager.instance.cc(object, target_domain: instance_options[:target_domain]) end def virtual_tags @@ -174,6 +201,32 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer object.preloadable_poll&.voters_count end + def title_present? + return @has_title if defined?(@has_title) + + @has_title = object.title.present? + end + + def server_metadata + Mastodon::Version.server_metadata_json + end + + def reblog + ActivityPub::TagManager.instance.uri_for(object.reblog) + end + + def renote + ActivityPub::TagManager.instance.uri_for(object.reblog) + end + + def reblog_present? + object.reblog_of_id.present? + end + + def expires_at_present? + object.expires_at.present? + end + class MediaAttachmentSerializer < ActivityPub::Serializer context_extensions :blurhash, :focal_point diff --git a/app/serializers/activitypub/outbox_serializer.rb b/app/serializers/activitypub/outbox_serializer.rb index 4f4f950a5..2692a1c42 100644 --- a/app/serializers/activitypub/outbox_serializer.rb +++ b/app/serializers/activitypub/outbox_serializer.rb @@ -10,6 +10,6 @@ class ActivityPub::OutboxSerializer < ActivityPub::CollectionSerializer end def items - object.items.map { |status| ActivityPub::ActivityPresenter.from_status(status) } + object.items.map { |status| ActivityPub::ActivityPresenter.from_status(status, embed: false) } end end diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb index a925efc18..a464517ca 100644 --- a/app/serializers/activitypub/undo_announce_serializer.rb +++ b/app/serializers/activitypub/undo_announce_serializer.rb @@ -22,6 +22,6 @@ class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer end def virtual_object - ActivityPub::ActivityPresenter.from_status(object) + ActivityPub::ActivityPresenter.from_status(object, embed: false) end end diff --git a/app/serializers/nodeinfo/serializer.rb b/app/serializers/nodeinfo/serializer.rb index 7ff8aabec..2bd2c772f 100644 --- a/app/serializers/nodeinfo/serializer.rb +++ b/app/serializers/nodeinfo/serializer.rb @@ -3,7 +3,7 @@ class NodeInfo::Serializer < ActiveModel::Serializer include RoutingHelper - attributes :version, :software, :protocols, :usage, :open_registrations + attributes :version, :software, :protocols, :usage, :open_registrations, :metadata def version '2.0' @@ -37,9 +37,26 @@ class NodeInfo::Serializer < ActiveModel::Serializer Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode end + def metadata + { + domain_allows: display_allows? ? DomainAllow.where(hidden: false).map { |a| a.slice(:domain) } : [], + domain_blocks: display_blocks? ? DomainBlock.all.map { |b| b.slice(:domain, :severity, :reject_media, :reject_reports, :public_comment) } : [], + } + end + private def instance_presenter @instance_presenter ||= InstancePresenter.new end + + # Monsterfork additions + + def display_allows? + Setting.show_domain_allows == 'all' || (Setting.show_domain_allows == 'users' && user_signed_in?) + end + + def display_blocks? + Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?) + end end diff --git a/app/serializers/rest/account_domain_permission_serializer.rb b/app/serializers/rest/account_domain_permission_serializer.rb new file mode 100644 index 000000000..8bfbe1473 --- /dev/null +++ b/app/serializers/rest/account_domain_permission_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::AccountDomainPermissionSerializer < ActiveModel::Serializer + attributes :id, :domain, :visibility + + def id + object.id.to_s + end +end diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 4e497cdbd..e425c34a0 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -7,6 +7,8 @@ class REST::AccountSerializer < ActiveModel::Serializer :note, :url, :avatar, :avatar_static, :header, :header_static, :followers_count, :following_count, :statuses_count, :last_status_at + attributes :require_dereference, :show_replies, :show_unlisted + has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested? has_many :emojis, serializer: REST::CustomEmojiSerializer diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 54e7c450c..f20d9ef2b 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -5,7 +5,8 @@ class REST::InstanceSerializer < ActiveModel::Serializer attributes :uri, :title, :short_description, :description, :email, :version, :urls, :stats, :thumbnail, :max_toot_chars, :poll_limits, - :languages, :registrations, :approval_required, :invites_enabled + :languages, :registrations, :approval_required, :invites_enabled, + :federation has_one :contact_account, serializer: REST::AccountSerializer @@ -80,9 +81,26 @@ class REST::InstanceSerializer < ActiveModel::Serializer Setting.min_invite_role == 'user' end + def federation + { + domain_allows: display_allows? ? DomainAllow.where(hidden: false).map { |a| a.slice(:domain) } : [], + domain_blocks: display_blocks? ? DomainBlock.all.map { |b| b.slice(:domain, :severity, :reject_media, :reject_reports, :public_comment) } : [], + } + end + private def instance_presenter @instance_presenter ||= InstancePresenter.new end + + # Monsterfork additions + + def display_allows? + Setting.show_domain_allows == 'all' || (Setting.show_domain_allows == 'users' && user_signed_in?) + end + + def display_blocks? + Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?) + end end diff --git a/app/serializers/rest/mute_serializer.rb b/app/serializers/rest/mute_serializer.rb index 043a2f059..db33f8574 100644 --- a/app/serializers/rest/mute_serializer.rb +++ b/app/serializers/rest/mute_serializer.rb @@ -2,8 +2,8 @@ class REST::MuteSerializer < ActiveModel::Serializer include RoutingHelper - - attributes :id, :account, :target_account, :created_at, :hide_notifications + + attributes :id, :account, :target_account, :created_at, :hide_notifications, :timelines_only def account REST::AccountSerializer.new(object.account) @@ -12,4 +12,4 @@ class REST::MuteSerializer < ActiveModel::Serializer def target_account REST::AccountSerializer.new(object.target_account) end -end \ No newline at end of file +end diff --git a/app/serializers/rest/preferences_serializer.rb b/app/serializers/rest/preferences_serializer.rb index 119f0e06d..5220aa034 100644 --- a/app/serializers/rest/preferences_serializer.rb +++ b/app/serializers/rest/preferences_serializer.rb @@ -8,6 +8,8 @@ class REST::PreferencesSerializer < ActiveModel::Serializer attribute :reading_default_sensitive_media, key: 'reading:expand:media' attribute :reading_default_sensitive_text, key: 'reading:expand:spoilers' + attribute :posting_default_manual_publish, key: 'posting:default:manual_publish' + def posting_default_privacy object.user.setting_default_privacy end @@ -27,4 +29,8 @@ class REST::PreferencesSerializer < ActiveModel::Serializer def reading_default_sensitive_text object.user.setting_expand_spoilers end + + def posting_default_manual_publish + object.user.setting_manual_publish + end end diff --git a/app/serializers/rest/status_domain_permission_serializer.rb b/app/serializers/rest/status_domain_permission_serializer.rb new file mode 100644 index 000000000..ecdecdd3b --- /dev/null +++ b/app/serializers/rest/status_domain_permission_serializer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class REST::StatusDomainPermissionSerializer < ActiveModel::Serializer + attributes :id, :domain, :visibility + has_one :status + + def id + object.id.to_s + end +end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 58e7bd4e4..c172a37af 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -6,6 +6,9 @@ class REST::StatusSerializer < ActiveModel::Serializer :uri, :url, :replies_count, :reblogs_count, :favourites_count + # Monsterfork additions + attributes :updated_at, :edited, :nest_level, :root + attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? attribute :muted, if: :current_user? @@ -13,22 +16,33 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :pinned, if: :pinnable? attribute :local_only if :local? - attribute :content, unless: :source_requested? + attribute :content attribute :text, if: :source_requested? attribute :content_type, if: :source_requested? + attribute :published if :local? + attribute :hidden, if: :current_user? + attribute :conversation_hidden, if: :current_user? + attribute :notify, if: :locally_owned? + attribute :title?, key: :article + attribute :article_content, if: :title? + attribute :publish_at, if: :locally_owned? + attribute :expires_at, if: :locally_owned? + belongs_to :reblog, serializer: REST::StatusSerializer belongs_to :application, if: :show_application? belongs_to :account, serializer: REST::AccountSerializer has_many :media_attachments, serializer: REST::MediaAttachmentSerializer has_many :ordered_mentions, key: :mentions - has_many :tags + has_many :ordered_tags, key: :tags has_many :emojis, serializer: REST::CustomEmojiSerializer has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer + has_many :domain_permissions, serializer: REST::StatusDomainPermissionSerializer, if: :locally_owned? + def id object.id.to_s end @@ -45,8 +59,22 @@ class REST::StatusSerializer < ActiveModel::Serializer !current_user.nil? end + def owned? + current_user? && current_user.account_id == object.account_id + end + + def locally_owned? + object.local? && owned? + end + + def title? + return @has_title if defined?(@has_title) + + @has_title = object.title.present? + end + def show_application? - object.account.user_shows_application? || (current_user? && current_user.account_id == object.account_id) + object.account.user_shows_application? || owned? end def visibility @@ -64,14 +92,30 @@ class REST::StatusSerializer < ActiveModel::Serializer ActivityPub::TagManager.instance.uri_for(object) end + def spoiler_text + title? ? object.title : object.spoiler_text + end + def content Formatter.instance.format(object) end + def article_content + Formatter.instance.format(object, article_content: true) + end + + def text + object.original_text.presence || object.text + end + def url ActivityPub::TagManager.instance.url_for(object) end + def root + object.conversation&.root + end + def favourited if instance_options && instance_options[:relationships] instance_options[:relationships].favourites_map[object.id] || false @@ -96,6 +140,22 @@ class REST::StatusSerializer < ActiveModel::Serializer end end + def conversation_hidden + if instance_options && instance_options[:relationships] + instance_options[:relationships].hidden_conversations_map[object.conversation_id] || false + else + current_user.account.hiding_conversation?(object.conversation) + end + end + + def hidden + if instance_options && instance_options[:relationships] + instance_options[:relationships].hidden_statuses_map[object.id] || false + else + current_user.account.muting_status?(object) + end + end + def bookmarked if instance_options && instance_options[:relationships] instance_options[:relationships].bookmarks_map[object.id] || false @@ -127,6 +187,10 @@ class REST::StatusSerializer < ActiveModel::Serializer object.active_mentions.to_a.sort_by(&:id) end + def ordered_tags + object.tags.order('name') + end + class ApplicationSerializer < ActiveModel::Serializer attributes :name, :website end diff --git a/app/services/activitypub/fetch_collection_items_service.rb b/app/services/activitypub/fetch_collection_items_service.rb new file mode 100644 index 000000000..ef54321de --- /dev/null +++ b/app/services/activitypub/fetch_collection_items_service.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +class ActivityPub::FetchCollectionItemsService < BaseService + include JsonLdHelper + + COOLDOWN = 30.minutes + + # Fetches objects in a collection from a URI or hash and queues them for processing. + # @param collection [Hash, String] Collection hash or URI + # @param account [Account] Owner of the collection + # @param page_limit [Integer] (10) Maximum number of pages to fetch from the collection. + # @param item_limit [Integer] (100) Maximum number of items to fetch from the collection. + # @option options [Boolean] :every_page (false) Whether to fetch every page in the collection, + # even if its items have been previously fetched. By default, fetching will stop if all the + # items on any page have already been fetched. + # @option options [Boolean] :look_ahead (false) Whether to check the next page for unfetched + # items if the current page's items have been previously fetched. If there are unfetched + # items on the next page, fetching will continue. + # @option options [Boolean] :skip_cooldown (false) Skip the fetch cooldown period on the a + # collection URI (e.g., for account migration). + # @option options [Boolean] :include_boosts (false) Whether to skip boosts. Including these + # will cause a LOT of server traffic. + # @return [void] + # @raise [Mastodon::RaceConditionError] Collection is already being fetched. + # @raise [Mastodon::UnexpectedResponseError] Server returned an error while fetching a page. + def call(collection, account, page_limit: 10, item_limit: 100, **options) + uri = value_or_id(collection) + return if uri.blank? || ActivityPub::TagManager.instance.local_uri?(uri) + + uri = collection['partOf'] if collection.is_a?(Hash) && collection['partOf'].present? + + @account = account + @account = account_from_uri(uri) if @account.blank? + set_fetch_account + + return if !options[:skip_cooldown] && Redis.current.get("fetch_collection_cooldown:#{uri}") + + collection = fetch_collection(collection) + return if collection.blank? + + if @account.blank? + @account = account_from_uri(collection['partOf'].presence || collection['id']) + set_fetch_account + end + + fetch_collection_pages(collection, page_limit, item_limit, **options) + end + + private + + def lock_options(uri) + { redis: Redis.current, key: "fetch_collection:#{uri}" } + end + + def set_fetch_account + @on_behalf_of = @account.present? ? @account.followers.local.random.first : nil + end + + def account_from_uri(uri) + ActivityPub::TagManager.instance.uri_to_resource(uri, Account) + end + + def account_id_from_uri(uri) + return if uri.blank? + + Rails.cache.fetch("account_id_from_uri:#{uri}", expires_in: 10.minutes) do + account_from_uri(uri)&.id + end + end + + def valid_item?(item) + item.is_a?(Hash) && + !invalid_uri?(item['id']) && + (item['attributedTo'].present? || item['actor'].present?) && ( + item['object'].blank? || item['type'] == 'Create' && !invalid_uri?(value_or_id(item['object'])) + ) + end + + def uri_with_account_id(item) + object = item['object'].presence || item + [value_or_id(object), object.is_a?(Hash) ? account_id_from_uri(object['attributedTo']) : account_id_from_uri(item['actor'])] + end + + def invalid_uri?(uri) + unsupported_uri_scheme?(uri) || !uri_allowed?(uri) || ActivityPub::TagManager.instance.local_uri?(uri) + end + + def fetch_collection(collection_or_uri) + return (collection_or_uri['id'].present? ? collection_or_uri : nil) if collection_or_uri.is_a?(Hash) + return if !collection_or_uri.is_a?(String) || invalid_origin?(collection_or_uri) + + fetch_resource_without_id_validation(collection_or_uri, @on_behalf_of, true) + end + + def fetch_collection_pages(collection, page_limit, item_limit, **options) + uri = collection['partOf'].presence || collection['id'] + cooldown_key = "fetch_collection_cooldown:#{uri}" + + return if !options[:skip_cooldown] && Redis.current.get(cooldown_key) + + Redis.current.set(cooldown_key, 1, ex: COOLDOWN) + + RedisLock.acquire(lock_options(uri)) do |lock| + raise Mastodon::RaceConditionError unless lock.acquired? + + page = CollectionPage.find_or_create_by(uri: uri, account: @account) + every_page = options[:every_page] + + if page.next.present? + collection = fetch_collection(page.next) + fetch_collection_items(collection, page, page_limit, item_limit, **options) + every_page = false + end + + uri = collection['first'].presence || collection['id'] + page.update!(next: uri) + collection = fetch_collection(uri) if collection['id'] != uri + fetch_collection_items(collection, page, page_limit, item_limit, **options.merge({ every_page: every_page })) + end + end + + def fetch_collection_items(collection, page, page_limit, item_limit, **options) + page_count = 0 + item_count = 0 + seen_pages = Set[page.next] + have_items = false + + while collection.present? && collection['type'].present? + batch = case collection['type'] + when 'Collection', 'CollectionPage' + collection['items'] + when 'OrderedCollection', 'OrderedCollectionPage' + collection['orderedItems'] + end + + break unless batch.is_a?(Array) + + batch_size = [batch.count, item_limit - item_count].min + batch = batch.take(batch_size).select { |item| valid_item?(item) }.map { |item| uri_with_account_id(item) } + result = CollectionItem.import([:uri, :account_id], batch, validate: false, on_duplicate_key_ignore: true) + + if !options[:every_page] && result.ids.blank? + break if have_items || !options[:look_ahead] + + have_items = true + elsif have_items + have_items = false + end + + item_count += result.ids.count + page_count += 1 + + next_page = collection['next'] + break unless item_count < item_limit && page_count < page_limit && next_page.present? + break if seen_pages.include?(next_page) + + sleep [page_count.to_f / 5, 1].min + + seen_pages << next_page + page.update!(next: next_page) + collection = fetch_collection(next_page) + end + + page.delete + ActivityPub::ProcessCollectionItemsWorker.perform_async + end +end diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb index 2c2770466..0a20f5edc 100644 --- a/app/services/activitypub/fetch_featured_collection_service.rb +++ b/app/services/activitypub/fetch_featured_collection_service.rb @@ -22,9 +22,10 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService private def process_items(items) + first_local_follower = @account.followers.local.random.first status_ids = items.map { |item| value_or_id(item) } .reject { |uri| ActivityPub::TagManager.instance.local_uri?(uri) } - .map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri) } + .map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: first_local_follower) } .compact .select { |status| status.account_id == @account.id } .map(&:id) @@ -43,7 +44,7 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService StatusPin.where(account: @account, status_id: to_remove).delete_all unless to_remove.empty? to_add.each do |status_id| - StatusPin.create!(account: @account, status_id: status_id) + StatusPin.create(account: @account, status_id: status_id) end end diff --git a/app/services/activitypub/fetch_replies_service.rb b/app/services/activitypub/fetch_replies_service.rb index 8cb309e52..e113e4937 100644 --- a/app/services/activitypub/fetch_replies_service.rb +++ b/app/services/activitypub/fetch_replies_service.rb @@ -1,49 +1,32 @@ # frozen_string_literal: true class ActivityPub::FetchRepliesService < BaseService - include JsonLdHelper - - def call(parent_status, collection_or_uri, allow_synchronous_requests = true) + def call(parent_status, collection, **options) @account = parent_status.account - @allow_synchronous_requests = allow_synchronous_requests - - @items = collection_items(collection_or_uri) - return if @items.nil? + return if @account.suspended? - FetchReplyWorker.push_bulk(filtered_replies) + fetch_collection_items(collection, **options) + return if (collection.is_a?(String) && collection == @account.outbox_url) || @account.local? || @account.silenced? || @account.passive_relationships.exists? || !@account.active_relationships.exists? - @items + fetch_collection_items(@account.outbox_url, **options) + rescue ActiveRecord::RecordNotFound + nil end private - def collection_items(collection_or_uri) - collection = fetch_collection(collection_or_uri) - return unless collection.is_a?(Hash) - - collection = fetch_collection(collection['first']) if collection['first'].present? - return unless collection.is_a?(Hash) - - case collection['type'] - when 'Collection', 'CollectionPage' - collection['items'] - when 'OrderedCollection', 'OrderedCollectionPage' - collection['orderedItems'] - end - end - - def fetch_collection(collection_or_uri) - return collection_or_uri if collection_or_uri.is_a?(Hash) - return unless @allow_synchronous_requests - return if invalid_origin?(collection_or_uri) - fetch_resource_without_id_validation(collection_or_uri, nil, true) - end - - def filtered_replies - # Only fetch replies to the same server as the original status to avoid - # amplification attacks. - - # Also limit to 5 fetched replies to limit potential for DoS. - @items.map { |item| value_or_id(item) }.reject { |uri| invalid_origin?(uri) }.take(5) + def fetch_collection_items(collection, **options) + ActivityPub::FetchCollectionItemsService.new.call( + collection, + @account, + page_limit: 1, + item_limit: 20, + **options + ) + rescue Mastodon::RaceConditionError, Mastodon::UnexpectedResponseError + collection_uri = collection.is_a?(Hash) ? collection['id'] : collection + return unless collection_uri.present? && collection_uri.is_a?(String) + + ActivityPub::FetchRepliesWorker.perform_async(@account.id, collection_uri) end end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 85b915ec6..7f17e460c 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -35,12 +35,13 @@ class ActivityPub::ProcessAccountService < BaseService return if @account.nil? after_protocol_change! if protocol_changed? - after_key_change! if key_changed? && !@options[:signed_with_known_key] clear_tombstones! if key_changed? + return after_key_change! if key_changed? && !@options[:signed_with_known_key] unless @options[:only_key] check_featured_collection! if @account.featured_collection_url.present? check_links! unless @account.fields.empty? + process_sync end @account @@ -86,6 +87,11 @@ class ActivityPub::ProcessAccountService < BaseService @account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) } @account.actor_type = actor_type @account.discoverable = @json['discoverable'] || false + @account.require_dereference = @json['requireDereference'] || false + @account.show_replies = @json['showReplies'] || true + @account.show_unlisted = @json['showUnlisted'] || true + @account.private = @json['private'] || false + @account.require_auth = @json['require_auth'] || false end def set_fetchable_attributes! @@ -104,7 +110,8 @@ class ActivityPub::ProcessAccountService < BaseService end def after_key_change! - RefollowWorker.perform_async(@account.id) + ResetAccountWorker.perform_async(@account.id) + nil end def check_featured_collection! @@ -288,4 +295,8 @@ class ActivityPub::ProcessAccountService < BaseService @account.identity_proofs.where(provider: provider, provider_username: provider_username).find_or_create_by(provider: provider, provider_username: provider_username, token: token) end + + def process_sync + ActivityPub::SyncAccountWorker.perform_async(@account.id) + end end diff --git a/app/services/activitypub/process_collection_items_service.rb b/app/services/activitypub/process_collection_items_service.rb new file mode 100644 index 000000000..9c30d81e9 --- /dev/null +++ b/app/services/activitypub/process_collection_items_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessCollectionItemsService < BaseService + def call(account_id, on_behalf_of) + RedisLock.acquire(lock_options(account_id)) do |lock| + if lock.acquired? + CollectionItem.unprocessed.where(account_id: account_id).find_each do |item| + # Avoid failing servers holding up the rest of the queue. + next if item.retries.positive? && rand(3).positive? + + begin + FetchRemoteStatusService.new.call(item.uri, nil, on_behalf_of) + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound + nil + rescue HTTP::TimeoutError + item.increment!(:retries) + end + + item.update!(processed: true) if item.retries.zero? || item.retries > 4 + end + end + end + end + + private + + def lock_options(account_id) + { redis: Redis.current, key: "process_collection_items:#{account_id}" } + end +end diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb index 2a0e10a79..432ba65e6 100644 --- a/app/services/after_block_service.rb +++ b/app/services/after_block_service.rb @@ -8,6 +8,8 @@ class AfterBlockService < BaseService clear_home_feed! clear_notifications! clear_conversations! + unlink_replies! + unlink_mentions! end private @@ -23,4 +25,16 @@ class AfterBlockService < BaseService def clear_notifications! Notification.where(account: @account).where(from_account: @target_account).in_batches.delete_all end + + def unlink_replies! + @target_account.statuses.where(in_reply_to_account_id: @account.id) + .or(@account.statuses.where(in_reply_to_account_id: @target_account.id)) + .in_batches.update_all(in_reply_to_account_id: nil) + end + + def unlink_mentions! + @account.mentions.where(account_id: @target_account.id) + .or(@target_account.mentions.where(account_id: @account.id)) + .in_batches.destroy_all + end end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index 266a0f4b9..0b8ecd3e0 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -3,16 +3,16 @@ class BlockService < BaseService include Payloadable - def call(account, target_account) + def call(account, target_account, softblock: false) return if account.id == target_account.id - UnfollowService.new.call(account, target_account) if account.following?(target_account) - UnfollowService.new.call(target_account, account) if target_account.following?(account) + UnfollowService.new.call(account, target_account, force: softblock) if softblock || account.following?(target_account) + UnfollowService.new.call(target_account, account, force: softblock) if softblock || target_account.following?(account) RejectFollowService.new.call(target_account, account) if target_account.requested?(account) block = account.block!(target_account) - BlockWorker.perform_async(account.id, target_account.id) + BlockWorker.perform_async(account.id, target_account.id) unless softblock create_notification(block) if !target_account.local? && target_account.activitypub? block end diff --git a/app/services/concerns/payloadable.rb b/app/services/concerns/payloadable.rb index 3e45570c3..ba94539c8 100644 --- a/app/services/concerns/payloadable.rb +++ b/app/services/concerns/payloadable.rb @@ -15,6 +15,6 @@ module Payloadable end def signing_enabled? - ENV['AUTHORIZED_FETCH'] != 'true' && !Rails.configuration.x.whitelist_mode + true end end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 6fa98ce12..800e4aa07 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -3,10 +3,11 @@ class FanOutOnWriteService < BaseService # Push a status into home and mentions feeds # @param [Status] status - def call(status) + def call(status, only_to_self: false) raise Mastodon::RaceConditionError if status.visibility.nil? deliver_to_self(status) if status.account.local? + return if only_to_self || !status.published? if status.direct_visibility? deliver_to_mentioned_followers(status) @@ -14,22 +15,30 @@ class FanOutOnWriteService < BaseService deliver_to_own_conversation(status) elsif status.limited_visibility? deliver_to_mentioned_followers(status) + deliver_to_lists(status) else deliver_to_followers(status) deliver_to_lists(status) end - return if status.account.silenced? || !status.public_visibility? - return if status.reblog? && !Setting.show_reblogs_in_public_timelines - - render_anonymous_payload(status) + return if status.account.silenced? + render_anonymous_payload(status.proper) deliver_to_hashtags(status) - return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines + if status.reblog? + if status.local? && status.reblog.public_visibility? && !status.reblog.account.silenced? + deliver_to_public(status.reblog) + deliver_to_media(status.reblog) if status.reblog.media_attachments.any? + end + return + end + + deliver_to_hashtags(status) if status.distributable? + return if !status.public_visibility? || (status.reply? && status.in_reply_to_account_id != status.account_id) - deliver_to_public(status) - deliver_to_media(status) if status.media_attachments.any? + deliver_to_media(status, true) if status.media_attachments.any? + deliver_to_public(status, true) end private @@ -84,10 +93,15 @@ class FanOutOnWriteService < BaseService end end - def deliver_to_public(status) + def deliver_to_public(status, tavern = false) + key = "timeline:public:#{status.id}" + return if Redis.current.get(key) + Rails.logger.debug "Delivering status #{status.id} to public timeline" - Redis.current.publish('timeline:public', @payload) + Redis.current.set(key, 1, ex: 2.hours) + + Redis.current.publish('timeline:public', @payload) if status.local? || !tavern if status.local? Redis.current.publish('timeline:public:local', @payload) else @@ -95,10 +109,13 @@ class FanOutOnWriteService < BaseService end end - def deliver_to_media(status) + def deliver_to_media(status, tavern = false) + key = "timeline:public:#{status.id}" + return if Redis.current.get(key) + Rails.logger.debug "Delivering status #{status.id} to media timeline" - Redis.current.publish('timeline:public:media', @payload) + Redis.current.publish('timeline:public:media', @payload) if status.local? || !tavern if status.local? Redis.current.publish('timeline:public:local:media', @payload) else @@ -109,7 +126,7 @@ class FanOutOnWriteService < BaseService def deliver_to_direct_timelines(status) Rails.logger.debug "Delivering status #{status.id} to direct timelines" - FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? }) do |account| + FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select(&:local?)) do |account| [status.id, account.id, :direct] end end diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb index eafde4d4a..4f98b51f6 100644 --- a/app/services/fetch_remote_status_service.rb +++ b/app/services/fetch_remote_status_service.rb @@ -1,14 +1,20 @@ # frozen_string_literal: true class FetchRemoteStatusService < BaseService - def call(url, prefetched_body = nil) + def call(url, prefetched_body = nil, on_behalf_of = nil) + status = ActivityPub::TagManager.instance.uri_to_resource(url, Status) + return status if status.present? + if prefetched_body.nil? - resource_url, resource_options = FetchResourceService.new.call(url) + resource_url, resource_options = FetchResourceService.new.call(url, on_behalf_of: on_behalf_of) else resource_url = url resource_options = { prefetched_body: prefetched_body } end - ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options) unless resource_url.nil? + return if resource_url.blank? + + resource_options ||= {} + ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options.merge({ on_behalf_of: on_behalf_of })) end end diff --git a/app/services/fetch_resource_service.rb b/app/services/fetch_resource_service.rb index 6c0093cd4..17e8024de 100644 --- a/app/services/fetch_resource_service.rb +++ b/app/services/fetch_resource_service.rb @@ -7,9 +7,11 @@ class FetchResourceService < BaseService attr_reader :response_code - def call(url) + def call(url, on_behalf_of: nil) return if url.blank? + @on_behalf_of = on_behalf_of || Account.representative + process(url) rescue HTTP::Error, OpenSSL::SSL::SSLError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e Rails.logger.debug "Error fetching resource #{@url}: #{e}" @@ -18,8 +20,9 @@ class FetchResourceService < BaseService private - def process(url, terminal = false) + def process(url, terminal = false, retry_as_server = false) @url = url + @retry_as_server ||= retry_as_server perform_request { |response| process_response(response, terminal) } end @@ -35,13 +38,14 @@ class FetchResourceService < BaseService # and prevents even public resources from being fetched, so # don't do it - request.on_behalf_of(Account.representative) unless Rails.env.development? + request.on_behalf_of(@retry_as_server ? Account.representative : @on_behalf_of) unless Rails.env.development? end.perform(&block) end def process_response(response, terminal = false) @response_code = response.code - return nil if response.code != 200 + skip_retry = @retry_as_server || Rails.env.development? || @on_behalf_of.id == -99 + return (skip_retry ? nil : process(response.uri, terminal, true)) if response.code != 200 if ['application/activity+json', 'application/ld+json'].include?(response.mime_type) body = response.body_with_limit @@ -67,13 +71,13 @@ class FetchResourceService < BaseService page = Nokogiri::HTML(response.body_with_limit) json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) } - process(json_link['href'], terminal: true) unless json_link.nil? + process(json_link['href'], true) unless json_link.nil? end def process_link_headers(link_header) json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']) - process(json_link.href, terminal: true) unless json_link.nil? + process(json_link.href, true) unless json_link.nil? end def parse_link_header(response) diff --git a/app/services/keys/query_service.rb b/app/services/keys/query_service.rb index 286fbd834..f48dafb61 100644 --- a/app/services/keys/query_service.rb +++ b/app/services/keys/query_service.rb @@ -63,7 +63,7 @@ class Keys::QueryService < BaseService json = fetch_resource(@account.devices_url) - return if json['items'].blank? + return if json.blank? || json['items'].blank? @devices = json['items'].map do |device| Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim']) diff --git a/app/services/mute_conversation_service.rb b/app/services/mute_conversation_service.rb new file mode 100644 index 000000000..46adb98dc --- /dev/null +++ b/app/services/mute_conversation_service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class MuteConversationService < BaseService + def call(account, conversation, hidden: false) + return if account.blank? || conversation.blank? + + account.mute_conversation!(conversation, hidden: hidden) + MuteConversationWorker.perform_async(account.id, conversation.id) if hidden + end +end diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 676804cb9..1a3f4981f 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class MuteService < BaseService - def call(account, target_account, notifications: nil) + def call(account, target_account, notifications: nil, timelines_only: nil) return if account.id == target_account.id - mute = account.mute!(target_account, notifications: notifications) + mute = account.mute!(target_account, notifications: notifications, timelines_only: timelines_only) if mute.hide_notifications? BlockWorker.perform_async(account.id, target_account.id) diff --git a/app/services/mute_status_service.rb b/app/services/mute_status_service.rb new file mode 100644 index 000000000..bdf99232c --- /dev/null +++ b/app/services/mute_status_service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class MuteStatusService < BaseService + def call(account, status) + return if account.blank? || status.blank? + + account.mute_status!(status) + FeedManager.instance.unpush_status(account, status) + end +end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index abd676494..65f6052bf 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -49,6 +49,11 @@ class NotifyService < BaseService @following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account) end + def following_recipient? + return @following_recipient if defined?(@following_recipient) + @following_recipient = @notification.from_account.following?(@recipient) + end + def optional_non_follower? @recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient) end @@ -81,7 +86,7 @@ class NotifyService < BaseService end def hellbanned? - @notification.from_account.silenced? && !following_sender? + @notification.from_account.silenced? && !(following_sender? || following_recipient?) end def from_self? diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 250d0e8ed..769b9aba0 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -2,6 +2,7 @@ class PostStatusService < BaseService include Redisable + include ImgProxyHelper MIN_SCHEDULE_OFFSET = 5.minutes.freeze @@ -13,6 +14,8 @@ class PostStatusService < BaseService # @option [Boolean] :sensitive # @option [String] :visibility # @option [String] :spoiler_text + # @option [String] :title + # @option [String] :footer # @option [String] :language # @option [String] :scheduled_at # @option [Hash] :poll Optional poll to attach @@ -20,12 +23,31 @@ class PostStatusService < BaseService # @option [Doorkeeper::Application] :application # @option [String] :idempotency Optional idempotency key # @option [Boolean] :with_rate_limit + # @option [Status] :status Edit an existing status + # @option [Enumerable] :mentions Optional array of Mentions to include + # @option [Enumerable] :tags Option array of tag names to include + # @option [Boolean] :publish If true, status will be published + # @option [Boolean] :notify If false, status will not be delivered to local timelines or mentions + # @option [String] :expires_at If set, automatically delete at this time (UTC) + # @option [String] :publish_at If set, automatically publish at this time (UTC) # @return [Status] def call(account, options = {}) @account = account @options = options @text = @options[:text] || '' @in_reply_to = @options[:thread] + @expires_at = @options[:expires_at]&.to_datetime + @publish_at = @options[:publish_at]&.to_datetime + + @expires_at ||= Time.now.utc + @account.user&.setting_unpublish_in.to_i.minutes if @account.user&.setting_unpublish_in.to_i.positive? + @publish_at ||= Time.now.utc + @account.user&.setting_publish_in.to_i.minutes if @account.user&.setting_publish_in.to_i.positive? + + @options[:publish] ||= !(account.user&.setting_manual_publish || @publish_at.present?) + + raise Mastodon::NotPermittedError if different_author? + + @tag_names = (@options[:tags] || []).select { |tag| tag =~ /\A(#{Tag::HASHTAG_NAME_RE})\z/i } + @mentions = @options[:mentions] || [] return idempotency_duplicate if idempotency_given? && idempotency_duplicate? @@ -34,10 +56,12 @@ class PostStatusService < BaseService if scheduled? schedule_status! + elsif @options[:status].present? && status_exists? + update_status! else process_status! postprocess_status! - bump_potential_friendship! + bump_potential_friendship! if @options[:publish] end redis.setex(idempotency_key, 3_600, @status.id) if idempotency_given? @@ -49,14 +73,14 @@ class PostStatusService < BaseService def preprocess_attributes! if @text.blank? && @options[:spoiler_text].present? - @text = '.' - if @media&.find(&:video?) || @media&.find(&:gifv?) - @text = '📹' - elsif @media&.find(&:audio?) - @text = '🎵' - elsif @media&.find(&:image?) - @text = '🖼' - end + @text = '.' + if @media&.find(&:video?) || @media&.find(&:gifv?) + @text = '📹' + elsif @media&.find(&:audio?) + @text = '🎵' + elsif @media&.find(&:image?) + @text = '🖼' + end end @sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present? @visibility = @options[:visibility] || @account.user&.setting_default_privacy @@ -75,8 +99,11 @@ class PostStatusService < BaseService @status = @account.statuses.create!(status_attributes) end - process_hashtags_service.call(@status) - process_mentions_service.call(@status) + @status.notify = @options[:notify] if @options[:notify].present? + + process_command_tags_service.call(@account, @status) + process_hashtags_service.call(@status, nil, @tag_names) + process_mentions_service.call(@status, mentions: @mentions, deliver: @options[:publish]) end def schedule_status! @@ -99,16 +126,25 @@ class PostStatusService < BaseService def postprocess_status! LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text? DistributionWorker.perform_async(@status.id) + + return unless @options[:publish] + ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.local_only? PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll end + def update_status! + tags = Tag.find_or_create_by_names(@tag_names) + @status = UpdateStatusService.new.call(@options[:status], status_attributes, @mentions, tags) + end + def validate_media! return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable) raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present? - @media = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i)) + @media = @options[:status].present? ? @account.media_attachments.where(status_id: [nil, @options[:status].id]) : @account.media_attachments.where(status_id: nil) + @media = @media.where(id: @options[:media_ids].take(4).map(&:to_i)) raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if @media.size > 1 && @media.find(&:audio_or_video?) raise Mastodon::ValidationError, I18n.t('media_attachments.validations.not_ready') if @media.any?(&:not_processed?) @@ -126,6 +162,10 @@ class PostStatusService < BaseService ProcessHashtagsService.new end + def process_command_tags_service + ProcessCommandTagsService.new + end + def scheduled? @scheduled_at.present? end @@ -156,24 +196,32 @@ class PostStatusService < BaseService def bump_potential_friendship! return if !@status.reply? || @account.id == @status.in_reply_to_account_id + ActivityTracker.increment('activity:interactions') return if @account.following?(@status.in_reply_to_account_id) + PotentialFriendshipTracker.record(@account.id, @status.in_reply_to_account_id, :reply) end def status_attributes { text: @text, + original_text: @text, media_attachments: @media || [], thread: @in_reply_to, poll_attributes: poll_attributes, sensitive: @sensitive, spoiler_text: @options[:spoiler_text] || '', + title: @options[:title], + footer: @options[:footer], visibility: @visibility, language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account), application: @options[:application], + published: @options[:publish], content_type: @options[:content_type] || @account.user&.setting_default_content_type, rate_limit: @options[:with_rate_limit], + expires_at: @expires_at, + publish_at: @publish_at, }.compact end @@ -198,6 +246,16 @@ class PostStatusService < BaseService options_hash[:scheduled_at] = nil options_hash[:idempotency] = nil options_hash[:with_rate_limit] = false + options_hash[:mention_ids] = options_hash.delete(:mentions)&.pluck(:id) + options_hash[:status_id] = options_hash.delete(:status)&.id end end + + def different_author? + @options[:status].present? && @options[:status].account_id != @account.id + end + + def status_exists? + !(@options[:status].discarded? || @options[:status].destroyed?) + end end diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb index 029c2f6e5..40cfad572 100644 --- a/app/services/precompute_feed_service.rb +++ b/app/services/precompute_feed_service.rb @@ -2,6 +2,7 @@ class PrecomputeFeedService < BaseService def call(account) + Redis.current.del("feed:home:#{account.id}") FeedManager.instance.populate_feed(account) FeedManager.instance.populate_direct_feed(account) ensure diff --git a/app/services/process_command_tags_service.rb b/app/services/process_command_tags_service.rb new file mode 100644 index 000000000..6b6d46662 --- /dev/null +++ b/app/services/process_command_tags_service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ProcessCommandTagsService < BaseService + def call(account, status, raise_if_no_output: true) + CommandTag::Processor.new(account, status).process! + raise Mastodon::LengthValidationError, 'Text commands were processed successfully.' if raise_if_no_output && status.destroyed? + + status + end +end diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb index e8e139b05..5ec5ea0c2 100644 --- a/app/services/process_hashtags_service.rb +++ b/app/services/process_hashtags_service.rb @@ -1,15 +1,19 @@ # frozen_string_literal: true class ProcessHashtagsService < BaseService - def call(status, tags = []) - tags = Extractor.extract_hashtags(status.text) if status.local? + def call(status, tags = nil, extra_tags = []) + tags ||= extra_tags | (status.local? ? Extractor.extract_hashtags(status.text) : []) records = [] + tag_ids = status.tag_ids.to_set + Tag.find_or_create_by_names(tags) do |tag| + next if tag_ids.include?(tag.id) + status.tags << tag records << tag - TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility? + TrendingTags.record_use!(tag, status.account, status.created_at) if status.distributable? end return unless status.distributable? diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index f45422970..b5134bf9c 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -7,70 +7,37 @@ class ProcessMentionsService < BaseService # local mention pointers, send Salmon notifications to mentioned # remote users # @param [Status] status - def call(status) - return unless status.local? + # @option [Enumerable] :mentions Mentions to include + # @option [Boolean] :deliver Deliver mention notifications + def call(status, mentions: [], deliver: true) + return unless status.local? && !(status.frozen? || status.destroyed?) - @status = status - mentions = [] + @status = status + @status.text, mentions = ResolveMentionsService.new.call(@status, mentions: mentions) + @status.save! - status.text = status.text.gsub(Account::MENTION_RE) do |match| - username, domain = Regexp.last_match(1).split('@') + return unless deliver - domain = begin - if TagManager.instance.local_domain?(domain) - nil - else - TagManager.instance.normalize_domain(domain) - end - end - - mentioned_account = Account.find_remote(username, domain) - - if mention_undeliverable?(mentioned_account) - begin - mentioned_account = resolve_account_service.call(Regexp.last_match(1)) - rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError - mentioned_account = nil - end - end - - next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended? - - mention = mentioned_account.mentions.new(status: status) - mentions << mention if mention.save - - "@#{mentioned_account.acct}" - end - - status.save! check_for_spam(status) + @activitypub_json = {} mentions.each { |mention| create_notification(mention) } end private - def mention_undeliverable?(mentioned_account) - mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?) - end - def create_notification(mention) mentioned_account = mention.account if mentioned_account.local? - LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name) + LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name) unless !@status.notify? || mention.silent? elsif mentioned_account.activitypub? && !@status.local_only? - ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url) + ActivityPub::DeliveryWorker.perform_async(activitypub_json(mentioned_account.domain), mention.status.account_id, mentioned_account.inbox_url) end end - def activitypub_json - return @activitypub_json if defined?(@activitypub_json) - @activitypub_json = Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account)) - end - - def resolve_account_service - ResolveAccountService.new + def activitypub_json(domain) + @activitypub_json[domain] ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status, embed: false), ActivityPub::ActivitySerializer, signer: @status.account, target_domain: domain)) end def check_for_spam(status) diff --git a/app/services/publish_status_service.rb b/app/services/publish_status_service.rb new file mode 100644 index 000000000..e95c3dacd --- /dev/null +++ b/app/services/publish_status_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +class PublishStatusService < BaseService + include Redisable + + def call(status) + return if status.published? + + @status = status + + update_status! + reset_status_caches + distribute + bump_potential_friendship! + end + + private + + def update_status! + @status.update!(published: true, publish_at: nil, expires_at: @status.expires_at.blank? ? nil : Time.now.utc + (@status.expires_at - @status.created_at)) + ProcessMentionsService.new.call(@status) + end + + def reset_status_caches + Rails.cache.delete_matched("statuses/#{@status.id}-*") + Rails.cache.delete("statuses/#{@status.id}") + Rails.cache.delete(@status) + Rails.cache.delete_matched("format:#{@status.id}:*") + redis.zremrangebyscore("spam_check:#{@status.account.id}", @status.id, @status.id) + end + + def distribute + LinkCrawlWorker.perform_in(rand(1..30).seconds, @status.id) unless @status.spoiler_text? + DistributionWorker.perform_async(@status.id) + ActivityPub::DistributionWorker.perform_async(@status.id) if @status.local? && !@status.local_only? + end + + def bump_potential_friendship! + return if !@status.reply? || @status.account.id == @status.in_reply_to_account_id + + ActivityTracker.increment('activity:interactions') + return if @status.account.following?(@status.in_reply_to_account_id) + + PotentialFriendshipTracker.record(@status.account.id, @status.in_reply_to_account_id, :reply) + end +end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 6cecb5ac4..ddd22e379 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -28,7 +28,7 @@ class ReblogService < BaseService end end - reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit]) + reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit], sensitive: true, spoiler_text: options[:spoiler_text] || '', published: true) DistributionWorker.perform_async(reblog.id) ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only? @@ -60,6 +60,6 @@ class ReblogService < BaseService end def build_json(reblog) - Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog), ActivityPub::ActivitySerializer, signer: reblog.account)) + Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog, embed: false), ActivityPub::ActivitySerializer, signer: reblog.account, target_domain: reblog.account.domain)) end end diff --git a/app/services/remove_hashtags_service.rb b/app/services/remove_hashtags_service.rb new file mode 100644 index 000000000..6bf77a068 --- /dev/null +++ b/app/services/remove_hashtags_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RemoveHashtagsService < BaseService + def call(status, tags) + tags = status.tags.matching_name(tags) if tags.is_a?(Array) + + status.account.featured_tags.where(tag: tags).each do |featured_tag| + featured_tag.decrement(status.id) + end + + if status.distributable? + delete_payload = Oj.dump(event: :delete, payload: status.id.to_s) + tags.pluck(:name).each do |hashtag| + redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", delete_payload) + redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", delete_payload) if status.local? + end + end + + status.tags -= tags + end +end diff --git a/app/services/remove_media_attachments_service.rb b/app/services/remove_media_attachments_service.rb new file mode 100644 index 000000000..de3cd9afb --- /dev/null +++ b/app/services/remove_media_attachments_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RemoveMediaAttachmentsService < BaseService + # Remove a list of media attachments by their IDs + # @param [Enumerable] attachment_ids + def call(attachment_ids) + media_attachments = MediaAttachment.where(id: attachment_ids) + media_attachments.map(&:id).each { |id| Rails.cache.delete_matched("statuses/#{id}-*") } + media_attachments.destroy_all + end +end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index a5aafee21..57120e38f 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -15,13 +15,15 @@ class RemoveStatusService < BaseService @status = status @account = status.account @tags = status.tags.pluck(:name).to_a - @mentions = status.active_mentions.includes(:account).to_a + @mentions = status.mentions.includes(:account).to_a @reblogs = status.reblogs.includes(:account).to_a @options = options + return unless status.published? || @options[:unpublished] + RedisLock.acquire(lock_options) do |lock| if lock.acquired? - remove_from_self if status.account.local? + remove_from_self if status.account.local? && !@options[:unpublish] remove_from_followers remove_from_lists remove_from_affected @@ -30,10 +32,15 @@ class RemoveStatusService < BaseService remove_from_public remove_from_media if status.media_attachments.any? remove_from_direct if status.direct_visibility? - remove_from_spam_check - remove_media - - @status.destroy! if @options[:immediate] || !@status.reported? + remove_from_spam_check unless @options[:unpublish] + remove_media unless @options[:unpublish] + + if @options[:immediate] || !(@options[:unpublish] || @status.reported?) + @status.destroy! + else + @status.update(published: false, expires_at: nil, local_only: @status.local?) + DistributionWorker.perform_async(@status.id) if @status.local? + end else raise Mastodon::RaceConditionError end @@ -48,6 +55,7 @@ class RemoveStatusService < BaseService remove_from_remote_followers remove_from_remote_affected + remove_from_remote_shared end private @@ -107,12 +115,18 @@ class RemoveStatusService < BaseService def relay! ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| + [signed_activity_json(Addressable::URI.parse(inbox_url).host), @account.id, inbox_url] + end + end + + def remove_from_remote_shared + ActivityPub::DeliveryWorker.push_bulk(Account.remote.activitypub.where.not(shared_inbox_url: '').distinct.select(:shared_inbox_url).pluck(:shared_inbox_url)) do |inbox_url| [signed_activity_json, @account.id, inbox_url] end end def signed_activity_json - @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account)) + @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? && @status.spoiler_text.blank? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account)) end def remove_reblogs @@ -130,7 +144,7 @@ class RemoveStatusService < BaseService featured_tag.decrement(@status.id) end - return unless @status.public_visibility? + return unless @status.distributable? @tags.each do |hashtag| redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload) @@ -139,7 +153,7 @@ class RemoveStatusService < BaseService end def remove_from_public - return unless @status.public_visibility? + return unless @status.distributable? redis.publish('timeline:public', @payload) if @status.local? @@ -150,7 +164,7 @@ class RemoveStatusService < BaseService end def remove_from_media - return unless @status.public_visibility? + return unless @status.distributable? redis.publish('timeline:public:media', @payload) if @status.local? diff --git a/app/services/resolve_mentions_service.rb b/app/services/resolve_mentions_service.rb new file mode 100644 index 000000000..e51e9f1ef --- /dev/null +++ b/app/services/resolve_mentions_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class ResolveMentionsService < BaseService + # Scan text for mentions and create local mention pointers + # @param [Status] status Status to attach to mention pointers + # @option [String] :text Text containing mentions to resolve (default: use status text) + # @option [Enumerable] :mentions Additional mentions to include + # @return [Array] Array containing text with mentions resolved (String) and mention pointers (Set) + def call(status, text: nil, mentions: []) + mentions = Mention.includes(:account).where(id: mentions.pluck(:id), accounts: { suspended_at: nil }).or(status.mentions.includes(:account)) + implicit_mention_acct_ids = mentions.active.pluck(:account_id).to_set + text = status.text if text.nil? + mentions = mentions.to_set + + text.gsub(Account::MENTION_RE) do |match| + username, domain = Regexp.last_match(1).split('@') + + domain = begin + if TagManager.instance.local_domain?(domain) + nil + else + TagManager.instance.normalize_domain(domain) + end + end + + mentioned_account = Account.find_remote(username, domain) + + if mention_undeliverable?(mentioned_account) + begin + mentioned_account = resolve_account_service.call(Regexp.last_match(1)) + rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError + mentioned_account = nil + end + end + + next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended? + + mention = mentioned_account.mentions.where(status: status).first_or_create(status: status, silent: false) + mention.update(silent: false) if mention.silent? + + mentions << mention + implicit_mention_acct_ids.delete(mentioned_account.id) + + "@#{mentioned_account.acct}" + end + + Mention.where(id: implicit_mention_acct_ids).update_all(silent: true) + + [text, mentions] + end + + private + + def mention_undeliverable?(mentioned_account) + mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?) + end + + def resolve_account_service + ResolveAccountService.new + end +end diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index 78080d878..bac41f961 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -23,7 +23,7 @@ class ResolveURLService < BaseService if equals_or_includes_any?(type, ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) ActivityPub::FetchRemoteAccountService.new.call(resource_url, prefetched_body: body) elsif equals_or_includes_any?(type, ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES) - status = FetchRemoteStatusService.new.call(resource_url, body) + status = FetchRemoteStatusService.new.call(resource_url, body, @on_behalf_of) authorize_with @on_behalf_of, status, :show? unless status.nil? status end @@ -42,7 +42,7 @@ class ResolveURLService < BaseService end def fetched_resource - @fetched_resource ||= fetch_resource_service.call(@url) + @fetched_resource ||= fetch_resource_service.call(@url, on_behalf_of: @on_behalf_of) end def fetch_resource_service diff --git a/app/services/revoke_status_service.rb b/app/services/revoke_status_service.rb new file mode 100644 index 000000000..95810acd2 --- /dev/null +++ b/app/services/revoke_status_service.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +class RevokeStatusService < BaseService + include Redisable + include Payloadable + + # Unpublish a status from a given set of local accounts' timelines and public, if visibility changed. + # @param [Status] status + # @param [Enumerable] account_ids + def call(status, account_ids) + @payload = Oj.dump(event: :delete, payload: status.id.to_s) + @status = status + @account = status.account + @account_ids = account_ids + @mentions = status.mentions.where(account_id: account_ids) + @reblogs = status.reblogs.where(account_id: account_ids) + + RedisLock.acquire(lock_options) do |lock| + if lock.acquired? + remove_from_followers + remove_from_lists + remove_from_affected + remove_reblogs + remove_from_hashtags + remove_from_public + remove_from_media + remove_from_direct if status.direct_visibility? + else + raise Mastodon::RaceConditionError + end + end + end + + private + + def remove_from_followers + @account.followers_for_local_distribution.where(id: @account_ids).reorder(nil).find_each do |follower| + FeedManager.instance.unpush_from_home(follower, @status) + end + end + + def remove_from_lists + @account.lists_for_local_distribution.where(account_id: @account_ids).select(:id, :account_id).reorder(nil).find_each do |list| + FeedManager.instance.unpush_from_list(list, @status) + end + end + + def remove_from_affected + @mentions.map(&:account).select(&:local?).each do |account| + redis.publish("timeline:#{account.id}", @payload) + end + end + + def remove_reblogs + @reblogs.each do |reblog| + RemoveStatusService.new.call(reblog) + end + end + + def remove_from_hashtags + @account.featured_tags.where(tag_id: @status.tags.pluck(:id)).each do |featured_tag| + featured_tag.decrement(@status.id) + end + + return unless @status.distributable? + + @tags.each do |hashtag| + redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload) + redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local? + end + end + + def remove_from_public + return if @status.distributable? + + redis.publish('timeline:public', @payload) + if @status.local? + redis.publish('timeline:public:local', @payload) + else + redis.publish('timeline:public:remote', @payload) + end + end + + def remove_from_media + return if @status.distributable? + + redis.publish('timeline:public:media', @payload) + if @status.local? + redis.publish('timeline:public:local:media', @payload) + else + redis.publish('timeline:public:remote:media', @payload) + end + end + + def remove_from_direct + @mentions.each do |mention| + FeedManager.instance.unpush_from_direct(mention.account, @status) if mention.account.local? + end + end + + def lock_options + { redis: Redis.current, key: "distribute:#{@status.id}" } + end +end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 19500a8d4..819ce2c16 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -53,7 +53,7 @@ class SearchService < BaseService account_domains = results.map(&:account_domain) preloaded_relations = relations_map_for_account(@account, account_ids, account_domains) - results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? } + results.reject { |status| StatusFilter.new(status, @account, true, preloaded_relations).filtered? } rescue Faraday::ConnectionFailed, Parslet::ParseFailed [] end diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 151f3674f..c3e70d414 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -13,13 +13,15 @@ class UnfollowService < BaseService @target_account = target_account @options = options - unfollow! || undo_follow_request! + unfollow! + undo_follow_request! end private def unfollow! follow = Follow.find_by(account: @source_account, target_account: @target_account) + follow = Follow.create!(account: @source_account, target_account: @target_account) if follow.blank? && @options[:force] return unless follow @@ -34,6 +36,7 @@ class UnfollowService < BaseService def undo_follow_request! follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account) + follow_request = FollowRequest.create!(account: @source_account, target_account: @target_account) if follow_request.blank? && @options[:force] return unless follow_request diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb new file mode 100644 index 000000000..9dc4fbbcd --- /dev/null +++ b/app/services/update_status_service.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +class UpdateStatusService < BaseService + include Redisable + include ImgProxyHelper + + ALLOWED_ATTRIBUTES = %i( + spoiler_text + title + text + original_text + footer + content_type + language + sensitive + visibility + local_only + media_attachments + media_attachment_ids + application + expires_at + ).freeze + + # Updates the content of an existing status. + # @param [Status] status The status to update. + # @param [Hash] params The attributes of the new status. + # @param [Enumerable] mentions Additional mentions added to the status. + # @param [Enumerable] tags New tags for the status to belong to (implicit tags are preserved). + def call(status, params, mentions = nil, tags = nil) + raise ActiveRecord::RecordNotFound if status.blank? || status.discarded? || status.destroyed? + return status if params.blank? + + @status = status + @account = @status.account + @params = params.with_indifferent_access.slice(*ALLOWED_ATTRIBUTES).compact + @mentions = (@status.mentions | (mentions || [])).to_set + @tags = (tags.nil? ? @status.tags : (tags || [])).to_set + + @params[:text] ||= '' + @params[:original_text] = @params[:text] + @params[:published] = true if @status.published? + @params[:local_only] = @status.local_only? if @params[:local_only] == true && (@status.edited.positive? || @status.published?) + @params[:edited] ||= 1 + @status.edited if @params[:published].presence || @status.published? + @params[:expires_at] ||= Time.now.utc + (@status.expires_at - @status.created_at) if @status.expires_at.present? + + @params[:originally_local_only] = @params[:local_only] unless @status.published? + + update_tags if @status.local? + + @delete_payload = Oj.dump(event: :delete, payload: @status.id.to_s) + @deleted_tag_ids = @status.tags.pluck(:id) - @tags.pluck(:id) + @deleted_tag_names = @status.tags.pluck(:name) - @tags.pluck(:name) + @deleted_attachment_ids = @status.media_attachment_ids - (@params[:media_attachment_ids] || @params[:media_attachments]&.pluck(:id) || []) + + ApplicationRecord.transaction do + @status.update!(@params) + + if @account.local? + ProcessCommandTagsService.new.call(@account, @status) + else + process_inline_images! + end + + update_mentions + @status.save! + + detach_deleted_tags + attach_updated_tags + end + + prune_tags + prune_attachments + reset_status_caches + + SpamCheck.perform(@status) if @status.published? + distribute + + @status + end + + private + + def prune_attachments + @new_inline_ids = @status.inlined_attachments.pluck(:media_attachment_id) + RemoveMediaAttachmentsWorker.perform_async(@deleted_attachment_ids) if @deleted_attachment_ids.present? + end + + def detach_deleted_tags + @status.tags -= Tag.where(id: @deleted_tag_ids) if @deleted_tag_ids.present? + end + + def prune_tags + @account.featured_tags.where(tag_id: @deleted_tag_ids).each do |featured_tag| + featured_tag.decrement(@status.id) + end + + return unless @status.distributable? && @deleted_tag_names.present? + + @deleted_tag_names.each do |hashtag| + redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @delete_payload) + redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @delete_payload) if @status.local? + end + end + + def update_tags + old_explicit_tags = Tag.matching_name(Extractor.extract_hashtags(@status.text)) + @tags |= Tag.find_or_create_by_names(Extractor.extract_hashtags(@params[:text])) + + # Preserve implicit tags attached to the original status. + # TODO: Let locals remove them from edits. + @tags |= @status.tags.where.not(id: old_explicit_tags.select(:id)) + end + + def update_mentions + @new_mention_ids = @mentions.pluck(:id) - @status.mention_ids + @status.text, @mentions = ResolveMentionsService.new.call(@status, mentions: @mentions) + @new_mention_ids |= (@mentions.pluck(:id) - @new_mention_ids) + end + + def attach_updated_tags + tag_ids = @status.tag_ids.to_set + new_tag_ids = [] + now = Time.now.utc + + @tags.each do |tag| + next if tag_ids.include?(tag.id) || /\A(#{Tag::HASHTAG_NAME_RE})\z/i =~ $LAST_READ_LINE + + @status.tags << tag + new_tag_ids << tag.id + TrendingTags.record_use!(tag, @account, now) if @status.distributable? + end + + return unless @status.local? && @status.distributable? + + @account.featured_tags.where(tag_id: new_tag_ids).each do |featured_tag| + featured_tag.increment(now) + end + end + + def reset_status_caches + Rails.cache.delete_matched("statuses/#{@status.id}-*") + Rails.cache.delete("statuses/#{@status.id}") + Rails.cache.delete(@status) + Rails.cache.delete_matched("format:#{@status.id}:*") + redis.zremrangebyscore("spam_check:#{@account.id}", @status.id, @status.id) + end + + def distribute + LinkCrawlWorker.perform_in(rand(1..30).seconds, @status.id) unless @status.spoiler_text? + DistributionWorker.perform_async(@status.id) + + return unless @status.published? + + ActivityPub::DistributionWorker.perform_async(@status.id) if @status.local? && !@status.local_only? + + return unless @status.notify? + + mentions = @status.active_mentions.includes(:account).where(id: @new_mention_ids, accounts: { domain: nil }) + mentions.each { |mention| LocalNotificationWorker.perform_async(mention.account.id, mention.id, mention.class.name) } + end +end diff --git a/app/validators/poll_validator.rb b/app/validators/poll_validator.rb index 8259a62e5..898c0c67b 100644 --- a/app/validators/poll_validator.rb +++ b/app/validators/poll_validator.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class PollValidator < ActiveModel::Validator - MAX_OPTIONS = 5 - MAX_OPTION_CHARS = 100 - MAX_EXPIRATION = 1.month.freeze - MIN_EXPIRATION = 5.minutes.freeze + MAX_OPTIONS = 33 + MAX_OPTION_CHARS = 202 + MAX_EXPIRATION = 6.months.freeze + MIN_EXPIRATION = 1.minute.freeze def validate(poll) current_time = Time.now.utc diff --git a/app/views/about/_domain_allows.html.haml b/app/views/about/_domain_allows.html.haml new file mode 100644 index 000000000..ab5755b41 --- /dev/null +++ b/app/views/about/_domain_allows.html.haml @@ -0,0 +1,12 @@ +%table + %thead + %tr + %th{colspan: 3}= t('about.unavailable_content_description.domain') + %tbody + - domain_allows.in_groups_of(3) do |group| + %tr + - group.each do |domain_allow| + %td.nowrap + - unless domain_allow.nil? + %span + %a{ title: domain_allow.domain, href: "https://#{domain_allow.domain}", rel: 'noopener nofollow' }= domain_allow.domain diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml index 5d159e9e6..c3bd3ed60 100644 --- a/app/views/about/_registration.html.haml +++ b/app/views/about/_registration.html.haml @@ -6,14 +6,17 @@ = f.simple_fields_for :account do |account_fields| = account_fields.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations? + = f.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username_confirmation'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username_confirmation'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations? = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations? = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false, disabled: closed_registrations? = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations? + = f.hidden_field :kobold, input_html: { :autocomplete => 'off' } + - if approved_registrations? .fields-group = f.simple_fields_for :invite_request do |invite_request_fields| - = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: false + = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: true .fields-group = f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), required: true, disabled: closed_registrations? diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index 0a12ab8d6..0e4465a4a 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -42,13 +42,18 @@ .column-3 = render 'application/flashes' - - if @contents.blank? && (!display_blocks? || @blocks&.empty?) + - if @contents.blank? && ((!display_allows? || @allows&.empty?) && (!display_blocks? || @blocks&.empty?)) = nothing_here - else .box-widget .rich-formatting = @contents.html_safe + - if display_allows? && !@allows.empty? + %h2#available-content= t('about.available_content') + %p= t('about.available_content_html') + = render partial: 'domain_allows', locals: { domain_allows: @allows } + - if display_blocks? && !@blocks.empty? %h2#unavailable-content= t('about.unavailable_content') @@ -78,5 +83,8 @@ - item.children.each do |sub_item| %li= link_to sub_item.title, "##{sub_item.anchor}" + - if display_allows? && !@allows.empty? + %li= link_to t('about.available_content'), '#available-content' + - if display_blocks? && !@blocks.empty? %li= link_to t('about.unavailable_content'), '#unavailable-content' diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index 565c4ed59..f29c5c73c 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -3,77 +3,116 @@ - content_for :header_tags do %link{ rel: 'canonical', href: about_url }/ + %script{ src: '/registration.js', type: 'text/javascript', crossorigin: 'anonymous' }/ = render partial: 'shared/og' -.landing - .landing__brand - = link_to root_url, class: 'brand' do - = svg_logo_full - %span.brand__tagline=t 'about.tagline' +.grid-4 + .column-0 + .public-account-header.public-account-header--no-bar + .public-account-header__image + = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.jpg'), alt: @instance_presenter.site_title, class: 'parallax' - .landing__grid - .landing__grid__column.landing__grid__column-registration + .column-1 + .landing-page__call-to-action{ dir: 'ltr' } + .row + .row__information-board + .information-board__section + %span= t 'about.user_count_before' + %strong= number_with_delimiter @instance_presenter.user_count + %span= t 'about.user_count_after', count: @instance_presenter.user_count + .information-board__section + %span= t 'about.status_count_before' + %strong= number_with_delimiter @instance_presenter.status_count + %span= t 'about.status_count_after', count: @instance_presenter.status_count + .row__mascot + .landing-page__mascot + = image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('media/images/elephant_ui_plane.svg'), alt: '' + + .column-2 + .contact-widget + %h4= t 'about.administered_by' + + = account_link_to(@instance_presenter.contact_account) + + - if @instance_presenter.site_contact_email.present? + %h4 + = succeed ':' do + = t 'about.contact' + + = mail_to @instance_presenter.site_contact_email, nil, title: @instance_presenter.site_contact_email + + .column-3 + = render 'application/flashes' + + .box-widget + = render 'registration' + + %br + + - if @contents.blank? && ((!display_allows? || @allows&.empty?) && (!display_blocks? || @blocks&.empty?)) + = nothing_here + - else .box-widget - = render 'registration' - - .directory - - if Setting.profile_directory - .directory__tag - = optional_link_to Setting.profile_directory, explore_path do - %h4 - = fa_icon 'address-book fw' - = t('about.discover_users') - %small= t('about.browse_directory') - - .avatar-stack - - @instance_presenter.sample_accounts.each do |account| - = image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, alt: '', class: 'account__avatar' - - - if Setting.timeline_preview - .directory__tag - = optional_link_to Setting.timeline_preview, public_timeline_path do - %h4 - = fa_icon 'globe fw' - = t('about.see_whats_happening') - %small= t('about.browse_public_posts') + .rich-formatting + = @contents.html_safe + + - if display_allows? && !@allows.empty? + %h2#available-content= t('about.available_content') + %p= t('about.available_content_html') + = render partial: 'domain_allows', locals: { domain_allows: @allows } + + - if display_blocks? && !@blocks.empty? + %h2#unavailable-content= t('about.unavailable_content') + - if (blocks = @blocks.select(&:reject_media?)) && !blocks.empty? + %h3= t('about.unavailable_content_description.rejecting_media_title') + %p= t('about.unavailable_content_description.rejecting_media') + = render partial: 'domain_blocks', locals: { domain_blocks: blocks } + - if (blocks = @blocks.select(&:silence?)) && !blocks.empty? + %h3= t('about.unavailable_content_description.silenced_title') + %p= t('about.unavailable_content_description.silenced') + = render partial: 'domain_blocks', locals: { domain_blocks: blocks } + - if (blocks = @blocks.select(&:suspend?)) && !blocks.empty? + %h3= t('about.unavailable_content_description.suspended_title') + %p= t('about.unavailable_content_description.suspended') + = render partial: 'domain_blocks', locals: { domain_blocks: blocks } + + .column-4 + .box-widget + = render 'login' + + %br + + %ul.table-of-contents + - @table_of_contents.each do |item| + %li + = link_to item.title, "##{item.anchor}" + + - unless item.children.empty? + %ul + - item.children.each do |sub_item| + %li= link_to sub_item.title, "##{sub_item.anchor}" + + - if display_allows? && !@allows.empty? + %li= link_to t('about.available_content'), '#available-content' + + - if display_blocks? && !@blocks.empty? + %li= link_to t('about.unavailable_content'), '#unavailable-content' + + %br + + .directory + - if Setting.profile_directory .directory__tag - = link_to 'https://joinmastodon.org/apps', target: '_blank', rel: 'noopener noreferrer' do + = optional_link_to Setting.profile_directory, explore_path do %h4 - = fa_icon 'tablet fw' - = t('about.get_apps') - %small= t('about.apps_platforms') + = fa_icon 'address-book fw' + = t('about.discover_users') - .landing__grid__column.landing__grid__column-login - .box-widget - = render 'login' - - .hero-widget - .hero-widget__img - = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.jpg'), alt: @instance_presenter.site_title - - .hero-widget__text - %p - = @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html') - = link_to about_more_path do - = t('about.learn_more') - = fa_icon 'angle-double-right' - - .hero-widget__footer - .hero-widget__footer__column - %h4= t 'about.administered_by' - - = account_link_to @instance_presenter.contact_account - - .hero-widget__footer__column - %h4= t 'about.server_stats' - - .hero-widget__counters__wrapper - .hero-widget__counter - %strong= number_to_human @instance_presenter.user_count, strip_insignificant_zeros: true - %span= t 'about.user_count_after', count: @instance_presenter.user_count - .hero-widget__counter - %strong= number_to_human @instance_presenter.active_user_count, strip_insignificant_zeros: true - %span - = t 'about.active_count_after' - %abbr{ title: t('about.active_footnote') } * + .directory__tag + = link_to 'https://joinmastodon.org/apps', target: '_blank', rel: 'noopener noreferrer' do + %h4 + = fa_icon 'tablet fw' + = t('about.get_apps') + + %br diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index 52fb0d946..27a29c061 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -13,19 +13,16 @@ = fa_icon('lock') if account.locked? .public-account-header__tabs__tabs .details-counters - .counter{ class: active_nav_class(short_account_url(account), short_account_with_replies_url(account), short_account_media_url(account)) } + .counter{ class: active_nav_class(short_account_url(account), short_account_threads_url(account), short_account_with_replies_url(account), short_account_reblogs_url(account), short_account_mentions_url(account), short_account_media_url(account)) } = link_to short_account_url(account), class: 'u-url u-uid', title: number_with_delimiter(account.statuses_count) do - %span.counter-number= number_to_human account.statuses_count, strip_insignificant_zeros: true %span.counter-label= t('accounts.posts', count: account.statuses_count) .counter{ class: active_nav_class(account_following_index_url(account)) } = link_to account_following_index_url(account), title: number_with_delimiter(account.following_count) do - %span.counter-number= number_to_human account.following_count, strip_insignificant_zeros: true %span.counter-label= t('accounts.following', count: account.following_count) .counter{ class: active_nav_class(account_followers_url(account)) } = link_to account_followers_url(account), title: hide_followers_count?(account) ? nil : number_with_delimiter(account.followers_count) do - %span.counter-number= hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true) %span.counter-label= t('accounts.followers', count: account.followers_count) .spacer .public-account-header__tabs__tabs__buttons diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index c9688ea88..3a6fca642 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -16,6 +16,9 @@ = opengraph 'og:type', 'profile' = render 'og', account: @account, url: short_account_url(@account, only_path: false) +- content_for :header_overrides do + - if @account&.user&.setting_style_css_profile.present? + = stylesheet_link_tag user_profile_css_path(id: @account.id), media: 'all' = render 'header', account: @account, with_bio: true @@ -26,8 +29,13 @@ .account__section-headline = active_link_to t('accounts.posts_tab_heading'), short_account_url(@account) - = active_link_to t('accounts.posts_with_replies'), short_account_with_replies_url(@account) + = active_link_to t('accounts.threads'), short_account_threads_url(@account) + - if @account.show_replies? + = active_link_to t('accounts.posts_with_replies'), short_account_with_replies_url(@account) + - if current_account.present? && @account.id != current_account.id + = active_link_to t('accounts.mentions'), short_account_mentions_url(@account) = active_link_to t('accounts.media'), short_account_media_url(@account) + = active_link_to t('accounts.reblogs'), short_account_reblogs_url(@account) - if user_signed_in? && @account.blocking?(current_account) .nothing-here.nothing-here--under-tabs= t('accounts.unavailable') diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index 8eac226e0..bff1f2b20 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -10,7 +10,7 @@ .filter-subset %strong= t('admin.accounts.moderation.title') %ul - %li= link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), admin_pending_accounts_path + %li= link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.confirmed.count)})"], ' '), admin_pending_accounts_path %li= filter_link_to t('admin.accounts.moderation.active'), silenced: nil, suspended: nil, pending: nil %li= filter_link_to t('admin.accounts.moderation.silenced'), silenced: '1', suspended: nil, pending: nil %li= filter_link_to t('admin.accounts.moderation.suspended'), suspended: '1', silenced: nil, pending: nil diff --git a/app/views/admin/domain_allows/new.html.haml b/app/views/admin/domain_allows/new.html.haml index 85ab7e464..0540765d7 100644 --- a/app/views/admin/domain_allows/new.html.haml +++ b/app/views/admin/domain_allows/new.html.haml @@ -6,6 +6,7 @@ .fields-group = f.input :domain, wrapper: :with_label, label: t('admin.domain_blocks.domain'), required: true + = f.input :hidden, wrapper: :with_label, label: t('admin.domain_allows.hidden') .actions = f.button :button, t('admin.domain_allows.add_new'), type: :submit diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml index 696ba3c7f..5aec735e4 100644 --- a/app/views/admin/instances/index.html.haml +++ b/app/views/admin/instances/index.html.haml @@ -2,19 +2,15 @@ = t('admin.instances.title') - content_for :heading_actions do - - if whitelist_mode? - = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button' - - else - = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button' + = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button' + = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button' .filters .filter-subset %strong= t('admin.instances.moderation.title') %ul %li= filter_link_to t('admin.instances.moderation.all'), limited: nil - - - unless whitelist_mode? - %li= filter_link_to t('admin.instances.moderation.limited'), limited: '1' + %li= filter_link_to t('admin.instances.moderation.limited'), limited: '1' - unless whitelist_mode? = form_tag admin_instances_url, method: 'GET', class: 'simple_form' do diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml index 92e14c0df..e5a5a6129 100644 --- a/app/views/admin/instances/show.html.haml +++ b/app/views/admin/instances/show.html.haml @@ -52,8 +52,11 @@ %div - if @domain_allow = link_to t('admin.domain_allows.undo'), admin_domain_allow_path(@domain_allow), class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete } - - elsif @domain_block + - else + = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path(_domain: @instance.domain), class: 'button' + + - if @domain_block = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@domain_block), class: 'button' = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@domain_block), class: 'button' - else - = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @instance.domain), class: 'button' + = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @instance.domain), class: 'button button--destructive' diff --git a/app/views/admin/pending_accounts/index.html.haml b/app/views/admin/pending_accounts/index.html.haml index 8101d7f99..2f73d12b4 100644 --- a/app/views/admin/pending_accounts/index.html.haml +++ b/app/views/admin/pending_accounts/index.html.haml @@ -1,5 +1,5 @@ - content_for :page_title do - = t('admin.pending_accounts.title', count: User.pending.count) + = t('admin.pending_accounts.title', count: User.pending.confirmed.count) = form_for(@form, url: batch_admin_pending_accounts_path) do |f| = hidden_field_tag :page, params[:page] || 1 diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 108846ca9..0d36e4551 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -47,12 +47,11 @@ %hr.spacer/ - - unless whitelist_mode? - .fields-group - = f.input :timeline_preview, as: :boolean, wrapper: :with_label, label: t('admin.settings.timeline_preview.title'), hint: t('admin.settings.timeline_preview.desc_html') + .fields-group + = f.input :timeline_preview, as: :boolean, wrapper: :with_label, label: t('admin.settings.timeline_preview.title'), hint: t('admin.settings.timeline_preview.desc_html') - .fields-group - = f.input :show_known_fediverse_at_about_page, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_known_fediverse_at_about_page.title'), hint: t('admin.settings.show_known_fediverse_at_about_page.desc_html') + .fields-group + = f.input :show_known_fediverse_at_about_page, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_known_fediverse_at_about_page.title'), hint: t('admin.settings.show_known_fediverse_at_about_page.desc_html') .fields-group = f.input :show_staff_badge, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_staff_badge.title'), hint: t('admin.settings.show_staff_badge.desc_html') @@ -60,27 +59,26 @@ .fields-group = f.input :open_deletion, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.deletion.title'), hint: t('admin.settings.registrations.deletion.desc_html') - - unless whitelist_mode? - .fields-group - = f.input :activity_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.activity_api_enabled.title'), hint: t('admin.settings.activity_api_enabled.desc_html') + .fields-group + = f.input :activity_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.activity_api_enabled.title'), hint: t('admin.settings.activity_api_enabled.desc_html') - .fields-group - = f.input :peers_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.peers_api_enabled.title'), hint: t('admin.settings.peers_api_enabled.desc_html') + .fields-group + = f.input :peers_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.peers_api_enabled.title'), hint: t('admin.settings.peers_api_enabled.desc_html') - .fields-group - = f.input :preview_sensitive_media, as: :boolean, wrapper: :with_label, label: t('admin.settings.preview_sensitive_media.title'), hint: t('admin.settings.preview_sensitive_media.desc_html') + .fields-group + = f.input :preview_sensitive_media, as: :boolean, wrapper: :with_label, label: t('admin.settings.preview_sensitive_media.title'), hint: t('admin.settings.preview_sensitive_media.desc_html') - .fields-group - = f.input :profile_directory, as: :boolean, wrapper: :with_label, label: t('admin.settings.profile_directory.title'), hint: t('admin.settings.profile_directory.desc_html') + .fields-group + = f.input :profile_directory, as: :boolean, wrapper: :with_label, label: t('admin.settings.profile_directory.title'), hint: t('admin.settings.profile_directory.desc_html') - .fields-group - = f.input :trends, as: :boolean, wrapper: :with_label, label: t('admin.settings.trends.title'), hint: t('admin.settings.trends.desc_html') + .fields-group + = f.input :trends, as: :boolean, wrapper: :with_label, label: t('admin.settings.trends.title'), hint: t('admin.settings.trends.desc_html') - .fields-group - = f.input :trendable_by_default, as: :boolean, wrapper: :with_label, label: t('admin.settings.trendable_by_default.title'), hint: t('admin.settings.trendable_by_default.desc_html') + .fields-group + = f.input :trendable_by_default, as: :boolean, wrapper: :with_label, label: t('admin.settings.trendable_by_default.title'), hint: t('admin.settings.trendable_by_default.desc_html') - .fields-group - = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html') + .fields-group + = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html') .fields-group = f.input :hide_followers_count, as: :boolean, wrapper: :with_label, label: t('admin.settings.hide_followers_count.title'), hint: t('admin.settings.hide_followers_count.desc_html') @@ -99,8 +97,11 @@ %hr.spacer/ - .fields-group - = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + .fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + .fields-row__column.fields-row__column-6.fields-group + = f.input :show_domain_allows, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_allows.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' .fields-row .fields-row__column.fields-row__column-6.fields-group @@ -112,7 +113,7 @@ = f.input :outgoing_spoilers, wrapper: :with_label, label: t('admin.settings.outgoing_spoilers.title'), hint: t('admin.settings.outgoing_spoilers.desc_html') .fields-group - = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } unless whitelist_mode? + = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 } = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 } = f.input :custom_css, wrapper: :with_block_label, as: :text, input_html: { rows: 8 }, label: t('admin.settings.custom_css.title'), hint: t('admin.settings.custom_css.desc_html') diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index cc72b87ce..b9033f553 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -2,6 +2,7 @@ = t('auth.register') - content_for :header_tags do + %script{ src: '/registration.js', type: 'text/javascript', crossorigin: 'anonymous' }/ = render partial: 'shared/og', locals: { description: description_for_sign_up } = simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { novalidate: false }) do |f| @@ -15,6 +16,7 @@ = f.simple_fields_for :account do |ff| .fields-group = ff.input :username, wrapper: :with_label, autofocus: true, label: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: t('simple_form.hints.defaults.username', domain: site_hostname) + = f.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username_confirmation'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username_confirmation'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations? .fields-group = f.input :email, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' } @@ -28,9 +30,10 @@ - if approved_registrations? && !@invite.present? .fields-group = f.simple_fields_for :invite_request, resource.invite_request || resource.build_invite_request do |invite_request_fields| - = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: false + = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: true = f.input :invite_code, as: :hidden + = f.hidden_field :kobold, input_html: { :autocomplete => 'off' } .fields-group = f.input :agreement, as: :boolean, wrapper: :with_label, label: whitelist_mode? ? t('auth.checkbox_agreement_without_rules_html', terms_path: terms_path) : t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), required: true diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 3336cf391..34f742c16 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -40,6 +40,11 @@ - if Setting.custom_css.present? = stylesheet_link_tag custom_css_path, media: 'all' + - if current_account&.user.present? + = stylesheet_link_tag user_webapp_css_path(current_account.id), media: 'all' + + = yield :header_overrides + %body{ class: body_classes } = content_for?(:content) ? yield(:content) : yield diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml index eaa0437c2..e820285cb 100644 --- a/app/views/layouts/public.html.haml +++ b/app/views/layouts/public.html.haml @@ -10,10 +10,9 @@ = link_to root_url, class: 'brand' do = svg_logo_full - - unless whitelist_mode? - = link_to t('directories.directory'), explore_path, class: 'nav-link optional' if Setting.profile_directory - = link_to t('about.about_this'), about_more_path, class: 'nav-link optional' - = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link optional' + = link_to t('directories.directory'), explore_path, class: 'nav-link optional' if Setting.profile_directory + = link_to t('about.about_this'), about_more_path, class: 'nav-link optional' + = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link optional' .nav-center diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index 5fc865814..48031a973 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -30,6 +30,11 @@ = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label = f.input :setting_system_emoji_font, as: :boolean, wrapper: :with_label + .fields-group + = f.input :setting_style_wide_media, as: :boolean, wrapper: :with_label + = f.input :setting_style_dashed_nest, as: :boolean, wrapper: :with_label + = f.input :setting_style_underline_a, as: :boolean, wrapper: :with_label + %h4= t 'appearance.toot_layout' .fields-group @@ -59,5 +64,29 @@ .fields-group = f.input :setting_expand_spoilers, as: :boolean, wrapper: :with_label + %h4= t 'appearance.custom_css' + + .fields-group + = f.input :setting_style_css_profile, as: :text, wrapper: :with_label + + - if current_user.setting_style_css_profile_errors.present? + %p + %strong= t('appearance.custom_css_error') + + %ul + - current_user.setting_style_css_profile_errors.each do |error| + %li.hint= error + + .fields-group + = f.input :setting_style_css_webapp, as: :text, wrapper: :with_label + + - if current_user&.setting_style_css_webapp_errors.present? + %p + %strong= t('appearance.custom_css_error') + + %ul + - current_user.setting_style_css_webapp_errors.each do |error| + %li.hint= error + .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/settings/preferences/filters/show.html.haml b/app/views/settings/preferences/filters/show.html.haml new file mode 100644 index 000000000..f91010724 --- /dev/null +++ b/app/views/settings/preferences/filters/show.html.haml @@ -0,0 +1,22 @@ +- content_for :page_title do + = t('settings.preferences') + +- content_for :heading_actions do + = button_tag t('generic.save_changes'), class: 'button', form: 'edit_preferences' + += simple_form_for current_user, url: settings_preferences_filters_path, html: { method: :put, id: 'edit_preferences' } do |f| + = render 'shared/error_messages', object: current_user + + %h4= t 'preferences.filtering' + + .fields-group + = f.input :setting_filter_to_unknown, as: :boolean, wrapper: :with_label + = f.input :setting_filter_from_unknown, as: :boolean, wrapper: :with_label + + %h4= t 'preferences.public_timelines' + + .fields-group + = f.input :chosen_languages, collection: filterable_languages.sort, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml index 3b5c7016d..a1d3d4357 100644 --- a/app/views/settings/preferences/other/show.html.haml +++ b/app/views/settings/preferences/other/show.html.haml @@ -38,10 +38,5 @@ .fields-group = f.input :setting_default_content_type, collection: ['text/plain', 'text/markdown', 'text/html'], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.defaults.setting_default_content_type_#{item.split('/')[1]}"), content_tag(:span, t("simple_form.hints.defaults.setting_default_content_type_#{item.split('/')[1]}"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' - %h4= t 'preferences.public_timelines' - - .fields-group - = f.input :chosen_languages, collection: filterable_languages.sort, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' - .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/settings/preferences/publishing/show.html.haml b/app/views/settings/preferences/publishing/show.html.haml new file mode 100644 index 000000000..9fe76f385 --- /dev/null +++ b/app/views/settings/preferences/publishing/show.html.haml @@ -0,0 +1,23 @@ +- content_for :page_title do + = t('settings.preferences') + +- content_for :heading_actions do + = button_tag t('generic.save_changes'), class: 'button', form: 'edit_preferences' + += simple_form_for current_user, url: settings_preferences_publishing_path, html: { method: :put, id: 'edit_preferences' } do |f| + = render 'shared/error_messages', object: current_user + + %h4= t 'preferences.advanced_publishing' + + .fields-row + .fields-group.fields-row__column.fields-row__column-6 + = f.input :setting_manual_publish, as: :boolean, wrapper: :with_label + = f.input :setting_unpublish_on_delete, as: :boolean, wrapper: :with_label + + .fields-group.fields-row__column.fields-row__column-6 + = f.input :setting_publish_in, collection: Status::TIMER_VALUES, wrapper: :with_label, label_method: lambda { |m| I18n.t("timer.#{m}") }, required: false, include_blank: false, hint: false + = f.input :setting_unpublish_in, collection: Status::TIMER_VALUES, wrapper: :with_label, label_method: lambda { |m| I18n.t("timer.#{m}") }, required: false, include_blank: false, hint: false + = f.input :setting_unpublish_delete, as: :boolean, wrapper: :with_label + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 6061e9cfd..1b7765f32 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -24,14 +24,37 @@ %hr.spacer/ .fields-group - = f.input :locked, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.locked') + = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot') + + %h4= t 'settings.profiles.privacy' + + %p.hint= t 'settings.profiles.privacy_html' .fields-group - = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot') + = f.input :locked, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.locked') - if Setting.profile_directory .fields-group - = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable'), recommended: true + = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable') + + .fields-group + = f.input :show_replies, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.show_replies') + + .fields-group + = f.input :show_unlisted, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.show_unlisted') + + .fields-group + = f.input :private, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.private') + + .fields-group + = f.input :require_auth, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.require_auth') + + %h4= t 'settings.profiles.advanced_privacy' + + %p.hint= t 'settings.profiles.advanced_privacy_html' + + .fields-group + = f.input :require_dereference, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.require_dereference_html') %hr.spacer/ diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index b3e9c44fc..8673f860c 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -15,13 +15,16 @@ = account_action_button(status.account) - .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }< - - if status.spoiler_text? - %p< - %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)} + .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.title? || status.spoiler_text?) } + - if status.title? || status.spoiler_text? + %div.spoiler + = fa_icon 'info-circle fw' + %span.p-summary= Formatter.instance.format_spoiler(status, autoplay: autoplay) + - if status.title? || status.spoiler_text? || parent_status&.spoiler_text? + %div %button.status__content__spoiler-link= t('statuses.show_more') .e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' } - = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) + = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay, article_content: true) - if status.preloadable_poll = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } @@ -29,17 +32,17 @@ - if !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first - = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: status.sensitive?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do + = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: status.sensitive? || parent_status&.sensitive?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.media_attachments.first.audio? - audio = status.media_attachments.first - = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do + = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta&.dig('colors', 'background'), foregroundColor: audio.file.meta&.dig('colors', 'foreground'), accentColor: audio.file.meta&.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta&.dig('original', 'duration') do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - else - = react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do + = react_component :media_gallery, height: 380, sensitive: status.sensitive? || parent_status&.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.preview_card - = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json + = react_component :card, sensitive: status.sensitive? || parent_status&.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json .detailed-status__meta %data.dt-published{ value: status.created_at.to_time.iso8601 } diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index f2b6866e9..0f2763d27 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -21,13 +21,20 @@ %span.display-name__account = acct(status.account) = fa_icon('lock') if status.account.locked? - .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }< - - if status.spoiler_text? - %p< - %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)} + .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.title? || status.spoiler_text? || parent_status&.spoiler_text?) }< + - if parent_status&.spoiler_text? + %div.spoiler.reblog-spoiler + = fa_icon 'retweet fw' + %span.p-summary= Formatter.instance.format_spoiler(parent_status, autoplay: autoplay) + - if status.title? || status.spoiler_text? + %div.spoiler + = fa_icon 'info-circle fw' + %span.p-summary= Formatter.instance.format_spoiler(status, autoplay: autoplay) + - if status.title? || status.spoiler_text? || parent_status&.spoiler_text? + %div %button.status__content__spoiler-link= t('statuses.show_more') .e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }< - = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) + = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay, article_content: true) - if status.preloadable_poll = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } @@ -35,17 +42,17 @@ - if !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first - = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: status.sensitive?, width: 610, height: 343, inline: true, alt: video.description do + = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: status.sensitive? || parent_status&.sensitive?, width: 610, height: 343, inline: true, alt: video.description do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.media_attachments.first.audio? - audio = status.media_attachments.first - = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do + = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta&.dig('colors', 'background'), foregroundColor: audio.file.meta&.dig('colors', 'foreground'), accentColor: audio.file.meta&.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta&.dig('original', 'duration') do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - else - = react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do + = react_component :media_gallery, height: 343, sensitive: status.sensitive? || parent_status&.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.preview_card - = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json + = react_component :card, sensitive: status.sensitive? || parent_status&.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json - if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id = link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do diff --git a/app/views/statuses/_status.html.haml b/app/views/statuses/_status.html.haml index 0e3652503..1c8acadf2 100644 --- a/app/views/statuses/_status.html.haml +++ b/app/views/statuses/_status.html.haml @@ -27,19 +27,12 @@ .status__prepend .status__prepend-icon-wrapper %i.status__prepend-icon.fa.fa-fw.fa-retweet - %span - = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name muted' do - %bdi - %strong.emojify= display_name(status.account, custom_emojify: true) - = t('stream_entries.reblogged') - elsif pinned .status__prepend .status__prepend-icon-wrapper %i.status__prepend-icon.fa.fa-fw.fa-thumb-tack - %span - = t('stream_entries.pinned') - = render (centered ? 'statuses/detailed_status' : 'statuses/simple_status'), status: status.proper, autoplay: autoplay + = render (centered ? 'statuses/detailed_status' : 'statuses/simple_status'), status: status.proper, autoplay: autoplay, parent_status: status - if include_threads - if @since_descendant_thread_id diff --git a/app/views/statuses/show.html.haml b/app/views/statuses/show.html.haml index 873df7fbd..c06f6ebce 100644 --- a/app/views/statuses/show.html.haml +++ b/app/views/statuses/show.html.haml @@ -16,6 +16,10 @@ = render 'og_description', activity: @status = render 'og_image', activity: @status, account: @account +- content_for :header_overrides do + - if @account&.user&.setting_style_css_profile.present? + = stylesheet_link_tag user_profile_css_path(id: @account.id), media: 'all' + .grid .column-0 .activity-stream.h-entry diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index e4997ba0e..716d751c4 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -9,11 +9,12 @@ class ActivityPub::DistributionWorker def perform(status_id) @status = Status.find(status_id) @account = @status.account + @payload = {} return if skip_distribution? ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| - [payload, @account.id, inbox_url] + [payload(Addressable::URI.parse(inbox_url).host), @account.id, inbox_url] end relay! if relayable? @@ -24,7 +25,7 @@ class ActivityPub::DistributionWorker private def skip_distribution? - @status.direct_visibility? || @status.limited_visibility? + !@status.published? || @status.direct_visibility? || @status.limited_visibility? end def relayable? @@ -35,20 +36,20 @@ class ActivityPub::DistributionWorker # Deliver the status to all followers. # If the status is a reply to another local status, also forward it to that # status' authors' followers. - @inboxes ||= if @status.reply? && @status.thread.account.local? && @status.distributable? + @inboxes ||= if @status.reply? && @status.thread&.account&.local? && @status.distributable? @account.followers.or(@status.thread.account.followers).inboxes else @account.followers.inboxes end end - def payload - @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account)) + def payload(domain) + @payload[domain] ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status, update: true, embed: false), ActivityPub::ActivitySerializer, signer: @account, target_domain: domain)) end def relay! ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| - [payload, @account.id, inbox_url] + [payload(Addressable::URI.parse(inbox_url).host), @account.id, inbox_url] end end end diff --git a/app/workers/activitypub/process_collection_items_for_account_worker.rb b/app/workers/activitypub/process_collection_items_for_account_worker.rb new file mode 100644 index 000000000..4b5710c1d --- /dev/null +++ b/app/workers/activitypub/process_collection_items_for_account_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +class ActivityPub::ProcessCollectionItemsForAccountWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: 3 + + def perform(account_id) + @account_id = account_id + on_behalf_of = nil + + if account_id.present? + account = Account.find(account_id) + on_behalf_of = account.followers.local.random.first + end + + ActivityPub::ProcessCollectionItemsService.new.call(account_id, on_behalf_of) + rescue ActiveRecord::RecordNotFound + nil + end +end diff --git a/app/workers/activitypub/process_collection_items_worker.rb b/app/workers/activitypub/process_collection_items_worker.rb new file mode 100644 index 000000000..d830edaec --- /dev/null +++ b/app/workers/activitypub/process_collection_items_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +class ActivityPub::ProcessCollectionItemsWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: 0 + + def perform + return if Sidekiq::Stats.new.workers_size > 3 + + RedisLock.acquire(lock_options) do |lock| + if lock.acquired? + account_id = random_unprocessed_account_id + ActivityPub::ProcessCollectionItemsForAccountWorker.perform_async(account_id) if account_id.present? + end + end + end + + private + + def random_unprocessed_account_id + CollectionItem.unprocessed.pluck(:account_id).sample + end + + def lock_options + { redis: Redis.current, key: 'process_collection_items' } + end +end diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb index d4d0148ac..e8648ffcd 100644 --- a/app/workers/activitypub/reply_distribution_worker.rb +++ b/app/workers/activitypub/reply_distribution_worker.rb @@ -12,11 +12,12 @@ class ActivityPub::ReplyDistributionWorker def perform(status_id) @status = Status.find(status_id) @account = @status.thread&.account + @payload = {} return unless @account.present? && @status.distributable? ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| - [payload, @status.account_id, inbox_url] + [payload(Addressable::URI.parse(inbox_url).host), @status.account_id, inbox_url] end rescue ActiveRecord::RecordNotFound true @@ -28,7 +29,7 @@ class ActivityPub::ReplyDistributionWorker @inboxes ||= @account.followers.inboxes end - def payload - @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account)) + def payload(domain) + @payload[domain] ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status, update: true, embed: false), ActivityPub::ActivitySerializer, signer: @status.account, target_domain: domain)) end end diff --git a/app/workers/activitypub/sync_account_worker.rb b/app/workers/activitypub/sync_account_worker.rb new file mode 100644 index 000000000..18825b20d --- /dev/null +++ b/app/workers/activitypub/sync_account_worker.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +class ActivityPub::SyncAccountWorker + include Sidekiq::Worker + include ExponentialBackoff + + sidekiq_options queue: 'pull', retry: 5 + + def perform(account_id, every_page = false, skip_cooldown = false) + @account = Account.find(account_id) + return if @account.local? + + @from_migrated_account = @account.moved_to_account&.local? + return unless @from_migrated_account || @account.followers.local.exists? + + RedisLock.acquire(lock_options) do |lock| + if lock.acquired? + fetch_collection_items(every_page, skip_cooldown) + elsif @from_migrated_account + # Cause a retry so server-to-server migrations can complete. + raise Mastodon::RaceConditionError + end + end + rescue ActiveRecord::RecordNotFound + nil + end + + private + + def lock_options + { redis: Redis.current, key: "account_sync:#{@account.id}" } + end + + # Limits for an account moving to this server. + def limits_migrated + { + page_limit: 2_000, + item_limit: 40_000, + look_ahead: true, + } + end + + # Limits for an account someone locally follows. + def limits_followed + { + page_limit: 25, + item_limit: 500, + look_ahead: @account.last_synced_at.blank?, + } + end + + def fetch_collection_items(every_page, skip_cooldown) + opts = @from_migrated_account && every_page ? limits_migrated : limits_followed + opts.merge!({ every_page: every_page, skip_cooldown: skip_cooldown }) + ActivityPub::FetchCollectionItemsService.new.call(@account.outbox_url, @account, **opts) + @account.update(last_synced_at: Time.now.utc) + end +end diff --git a/app/workers/distribution_worker.rb b/app/workers/distribution_worker.rb index 4e20ef31b..049d2732b 100644 --- a/app/workers/distribution_worker.rb +++ b/app/workers/distribution_worker.rb @@ -3,10 +3,11 @@ class DistributionWorker include Sidekiq::Worker - def perform(status_id) + def perform(status_id, only_to_self = false) RedisLock.acquire(redis: Redis.current, key: "distribute:#{status_id}") do |lock| if lock.acquired? - FanOutOnWriteService.new.call(Status.find(status_id)) + status = Status.find(status_id) + FanOutOnWriteService.new.call(status, only_to_self: !status.published? || only_to_self || !status.notify?) else raise Mastodon::RaceConditionError end diff --git a/app/workers/fetch_reply_worker.rb b/app/workers/fetch_reply_worker.rb index f7aa25e81..67db042fd 100644 --- a/app/workers/fetch_reply_worker.rb +++ b/app/workers/fetch_reply_worker.rb @@ -6,7 +6,12 @@ class FetchReplyWorker sidekiq_options queue: 'pull', retry: 3 - def perform(child_url) - FetchRemoteStatusService.new.call(child_url) + def perform(child_url, account_id = nil) + account = account_id.blank? ? nil : Account.find_by(id: account_id) + on_behalf_of = account.blank? ? nil : account.followers.local.random.first + + FetchRemoteStatusService.new.call(child_url, nil, on_behalf_of) + rescue ActiveRecord::RecordNotFound + nil end end diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb index b3d8aa264..32e51537d 100644 --- a/app/workers/link_crawl_worker.rb +++ b/app/workers/link_crawl_worker.rb @@ -6,7 +6,8 @@ class LinkCrawlWorker sidekiq_options queue: 'pull', retry: 0 def perform(status_id) - FetchLinkCardService.new.call(Status.find(status_id)) + status = Status.find(status_id) + FetchLinkCardService.new.call(status) if status.published? rescue ActiveRecord::RecordNotFound true end diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb index 39e321316..4e155546f 100644 --- a/app/workers/move_worker.rb +++ b/app/workers/move_worker.rb @@ -16,6 +16,9 @@ class MoveWorker copy_account_notes! carry_blocks_over! carry_mutes_over! + return unless @target_account.local? + + ActivityPub::SyncAccountWorker.perform_async(@source_account.id, every_page: true, skip_cooldown: true) rescue ActiveRecord::RecordNotFound true end diff --git a/app/workers/mute_conversation_worker.rb b/app/workers/mute_conversation_worker.rb new file mode 100644 index 000000000..efe6dd539 --- /dev/null +++ b/app/workers/mute_conversation_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class MuteConversationWorker + include Sidekiq::Worker + + def perform(account_id, conversation_id) + FeedManager.instance.unpush_conversation(Account.find(account_id), Conversation.find(conversation_id)) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/publish_scheduled_status_worker.rb b/app/workers/publish_scheduled_status_worker.rb index ce42f7be7..a5166f6a8 100644 --- a/app/workers/publish_scheduled_status_worker.rb +++ b/app/workers/publish_scheduled_status_worker.rb @@ -21,6 +21,8 @@ class PublishScheduledStatusWorker options.tap do |options_hash| options_hash[:application] = Doorkeeper::Application.find(options_hash.delete(:application_id)) if options[:application_id] options_hash[:thread] = Status.find(options_hash.delete(:in_reply_to_id)) if options_hash[:in_reply_to_id] + options_hash[:mentions] = Mention.where(id: options_hash.delete(:mention_ids)) if options_hash[:mention_ids] + options_hash[:status] = Status.find_by(id: options_hash.delete(:status_id)) if options_hash[:status_id] end end end diff --git a/app/workers/redownload_media_worker.rb b/app/workers/redownload_media_worker.rb index 0638cd0f0..0ead9a7a8 100644 --- a/app/workers/redownload_media_worker.rb +++ b/app/workers/redownload_media_worker.rb @@ -11,10 +11,27 @@ class RedownloadMediaWorker return if media_attachment.remote_url.blank? + orig_small_url = media_attachment.file.url(:small) + media_attachment.download_file! media_attachment.download_thumbnail! - media_attachment.save + + if media_attachment.save && media_attachment.inline? && media_attachment.status.present? + if unsupported_media_type?(media_attachment.file.content_type) + media_attachment.destroy + true + else + media_attachment.status.text.gsub!("#{orig_small_url}##{media_attachment.id}", media_attachment.file.url(:small)) + media_attachment.status.save + end + end rescue ActiveRecord::RecordNotFound true end + + private + + def unsupported_media_type?(mime_type) + mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type) + end end diff --git a/app/workers/remove_media_attachments_worker.rb b/app/workers/remove_media_attachments_worker.rb new file mode 100644 index 000000000..d5bac6ab8 --- /dev/null +++ b/app/workers/remove_media_attachments_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RemoveMediaAttachmentsWorker + include Sidekiq::Worker + + def perform(attachment_ids) + RemoveMediaAttachmentsService.new.call(attachment_ids) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/reset_account_worker.rb b/app/workers/reset_account_worker.rb new file mode 100644 index 000000000..f63d8682a --- /dev/null +++ b/app/workers/reset_account_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ResetAccountWorker + include Sidekiq::Worker + + def perform(account_id) + account = Account.find(account_id) + return if account.local? + + account_uri = account.uri + SuspendAccountService.new.call(account) + ResolveAccountService.new.call(account_uri) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/revoke_status_worker.rb b/app/workers/revoke_status_worker.rb new file mode 100644 index 000000000..8cc2b1623 --- /dev/null +++ b/app/workers/revoke_status_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RevokeStatusWorker + include Sidekiq::Worker + + def perform(status_id, account_ids) + RevokeStatusService.new.call(Status.find(status_id), account_ids) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/scheduler/ambassador_scheduler.rb b/app/workers/scheduler/ambassador_scheduler.rb new file mode 100644 index 000000000..f942a9893 --- /dev/null +++ b/app/workers/scheduler/ambassador_scheduler.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class Scheduler::AmbassadorScheduler + include Sidekiq::Worker + sidekiq_options lock: :until_executed, retry: 0 + + def perform + @ambassador = find_ambassador_acct + return if @ambassador.nil? + + status = next_boost + return if status.nil? + + ReblogService.new.call(@ambassador, status) + end + + private + + def find_ambassador_acct + ambassador = ENV['AMBASSADOR_USER'].to_i + return Account.find_by(id: ambassador) unless ambassador.zero? + + ambassador = ENV['AMBASSADOR_USER'] + return if ambassador.blank? + + Account.find_local(ambassador) + end + + def next_boost + ambassador_boost_candidates.first + end + + def ambassador_boost_candidates + ambassador_boostable.joins(:status_stat).where('favourites_count + reblogs_count > 4') + end + + def ambassador_boostable + ambassador_unboosted.excluding_silenced_accounts.not_excluded_by_account(@ambassador) + end + + def ambassador_unboosted + locally_boostable.where.not(id: ambassador_boosts) + end + + def ambassador_boosts + @ambassador.statuses.where('statuses.reblog_of_id IS NOT NULL').reorder(nil).select(:reblog_of_id) + end + + def locally_boostable + Status.local + .public_visibility + .without_replies + .without_reblogs + .where('statuses.created_at > ?', 18.weeks.ago) + end +end diff --git a/app/workers/scheduler/database_cleanup_scheduler.rb b/app/workers/scheduler/database_cleanup_scheduler.rb new file mode 100644 index 000000000..033556099 --- /dev/null +++ b/app/workers/scheduler/database_cleanup_scheduler.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Scheduler::DatabaseCleanupScheduler + include Sidekiq::Worker + + sidekiq_options lock: :until_executed, retry: 0 + + def perform + Conversation.left_outer_joins(:statuses).where(statuses: { id: nil }).destroy_all + Tag.left_outer_joins(:statuses).where(statuses: { id: nil }).destroy_all + StatusStat.left_outer_joins(:status).where(statuses: { id: nil }).destroy_all + Setting.rewhere(thing_type: 'User').where.not(thing_id: User.select(:id)).destroy_all + end +end diff --git a/app/workers/scheduler/publish_status_scheduler.rb b/app/workers/scheduler/publish_status_scheduler.rb new file mode 100644 index 000000000..27fac39e1 --- /dev/null +++ b/app/workers/scheduler/publish_status_scheduler.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Scheduler::PublishStatusScheduler + include Sidekiq::Worker + + sidekiq_options lock: :until_executed, retry: 0 + + def perform + Status.ready_to_publish.find_each { |status| PublishStatusService.new.call(status) } + end +end diff --git a/app/workers/scheduler/status_cleanup_scheduler.rb b/app/workers/scheduler/status_cleanup_scheduler.rb new file mode 100644 index 000000000..161818355 --- /dev/null +++ b/app/workers/scheduler/status_cleanup_scheduler.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Scheduler::StatusCleanupScheduler + include Sidekiq::Worker + + sidekiq_options lock: :until_executed, retry: 0 + + def perform + Status.with_discarded.expired.find_each do |status| + RemoveStatusService.new.call(status, unpublish: !(status.discarded? || status.account&.user&.setting_unpublish_delete)) + end + end +end diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb index 6113edde1..dade63028 100644 --- a/app/workers/scheduler/user_cleanup_scheduler.rb +++ b/app/workers/scheduler/user_cleanup_scheduler.rb @@ -10,5 +10,10 @@ class Scheduler::UserCleanupScheduler Account.where(id: batch.map(&:account_id)).delete_all User.where(id: batch.map(&:id)).delete_all end + + User.where(kobold: '', approved: false).find_in_batches do |batch| + Account.where(id: batch.map(&:account_id)).delete_all + User.where(id: batch.map(&:id)).delete_all + end end end diff --git a/app/workers/softblock_worker.rb b/app/workers/softblock_worker.rb new file mode 100644 index 000000000..a4624868c --- /dev/null +++ b/app/workers/softblock_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class SoftblockWorker + include Sidekiq::Worker + + def perform(account_id, target_account_id) + account = Account.find(account_id) + target_account = Account.find(target_account_id) + + BlockService.new.call(account, target_account, softblock: true) + sleep 1 + UnblockService.new.call(account, target_account) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb index 8bba9ca75..a1915a16f 100644 --- a/app/workers/thread_resolve_worker.rb +++ b/app/workers/thread_resolve_worker.rb @@ -6,13 +6,16 @@ class ThreadResolveWorker sidekiq_options queue: 'pull', retry: 3 - def perform(child_status_id, parent_url) + def perform(child_status_id, parent_url, on_behalf_of = nil) child_status = Status.find(child_status_id) - parent_status = FetchRemoteStatusService.new.call(parent_url) + on_behalf_of = child_status.account.followers.local.random.first if on_behalf_of.nil? && !child_status.distributable? + parent_status = FetchRemoteStatusService.new.call(parent_url, nil, on_behalf_of) return if parent_status.nil? child_status.thread = parent_status child_status.save! + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound + nil end end diff --git a/config/application.rb b/config/application.rb index ad6cf82d7..66f14061a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -45,6 +45,7 @@ module Mastodon # All translations from config/locales/*.rb,yml are auto loaded. # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] config.i18n.available_locales = [ + :'en-MP', :ar, :ast, :bg, @@ -116,10 +117,11 @@ module Mastodon :'zh-TW', ] - config.i18n.default_locale = ENV['DEFAULT_LOCALE']&.to_sym + config.i18n.default_locale = ENV.fetch('DEFAULT_LOCALE', 'en-MP')&.to_sym + config.i18n.fallbacks = [:'en-MP', :en] unless config.i18n.available_locales.include?(config.i18n.default_locale) - config.i18n.default_locale = :en + config.i18n.default_locale = :'en-MP' end # config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb') diff --git a/config/environments/development.rb b/config/environments/development.rb index 0791b82ab..a1a9bad5b 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -7,7 +7,7 @@ Rails.application.configure do config.cache_classes = false # Do not eager load code on boot. - config.eager_load = false + config.eager_load = true # Show full error reports. config.consider_all_requests_local = true diff --git a/config/environments/production.rb b/config/environments/production.rb index c2e8210f8..e5900a0bb 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -60,7 +60,7 @@ Rails.application.configure do # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # English when a translation cannot be found). - config.i18n.fallbacks = [:en] + config.i18n.fallbacks = [:'en-MP', :en] # Send deprecation notices to registered listeners. config.active_support.deprecation = :notify diff --git a/config/environments/test.rb b/config/environments/test.rb index a35cadcfa..7bdfe15e7 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -54,8 +54,8 @@ Rails.application.configure do # Raises error for missing translations # config.action_view.raise_on_missing_translations = true - config.i18n.default_locale = :en - config.i18n.fallbacks = true + config.i18n.default_locale = :'en-MP' + config.i18n.fallbacks = [:'en-MP', :en] end Paperclip::Attachment.default_options[:path] = "#{Rails.root}/spec/test_files/:class/:id_partition/:style.:extension" diff --git a/config/initializers/2_whitelist_mode.rb b/config/initializers/2_whitelist_mode.rb index 1cc6a8e72..3ac6d7a09 100644 --- a/config/initializers/2_whitelist_mode.rb +++ b/config/initializers/2_whitelist_mode.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true Rails.application.configure do - config.x.whitelist_mode = (ENV['LIMITED_FEDERATION_MODE'] || ENV['WHITELIST_MODE']) == 'true' + config.x.whitelist_mode = true end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 63cff7c59..1c790e90a 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -76,6 +76,10 @@ Doorkeeper.configure do :'write:notifications', :'write:reports', :'write:statuses', + :'write:statuses:publish', + :'write:domain_permissions', + :'write:domain_permissions:account', + :'write:domain_permissions:statuses', :read, :'read:accounts', :'read:blocks', @@ -88,11 +92,16 @@ Doorkeeper.configure do :'read:notifications', :'read:search', :'read:statuses', + :'read:domain_permissions', + :'read:domain_permissions:account', + :'read:domain_permissions:statuses', :follow, :push, :'admin:read', :'admin:read:accounts', :'admin:read:reports', + :'admin:read:domain_blocks', + :'admin:read:domain_allows', :'admin:write', :'admin:write:accounts', :'admin:write:reports', diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ebb7541eb..4170b69ba 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -22,4 +22,6 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'Ed25519' inflect.singular 'data', 'data' + + inflect.irregular 'publish', 'publishing' end diff --git a/config/initializers/locale.rb b/config/initializers/locale.rb index fed182a71..aa271dc56 100644 --- a/config/initializers/locale.rb +++ b/config/initializers/locale.rb @@ -5,3 +5,4 @@ I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'flavours', '*', 'nam I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names.{rb,yml}').to_s] I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names', '*.{rb,yml}').to_s] I18n.load_path += Dir[Rails.root.join('config', 'locales-glitch', '*.{rb,yml}').to_s] +I18n.load_path += Dir[Rails.root.join('config', 'locales-monsterfork', '*.{rb,yml}').to_s] diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index f2733562f..2ed0554de 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -15,8 +15,8 @@ Sidekiq.configure_server do |config| end config.death_handlers << lambda do |job, _ex| - digest = job['lock_digest'] - SidekiqUniqueJobs::Digests.delete_by_digest(digest) if digest + SidekiqUniqueJobs::Digests.delete_by_digest(job['lock_digest']) if job['lock_digest'] + SidekiqUniqueJobs::Digests.delete_by_digest(job['unique_digest']) if job['unique_digest'] end end diff --git a/config/locales/en-MP.yml b/config/locales/en-MP.yml new file mode 100644 index 000000000..d964e5c03 --- /dev/null +++ b/config/locales/en-MP.yml @@ -0,0 +1,164 @@ +--- +en-MP: + about: + about_hashtag_html: These are public roars tagged with <strong>#%{hashtag}</strong> from around the fediverse. + available_content: Connected servers + available_content_html: 'Users and content from these servers can be interacted with from here:' + browse_local_posts: Browse a live stream of public roars from Monsterpit + browse_public_posts: Browse a live stream of public roars on the fediverse + federation_hint_html: Join Monsterpit and meet creatures around the fediverse. + hosted_on: Monsterfork hosted on %{domain} + instance_actor_flash: This account is a virtual actor used to represent the server itself. It is used for federation purposes and should not be blocked unless you want to block the whole server, in which case you should use a domain block. + unavailable_content: Admin overrides + unavailable_content_description: + silenced: "Posts from these servers will be hidden in public timelines and no notifications will be generated from their users' interactions, unless you are following them or vise-versa. These are typically set for curation purposes rather than " + accounts: + endorsements_hint: You can endorse creatures you follow from the web interface, and they will show up here. + people_followed_by: Creatures whom %{name} follows + people_who_follow: Creatures who follow %{name} + pin_errors: + following: You must be already following the creature you want to endorse + posts: + one: Roar + other: Roars + posts_tab_heading: Blog + posts_with_replies: Replies + reblogs: Boosts + threads: Threads + mentions: Mentions + admin: + accounts: + search_same_email_domain: Other creatures with the same e-mail domain + search_same_ip: Other creatures with the same IP + action_logs: + actions: + update_status: "%{name} updated roar by %{target}" + deleted_status: "(deleted roar)" + dashboard: + pending_users: creatures waiting for review + feature_hcaptcha: hCaptcha + recent_users: Recent creatures + single_user_mode: Single creature mode + total_users: creatures in total + week_users_new: creatures this week + domain_allows: + hidden: Exclude from public server list + relays: + description_html: A <strong>federation relay</strong> is an intermediary server that exchanges large volumes of public roars between servers that subscribe and publish to it. <strong>It can help small and medium servers discover content from the fediverse</strong>, which would otherwise require local users manually following other people on remote servers. + enable_hint: Once enabled, your server will subscribe to all public roars from this relay, and will begin sending this server's public toots to it. + settings: + activity_api_enabled: + desc_html: Counts of locally posted roars, active creatures, and new registrations in weekly buckets + title: Publish aggregate statistics about creature activity + bootstrap_timeline_accounts: + title: Default follows for new creatures + default_noindex: + desc_html: Affects all creatures who have not changed this setting themselves + title: Opt creatures out of search engine indexing by default + domain_allows: + title: Show allowed domains + domain_blocks: + users: To logged-in local creatures + enable_bootstrap_timeline_accounts: + title: Enable default follows for new creatures + profile_directory: + desc_html: Allow creatures to be discoverable + registrations: + errors: + captcha_fail: Captcha verification failed + show_staff_badge: + desc_html: Display staff badges on profiles + appearance: + toot_layout: Roar layout + custom_css: Custom CSS + custom_css_error: "There are problems with the above CSS that must be fixed before it can be applied:" + auth: + description: + prefix_invited_by_user: "@%{name} invites you to join Monsterpit!" + prefix_sign_up: Roar with Monsterpit! + suffix: On Monsterpit, you'll be able to commune with creatures across the fediverse! + authorize_follow: + already_following: You are already following this creature + already_requested: You have already sent a follow request to that creature + error: Unfortunately, there was an error looking up that creature's account + post_follow: + return: Show the creature's profile + domain_permissions: + success: Domain permissions saved! + existing_username_validator: + not_found: could not find a local creature with that username + exports: + archive_takeout: + hint_html: You can request an archive of your <strong>roars and media</strong>. The exported data will be in the ActivityPub format, readable by any compliant software. You can request an archive every 7 days. + notification_mailer: + favourite: + body: 'Your status was admired by %{name}:' + subject: "%{name} admired your roar" + title: New admiration + reblog: + body: 'Your roar was boosted by %{name}:' + subject: "%{name} boosted your roar" + title: New roar + preferences: + advanced_publishing: Advanced publishing options + filtering: Filtering options + publishing: Advanced publishing + remote_interaction: + favourite: + proceed: Proceed to admire + prompt: 'You want to admire this roar:' + reblog: + proceed: Proceed to boost + prompt: 'You want to boost this roar:' + reply: + proceed: Proceed to reply + prompt: 'You want to reply to this roar:' + scheduled_statuses: + over_daily_limit: You have exceeded the limit of %{limit} scheduled roars for that day + over_total_limit: You have exceeded the limit of %{limit} scheduled roars + stream_entries: + pinned: '' + reblogged: '' + pin_errors: + limit: You have already pinned the maximum number of roars + ownership: Someone else's roar cannot be pinned + private: Non-public roar cannot be pinned + settings: + monsterfork: Monsterfork + profiles: + privacy: Privacy + privacy_html: These options allow you to adjust how much information is visible on your public profile on Monsterpit. <strong>Be aware that other servers you send your roars to have their own profile systems and may not honor these options. You will need to use <em>followers-only</em> or <em>direct</em> privacy for roars you do not want displayed in other servers' public profiles.</strong> + advanced_privacy: Advanced privacy + advanced_privacy_html: These options can increase your privacy at the expense of compatability with other servers. <strong>They can potentially cause roars to not be delivered to some of your followers. Only enable them if you're fully aware of their side effects.</strong> + timer: + '0': Never + 1: 1 minute + 2: 2 minutes + 3: 3 minutes + 5: 5 minutes + 10: 10 minutes + 15: 15 minutes + 30: 30 minutes + 60: 1 hour + 120: 2 hours + 180: 3 hours + 360: 6 hours + 720: 12 hours + 1440: 1 day + 2880: 2 days + 4320: 3 days + 7200: 5 days + 10080: 1 week + 20160: 2 weeks + 30240: 3 weeks + 60480: 6 weeks + 120960: 12 weeks + 181440: 18 weeks + 241920: 24 weeks + 362880: 36 weeks + 524160: 52 weeks + user_mailer: + warning: + explanation: + silence: While your account is limited, only creatures who are already following you will see your roars on this server, and you may be excluded from various public listings. However, others may still manually follow you. + suspend: Your account has been suspended, and all of your roars and your uploaded media files have been irreversibly removed from this server, and servers where you had followers. diff --git a/config/locales/simple_form.en-MP.yml b/config/locales/simple_form.en-MP.yml new file mode 100644 index 000000000..6987e4492 --- /dev/null +++ b/config/locales/simple_form.en-MP.yml @@ -0,0 +1,80 @@ +--- +en-MP: + simple_form: + hints: + account_warning_preset: + text: You can use roar syntax, such as URLs, hashtags and mentions + admin_account_action: + include_statuses: The creature will see which roars have caused the moderation action or warning + send_email_notification: The creature will receive an explanation of what happened with their account + text_html: Optional. You can use roar syntax. You can <a href="%{path}">add warning presets</a> to save time + announcement: + text: You can use roar syntax. Please be mindful of the space the announcement will take up on the user's screen + defaults: + irreversible: Filtered roars will disappear irreversibly, even if filter is later removed + phrase: Will be matched regardless of casing in text or content warning of a roar + private: Only allow authenticated followers to view your local profile. + require_auth: Require viewers to log in to access your profile, roars, and threads from Monsterpit. + require_dereference_html: "When enabled, Monsterpit will deliver your roars to other servers as pointers and require an authenticated request to access their (non-public) content. This allows permissions and blocks you've set to be enforced more stringently. <strong>This feature will make your roars inaccessible from Mastodon servers older than 3.2.0.</strong>" + setting_aggregate_reblogs: Do not show new boosts for roars that have been recently boosted (only affects newly-received boosts) + setting_default_content_type_html: When composing roars, assume they are written in raw HTML, unless specified otherwise + setting_default_content_type_markdown: When composing roars, assume they are using Markdown for rich text formatting, unless specified otherwise + setting_default_content_type_plain: When composing roars, assume they are plain text with no special formatting, unless specified otherwise (default) + setting_default_content_type_html_html: "<strong><strong>Bold</strong></strong>, <u><u>Underline</u></u>, <em><em>Italic</em></em>, <code><code>Console</code></code>, ..." + setting_default_content_type_markdown_html: "<strong>**Bold**</strong>, <u>_Underline_</u>, <em>*Italic*</em>, <code>`Console`</code>, ..." + setting_default_content_type_plain_html: No formatting. + setting_default_content_type_console_html: <code>Plain-text console formatting.</code> + setting_default_content_type_bbcode_html: "<strong>[b]Bold[/b]</strong>, <u>[u]Underline[/u]</u>, <em>[i]Italic[/i]</em>, <code>[code]Console[/code]</code>, ..." + setting_default_language: The language of your roars can be detected automatically, but it's not always accurate + setting_filter_to_unknown: Do not show replies to unfollowed accounts on your home timeline. Takes effect for newly-pushed items. + setting_filter_from_unknown: Do not show boosts from unfollowed accounts on your home timeline. Takes effect for newly-pushed items. + setting_manual_publish: This allows you to draft, proofread, and edit your roars before publishing them. You can publish a roar from its <strong>action menu</strong> (the three dots). + setting_show_application: The application you use to toot will be displayed in the detailed view of your roars + setting_skin: Reskins the selected UI flavour + setting_unpublish_on_delete: When enabled, deleting a published roar will unpublish it then make it local-only. Deleting an unpublished roar will permanently destroy it. + show_replies: Disable if you'd prefer your replies not be a part of your public profile + show_unlisted: Disable if you'd prefer to only show unlisted roars on your profile page to visitors who are logged-in or are your followers. + text: This helps us determine if registrations are made in sincerity and prevents spam. It is only visible to admins. + user: + chosen_languages: When checked, only roars in selected languages will be displayed in public timelines + labels: + admin_account_action: + include_statuses: Include reported roars in the e-mail + defaults: + bot: This is an automated account + private: Private mode + require_auth: Disallow anonymous access + require_dereference: Indirect federation mode + setting_crop_images: Crop images in non-expanded roars to 16x9 + setting_default_content_type: Default format for roars + setting_default_language: Roar language + setting_default_privacy: Roar privacy + setting_delete_modal: Show confirmation dialog before deleting a roar + setting_display_media_hide_all: Hide all + setting_display_media_show_all: Reveal all + setting_expand_spoilers: Always expand roars marked with content warnings + setting_favourite_modal: Show confirmation dialog before admiring (applies to Glitch flavour only) + setting_filter_to_unknown: Filter replies to unfollowed accounts + setting_filter_from_unknown: Filter boosts from unfollowed accounts + setting_manual_publish: Manually publish roars + setting_publish_in: Auto-publish + setting_show_application: Disclose application used to send roars + setting_style_css_profile: Custom CSS for profile page + setting_style_css_webapp: Custom CSS for web interface + setting_style_dashed_nest: Use dashed nest level indicators + setting_style_underline_a: Underline hyperlinks + setting_style_wide_media: Wide media attachments + setting_boost_every: Automatically queue and space out boosts + setting_boost_jitter: Add a random delay up to + setting_boost_random: Boost in random order + setting_unpublish_delete: Delete after unpublishing + setting_unpublish_in: Then unpublish after + setting_unpublish_on_delete: Unpublish on delete + setting_use_pending_items: Relax mode + show_replies: Show replies on profile + show_unlisted: Show unlisted roars to anonymous visitors + username_confirmation: Confirm your username + invite_request: + text: "Introduce yourself and let the admins know what brings you to Monsterpit." + notification_emails: + favourite: Someone admired your roar diff --git a/config/navigation.rb b/config/navigation.rb index c82dfbb6d..7f292af3f 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -13,6 +13,8 @@ SimpleNavigation::Configuration.run do |navigation| n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_url, if: -> { current_user.functional? } do |s| s.item :appearance, safe_join([fa_icon('desktop fw'), t('settings.appearance')]), settings_preferences_appearance_url s.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_preferences_notifications_url + s.item :filters, safe_join([fa_icon('filter fw'), t('preferences.filters')]), settings_preferences_filters_url + s.item :publishing, safe_join([fa_icon('pencil-square-o fw'), t('preferences.publishing')]), settings_preferences_publishing_url s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_url end @@ -45,7 +47,7 @@ SimpleNavigation::Configuration.run do |navigation| s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts|/admin/pending_accounts} s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags} - s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? } + s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url, highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? } s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? } end diff --git a/config/routes.rb b/config/routes.rb index 48d2718c8..a6b9f6981 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,9 @@ Rails.application.routes.draw do get 'intent', to: 'intents#show' get 'custom.css', to: 'custom_css#show', as: :custom_css + get '/custom/:id/profile.css', to: 'user_profile_css#show', as: :user_profile_css + get '/custom/:id/webapp.css', to: 'user_webapp_css#show', as: :user_webapp_css + resource :instance_actor, path: 'actor', only: [:show] do resource :inbox, only: [:create], module: :activitypub resource :outbox, only: [:show], module: :activitypub @@ -52,10 +55,10 @@ Rails.application.routes.draw do devise_for :users, path: 'auth', controllers: { omniauth_callbacks: 'auth/omniauth_callbacks', - sessions: 'auth/sessions', - registrations: 'auth/registrations', - passwords: 'auth/passwords', - confirmations: 'auth/confirmations', + sessions: 'auth/sessions', + registrations: 'auth/registrations', + passwords: 'auth/passwords', + confirmations: 'auth/confirmations', } get '/users/:username', to: redirect('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? } @@ -88,8 +91,11 @@ Rails.application.routes.draw do resource :inbox, only: [:create], module: :activitypub get '/@:username', to: 'accounts#show', as: :short_account + get '/@:username/threads', to: 'accounts#show', as: :short_account_threads get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies get '/@:username/media', to: 'accounts#show', as: :short_account_media + get '/@:username/reblogs', to: 'accounts#show', as: :short_account_reblogs + get '/@:username/mentions', to: 'accounts#show', as: :short_account_mentions get '/@:username/tagged/:tag', to: 'accounts#show', as: :short_account_tag get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status @@ -113,6 +119,8 @@ Rails.application.routes.draw do resource :appearance, only: [:show, :update], controller: :appearance resource :notifications, only: [:show, :update] resource :other, only: [:show, :update], controller: :other + resource :filters, only: [:show, :update], controller: :filters + resource :publishing, only: [:show, :update], controller: :publishing end resource :import, only: [:show, :create] @@ -307,7 +315,7 @@ Rails.application.routes.draw do # JSON / REST API namespace :v1 do - resources :statuses, only: [:create, :show, :destroy] do + resources :statuses, only: [:create, :update, :show, :destroy] do scope module: :statuses do resources :reblogged_by, controller: :reblogged_by_accounts, only: :index resources :favourited_by, controller: :favourited_by_accounts, only: :index @@ -320,11 +328,16 @@ Rails.application.routes.draw do resource :bookmark, only: :create post :unbookmark, to: 'bookmarks#destroy' - resource :mute, only: :create + resource :mute, only: [:create, :update] post :unmute, to: 'mutes#destroy' resource :pin, only: :create post :unpin, to: 'pins#destroy' + + resource :hide, only: :create + post :unhide, to: 'mutes#destroy' + + resource :publish, only: :create end member do @@ -408,6 +421,8 @@ Rails.application.routes.draw do resource :domain_blocks, only: [:show, :create, :destroy] resource :directory, only: [:show] + resource :domain_permissions, only: [:show, :create, :update, :destroy] + resources :follow_requests, only: [:index] do member do post :authorize @@ -486,6 +501,9 @@ Rails.application.routes.draw do resource :action, only: [:create], controller: 'account_actions' end + resource :domain_blocks, only: [:show] + resource :domain_allows, only: [:show] + resources :reports, only: [:index, :show] do member do post :assign_to_self @@ -516,7 +534,7 @@ Rails.application.routes.draw do get '/web/(*any)', to: 'home#index', as: :web get '/about', to: 'about#show' - get '/about/more', to: 'about#more' + get '/about/more', to: redirect('/about') get '/terms', to: 'about#terms' match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false diff --git a/config/settings.yml b/config/settings.yml index c61454e9e..be9fe093a 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -79,6 +79,8 @@ defaults: &defaults show_domain_blocks_rationale: 'disabled' outgoing_spoilers: '' + show_domain_allows: 'disabled' + development: <<: *defaults diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 5de25de23..e1b99ac99 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -36,3 +36,15 @@ pghero_scheduler: cron: '0 0 * * *' class: Scheduler::PgheroScheduler + ambassador_scheduler: + every: '<%= ENV['AMBASSADOR_DELAY'] || 10 %>m' + class: Scheduler::AmbassadorScheduler + database_cleanup_scheduler: + every: '1d' + class: Scheduler::DatabaseCleanupScheduler + status_cleanup_scheduler: + every: '1m' + class: Scheduler::StatusCleanupScheduler + publish_status_scheduler: + every: '1m' + class: Scheduler::PublishStatusScheduler \ No newline at end of file diff --git a/db/migrate/20200628105849_add_hidden_to_domain_allows.rb b/db/migrate/20200628105849_add_hidden_to_domain_allows.rb new file mode 100644 index 000000000..8fd4b79cc --- /dev/null +++ b/db/migrate/20200628105849_add_hidden_to_domain_allows.rb @@ -0,0 +1,7 @@ +class AddHiddenToDomainAllows < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :domain_allows, :hidden, :boolean, default: false, allow_null: false + end + end +end diff --git a/db/migrate/20200630222227_add_edited_to_statuses.rb b/db/migrate/20200630222227_add_edited_to_statuses.rb new file mode 100644 index 000000000..c0a5abb97 --- /dev/null +++ b/db/migrate/20200630222227_add_edited_to_statuses.rb @@ -0,0 +1,10 @@ +class AddEditedToStatuses < ActiveRecord::Migration[5.2] + def up + add_column :statuses, :edited, :int + change_column_default :statuses, :edited, 0 + end + + def down + remove_column :statuses, :edited + end +end diff --git a/db/migrate/20200630222517_backfill_default_statuses_edited.rb b/db/migrate/20200630222517_backfill_default_statuses_edited.rb new file mode 100644 index 000000000..cbcbd600b --- /dev/null +++ b/db/migrate/20200630222517_backfill_default_statuses_edited.rb @@ -0,0 +1,14 @@ +class BackfillDefaultStatusesEdited < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + Rails.logger.info('Backfilling "edited" column of table "statuses" to default value 0...') + Status.unscoped.in_batches do |statuses| + statuses.update_all(edited: 0) + end + end + + def down + true + end +end diff --git a/db/migrate/20200702032702_add_conversation_id_index_to_statuses.rb b/db/migrate/20200702032702_add_conversation_id_index_to_statuses.rb new file mode 100644 index 000000000..f35a2fc99 --- /dev/null +++ b/db/migrate/20200702032702_add_conversation_id_index_to_statuses.rb @@ -0,0 +1,7 @@ +class AddConversationIdIndexToStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + safety_assured { add_index :statuses, :conversation_id, where: 'deleted_at IS NULL', algorithm: :concurrently, name: :index_statuses_on_conversation_id } + end +end diff --git a/db/migrate/20200706171939_add_not_null_to_monsterfork_additions.rb b/db/migrate/20200706171939_add_not_null_to_monsterfork_additions.rb new file mode 100644 index 000000000..40b62f93a --- /dev/null +++ b/db/migrate/20200706171939_add_not_null_to_monsterfork_additions.rb @@ -0,0 +1,11 @@ +class AddNotNullToMonsterforkAdditions < ActiveRecord::Migration[5.2] + def change + safety_assured do + Rails.logger.info("Setting NOT NULL on domain_allows.hidden") + change_column_null :domain_allows, :hidden, false + + Rails.logger.info("Setting NOT NULL on statuses.edited") + change_column_null :statuses, :edited, false + end + end +end diff --git a/db/migrate/20200717014609_add_nest_level_to_statuses.rb b/db/migrate/20200717014609_add_nest_level_to_statuses.rb new file mode 100644 index 000000000..0b2196ad6 --- /dev/null +++ b/db/migrate/20200717014609_add_nest_level_to_statuses.rb @@ -0,0 +1,7 @@ +class AddNestLevelToStatuses < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :statuses, :nest_level, :integer, limit: 1, null: false, default: 0 + end + end +end diff --git a/db/migrate/20200718011317_add_require_dereference_to_accounts.rb b/db/migrate/20200718011317_add_require_dereference_to_accounts.rb new file mode 100644 index 000000000..9fcabd891 --- /dev/null +++ b/db/migrate/20200718011317_add_require_dereference_to_accounts.rb @@ -0,0 +1,7 @@ +class AddRequireDereferenceToAccounts < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :accounts, :require_dereference, :boolean, null: false, default: false + end + end +end diff --git a/db/migrate/20200719024610_add_show_replies_to_accounts.rb b/db/migrate/20200719024610_add_show_replies_to_accounts.rb new file mode 100644 index 000000000..ac6c5906b --- /dev/null +++ b/db/migrate/20200719024610_add_show_replies_to_accounts.rb @@ -0,0 +1,7 @@ +class AddShowRepliesToAccounts < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :accounts, :show_replies, :boolean, null: false, default: true + end + end +end diff --git a/db/migrate/20200719033609_add_show_unlisted_to_accounts.rb b/db/migrate/20200719033609_add_show_unlisted_to_accounts.rb new file mode 100644 index 000000000..a9bb16720 --- /dev/null +++ b/db/migrate/20200719033609_add_show_unlisted_to_accounts.rb @@ -0,0 +1,7 @@ +class AddShowUnlistedToAccounts < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :accounts, :show_unlisted, :boolean, null: false, default: true + end + end +end diff --git a/db/migrate/20200719114344_add_timelines_only_to_mute.rb b/db/migrate/20200719114344_add_timelines_only_to_mute.rb new file mode 100644 index 000000000..20bbfcd59 --- /dev/null +++ b/db/migrate/20200719114344_add_timelines_only_to_mute.rb @@ -0,0 +1,7 @@ +class AddTimelinesOnlyToMute < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :mutes, :timelines_only, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20200719181947_add_published_to_statuses.rb b/db/migrate/20200719181947_add_published_to_statuses.rb new file mode 100644 index 000000000..129840a0c --- /dev/null +++ b/db/migrate/20200719181947_add_published_to_statuses.rb @@ -0,0 +1,7 @@ +class AddPublishedToStatuses < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :statuses, :published, :boolean, default: true, null: false + end + end +end diff --git a/db/migrate/20200719184152_add_unpublished_index_to_statuses.rb b/db/migrate/20200719184152_add_unpublished_index_to_statuses.rb new file mode 100644 index 000000000..ee6d3e942 --- /dev/null +++ b/db/migrate/20200719184152_add_unpublished_index_to_statuses.rb @@ -0,0 +1,7 @@ +class AddUnpublishedIndexToStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + add_index :statuses, [:account_id, :id], where: '(deleted_at IS NULL) AND (published = FALSE)', order: { id: :desc }, algorithm: :concurrently, name: :index_unpublished_statuses + end +end diff --git a/db/migrate/20200720211530_add_hidden_to_conversation_mute.rb b/db/migrate/20200720211530_add_hidden_to_conversation_mute.rb new file mode 100644 index 000000000..aa7b31d8b --- /dev/null +++ b/db/migrate/20200720211530_add_hidden_to_conversation_mute.rb @@ -0,0 +1,7 @@ +class AddHiddenToConversationMute < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :conversation_mutes, :hidden, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20200720212317_create_status_mutes.rb b/db/migrate/20200720212317_create_status_mutes.rb new file mode 100644 index 000000000..efd8f15c8 --- /dev/null +++ b/db/migrate/20200720212317_create_status_mutes.rb @@ -0,0 +1,10 @@ +class CreateStatusMutes < ActiveRecord::Migration[5.2] + def change + create_table :status_mutes do |t| + t.integer :account_id, null: false, index: true + t.bigint :status_id, null: false, index: true + end + + add_index :status_mutes, [:account_id, :status_id], unique: true + end +end diff --git a/db/migrate/20200721184347_limit_visibility_of_replies_to_private_statuses.rb b/db/migrate/20200721184347_limit_visibility_of_replies_to_private_statuses.rb new file mode 100644 index 000000000..d22242bdd --- /dev/null +++ b/db/migrate/20200721184347_limit_visibility_of_replies_to_private_statuses.rb @@ -0,0 +1,13 @@ +class LimitVisibilityOfRepliesToPrivateStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + Status.includes(:thread).where.not(visibility: :direct).where(reply: true).where('statuses.in_reply_to_account_id != statuses.account_id').find_each do |status| + status.update!(visibility: status.thread.visibility) unless status.thread.nil? || %w(public unlisted).include?(status.thread.visibility) || ['direct', 'limited', status.thread.visibility].include?(status.visibility) + end + end + + def down + true + end +end diff --git a/db/migrate/20200721195456_add_index_on_statuses_visibility.rb b/db/migrate/20200721195456_add_index_on_statuses_visibility.rb new file mode 100644 index 000000000..c45405e95 --- /dev/null +++ b/db/migrate/20200721195456_add_index_on_statuses_visibility.rb @@ -0,0 +1,7 @@ +class AddIndexOnStatusesVisibility < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + add_index :statuses, :visibility, where: 'deleted_at IS NULL', algorithm: :concurrently, name: :index_statuses_on_visibility + end +end diff --git a/db/migrate/20200721202723_add_account_id_to_conversations.rb b/db/migrate/20200721202723_add_account_id_to_conversations.rb new file mode 100644 index 000000000..afddf4823 --- /dev/null +++ b/db/migrate/20200721202723_add_account_id_to_conversations.rb @@ -0,0 +1,9 @@ +class AddAccountIdToConversations < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + safety_assured do + add_reference :conversations, :account, foreign_key: true, index: {algorithm: :concurrently} + end + end +end diff --git a/db/migrate/20200721212401_backfill_account_id_on_conversations.rb b/db/migrate/20200721212401_backfill_account_id_on_conversations.rb new file mode 100644 index 000000000..595fd8e52 --- /dev/null +++ b/db/migrate/20200721212401_backfill_account_id_on_conversations.rb @@ -0,0 +1,15 @@ +class BackfillAccountIdOnConversations < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + Rails.logger.info('Backfilling owners of conversation threads...') + safety_assured do + Conversation.left_outer_joins(:statuses).where(statuses: { id: nil }).in_batches.destroy_all + execute('UPDATE conversations SET account_id = s.account_id FROM (SELECT account_id, conversation_id FROM statuses WHERE NOT reply) AS s WHERE conversations.id = s.conversation_id') + end + end + + def down + true + end +end diff --git a/db/migrate/20200721221427_add_public_to_conversations.rb b/db/migrate/20200721221427_add_public_to_conversations.rb new file mode 100644 index 000000000..392bd7418 --- /dev/null +++ b/db/migrate/20200721221427_add_public_to_conversations.rb @@ -0,0 +1,7 @@ +class AddPublicToConversations < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :conversations, :public, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20200721221659_backfill_conversation_visibility.rb b/db/migrate/20200721221659_backfill_conversation_visibility.rb new file mode 100644 index 000000000..93394b825 --- /dev/null +++ b/db/migrate/20200721221659_backfill_conversation_visibility.rb @@ -0,0 +1,15 @@ +class BackfillConversationVisibility < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + Rails.logger.info('Backfilling thread visibility...') + + safety_assured do + execute('UPDATE conversations SET public = true FROM (SELECT account_id, conversation_id FROM statuses WHERE NOT reply AND visibility IN (0, 1)) AS s WHERE conversations.id = s.conversation_id') + end + end + + def down + true + end +end diff --git a/db/migrate/20200723225552_add_title_to_statuses.rb b/db/migrate/20200723225552_add_title_to_statuses.rb new file mode 100644 index 000000000..16ae7264b --- /dev/null +++ b/db/migrate/20200723225552_add_title_to_statuses.rb @@ -0,0 +1,5 @@ +class AddTitleToStatuses < ActiveRecord::Migration[5.2] + def change + add_column :statuses, :title, :text + end +end diff --git a/db/migrate/20200724035808_add_inline_to_media_attachments.rb b/db/migrate/20200724035808_add_inline_to_media_attachments.rb new file mode 100644 index 000000000..171eca4b5 --- /dev/null +++ b/db/migrate/20200724035808_add_inline_to_media_attachments.rb @@ -0,0 +1,7 @@ +class AddInlineToMediaAttachments < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :media_attachments, :inline, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20200724045955_create_inline_media_attachments.rb b/db/migrate/20200724045955_create_inline_media_attachments.rb new file mode 100644 index 000000000..a894c3868 --- /dev/null +++ b/db/migrate/20200724045955_create_inline_media_attachments.rb @@ -0,0 +1,12 @@ +class CreateInlineMediaAttachments < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + create_table :inline_media_attachments do |t| + t.references :status, index: { algorithm: :concurrently }, foreign_key: { on_delete: :cascade } + t.references :media_attachment, index: { algorithm: :concurrently }, foreign_key: { on_delete: :cascade } + end + + add_index :inline_media_attachments, [:status_id, :media_attachment_id], unique: true, algorithm: :concurrently, name: 'uniq_index_on_status_and_attachment' + end +end diff --git a/db/migrate/20200725071818_create_status_domain_permissions.rb b/db/migrate/20200725071818_create_status_domain_permissions.rb new file mode 100644 index 000000000..e8faf3e00 --- /dev/null +++ b/db/migrate/20200725071818_create_status_domain_permissions.rb @@ -0,0 +1,13 @@ +class CreateStatusDomainPermissions < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + create_table :status_domain_permissions do |t| + t.references :status, null: false, index: { algorithm: :concurrently }, foreign_key: { on_delete: :cascade } + t.string :domain, null: false, default: '', index: { algorithm: :concurrently } + t.integer :visibility, null: false, default: 0, index: { algorithm: :concurrently } + end + + add_index :status_domain_permissions, [:status_id, :domain], unique: true, algorithm: :concurrently + end +end diff --git a/db/migrate/20200725080000_create_account_domain_permissions.rb b/db/migrate/20200725080000_create_account_domain_permissions.rb new file mode 100644 index 000000000..2497eda69 --- /dev/null +++ b/db/migrate/20200725080000_create_account_domain_permissions.rb @@ -0,0 +1,13 @@ +class CreateAccountDomainPermissions < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + create_table :account_domain_permissions do |t| + t.references :account, null: false, index: { algorithm: :concurrently }, foreign_key: { on_delete: :cascade } + t.string :domain, null: false, default: '', index: { algorithm: :concurrently } + t.integer :visibility, null: false, default: 0, index: { algorithm: :concurrently } + end + + add_index :account_domain_permissions, [:account_id, :domain], unique: true, algorithm: :concurrently + end +end diff --git a/db/migrate/20200726094737_add_semiprivate_to_statuses.rb b/db/migrate/20200726094737_add_semiprivate_to_statuses.rb new file mode 100644 index 000000000..facde265c --- /dev/null +++ b/db/migrate/20200726094737_add_semiprivate_to_statuses.rb @@ -0,0 +1,7 @@ +class AddSemiprivateToStatuses < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :statuses, :semiprivate, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20200726095058_backfill_semiprivate_on_statuses.rb b/db/migrate/20200726095058_backfill_semiprivate_on_statuses.rb new file mode 100644 index 000000000..69878ab94 --- /dev/null +++ b/db/migrate/20200726095058_backfill_semiprivate_on_statuses.rb @@ -0,0 +1,14 @@ +class BackfillSemiprivateOnStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + Rails.logger.info('Backfilling semiprivate statuses...') + safety_assured do + Status.where(id: StatusDomainPermission.select(:status_id).distinct(:status_id)).in_batches.update_all(semiprivate: true) + end + end + + def down + true + end +end diff --git a/db/migrate/20200728135753_add_original_text_to_statuses.rb b/db/migrate/20200728135753_add_original_text_to_statuses.rb new file mode 100644 index 000000000..6bf210191 --- /dev/null +++ b/db/migrate/20200728135753_add_original_text_to_statuses.rb @@ -0,0 +1,5 @@ +class AddOriginalTextToStatuses < ActiveRecord::Migration[5.2] + def change + add_column :statuses, :original_text, :text + end +end diff --git a/db/migrate/20200728171900_add_private_to_accounts.rb b/db/migrate/20200728171900_add_private_to_accounts.rb new file mode 100644 index 000000000..482d09576 --- /dev/null +++ b/db/migrate/20200728171900_add_private_to_accounts.rb @@ -0,0 +1,7 @@ +class AddPrivateToAccounts < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :accounts, :private, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20200728173757_add_require_auth_to_accounts.rb b/db/migrate/20200728173757_add_require_auth_to_accounts.rb new file mode 100644 index 000000000..00a3c1642 --- /dev/null +++ b/db/migrate/20200728173757_add_require_auth_to_accounts.rb @@ -0,0 +1,7 @@ +class AddRequireAuthToAccounts < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :accounts, :require_auth, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20200731064236_create_account_metadata.rb b/db/migrate/20200731064236_create_account_metadata.rb new file mode 100644 index 000000000..c2eb32b79 --- /dev/null +++ b/db/migrate/20200731064236_create_account_metadata.rb @@ -0,0 +1,10 @@ +class CreateAccountMetadata < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + create_table :account_metadata do |t| + t.references :account, null: false, unique: true, foreign_key: { on_delete: :cascade } + t.jsonb :fields, null: false, default: {} + end + end +end diff --git a/db/migrate/20200731135033_backfill_account_metadata.rb b/db/migrate/20200731135033_backfill_account_metadata.rb new file mode 100644 index 000000000..2ddfa6081 --- /dev/null +++ b/db/migrate/20200731135033_backfill_account_metadata.rb @@ -0,0 +1,11 @@ +class BackfillAccountMetadata < ActiveRecord::Migration[5.2] + def up + safety_assured do + execute("INSERT INTO account_metadata (account_id) SELECT id FROM accounts WHERE domain IS NULL OR domain = ''") + end + end + + def down + true + end +end diff --git a/db/migrate/20200731163700_create_destructing_statuses.rb b/db/migrate/20200731163700_create_destructing_statuses.rb new file mode 100644 index 000000000..4923eb393 --- /dev/null +++ b/db/migrate/20200731163700_create_destructing_statuses.rb @@ -0,0 +1,11 @@ +class CreateDestructingStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + create_table :destructing_statuses do |t| + t.references :status, null: false, unique: true, foreign_key: { on_delete: :cascade } + t.datetime :after, null: false, index: { algorithm: :concurrently } + t.boolean :defederate_only, null: false, default: false + end + end +end diff --git a/db/migrate/20200731205913_create_queued_boosts.rb b/db/migrate/20200731205913_create_queued_boosts.rb new file mode 100644 index 000000000..33ddbb966 --- /dev/null +++ b/db/migrate/20200731205913_create_queued_boosts.rb @@ -0,0 +1,10 @@ +class CreateQueuedBoosts < ActiveRecord::Migration[5.2] + def change + create_table :queued_boosts do |t| + t.references :account, null: false, foreign_key: { on_delete: :cascade } + t.references :status, null: false, foreign_key: { on_delete: :cascade } + end + + add_index :queued_boosts, [:account_id, :status_id], unique: true + end +end diff --git a/db/migrate/20200731211100_create_publishing_delays.rb b/db/migrate/20200731211100_create_publishing_delays.rb new file mode 100644 index 000000000..9561ca0b2 --- /dev/null +++ b/db/migrate/20200731211100_create_publishing_delays.rb @@ -0,0 +1,10 @@ +class CreatePublishingDelays < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + create_table :publishing_delays do |t| + t.references :status, null: false, unique: true, foreign_key: { on_delete: :cascade } + t.datetime :after, index: { algorithm: :concurrently } + end + end +end diff --git a/db/migrate/20200801210543_add_accounts_to_publishing_delays.rb b/db/migrate/20200801210543_add_accounts_to_publishing_delays.rb new file mode 100644 index 000000000..21f29aab8 --- /dev/null +++ b/db/migrate/20200801210543_add_accounts_to_publishing_delays.rb @@ -0,0 +1,9 @@ +class AddAccountsToPublishingDelays < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + safety_assured do + add_reference :publishing_delays, :account, null: false, foreign_key: { on_delete: :cascade }, index: { algorithm: :concurrently } + end + end +end diff --git a/db/migrate/20200801220000_add_accounts_to_destructing_statuses.rb b/db/migrate/20200801220000_add_accounts_to_destructing_statuses.rb new file mode 100644 index 000000000..42298b274 --- /dev/null +++ b/db/migrate/20200801220000_add_accounts_to_destructing_statuses.rb @@ -0,0 +1,9 @@ +class AddAccountsToDestructingStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + safety_assured do + add_reference :destructing_statuses, :account, null: false, foreign_key: { on_delete: :cascade }, index: { algorithm: :concurrently } + end + end +end diff --git a/db/migrate/20200811024642_update_status_indexes.rb b/db/migrate/20200811024642_update_status_indexes.rb new file mode 100644 index 000000000..264f583a4 --- /dev/null +++ b/db/migrate/20200811024642_update_status_indexes.rb @@ -0,0 +1,23 @@ +class UpdateStatusIndexes < ActiveRecord::Migration[5.2] + def up + safety_assured do + add_index :statuses, ["id", "account_id"], name: "index_statuses_local", order: { id: :desc }, where: "((published = TRUE) AND (local = TRUE OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((reply = FALSE) OR (in_reply_to_account_id = account_id)))" + add_index :statuses, ["id", "account_id"], name: "index_statuses_public", order: { id: :desc }, where: "((published = TRUE) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((reply = FALSE) OR (in_reply_to_account_id = account_id)))" + add_index :statuses, ["id", "account_id"], name: "index_statuses_local_reblogs", where: "(((local = TRUE) OR (uri IS NULL)) AND (statuses.reblog_of_id IS NOT NULL))" + + remove_index :statuses, name: "index_statuses_local_20190824" + remove_index :statuses, name: "index_statuses_public_20200119" + end + end + + def down + safety_assured do + add_index :statuses, ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" + add_index :statuses, ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" + + remove_index :statuses, name: "index_statuses_local" + remove_index :statuses, name: "index_statuses_local_reblogs" + remove_index :statuses, name: "index_statuses_public" + end + end +end diff --git a/db/migrate/20200816200108_add_root_to_conversations.rb b/db/migrate/20200816200108_add_root_to_conversations.rb new file mode 100644 index 000000000..f45a3b476 --- /dev/null +++ b/db/migrate/20200816200108_add_root_to_conversations.rb @@ -0,0 +1,7 @@ +class AddRootToConversations < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :conversations, :root, :string, index: true + end + end +end diff --git a/db/migrate/20200816200239_backfill_root_to_conversations.rb b/db/migrate/20200816200239_backfill_root_to_conversations.rb new file mode 100644 index 000000000..2056e0765 --- /dev/null +++ b/db/migrate/20200816200239_backfill_root_to_conversations.rb @@ -0,0 +1,19 @@ +class BackfillRootToConversations < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + Rails.logger.info("Adding URI to statuses without one...") + Status.where(uri: nil).or(Status.where(uri: '')).find_each do |status| + status.update(uri: ActivityPub::TagManager.instance.uri_for(status)) + end + + Rails.logger.info('Setting root of all conversations...') + safety_assured do + execute('UPDATE conversations SET root = s.uri FROM (SELECT conversation_id, uri FROM statuses WHERE NOT reply) AS s WHERE conversations.id = s.conversation_id') + end + end + + def down + true + end +end diff --git a/db/migrate/20200817003033_add_defaults_to_conversations.rb b/db/migrate/20200817003033_add_defaults_to_conversations.rb new file mode 100644 index 000000000..fc3c0ceee --- /dev/null +++ b/db/migrate/20200817003033_add_defaults_to_conversations.rb @@ -0,0 +1,8 @@ +class AddDefaultsToConversations < ActiveRecord::Migration[5.2] + def change + safety_assured do + change_column :conversations, :account_id, :bigint, default: nil + change_column :conversations, :root, :string, default: nil + end + end +end diff --git a/db/migrate/20200817003653_status_mute_account_id_bigint.rb b/db/migrate/20200817003653_status_mute_account_id_bigint.rb new file mode 100644 index 000000000..e46d17845 --- /dev/null +++ b/db/migrate/20200817003653_status_mute_account_id_bigint.rb @@ -0,0 +1,7 @@ +class StatusMuteAccountIdBigint < ActiveRecord::Migration[5.2] + def change + safety_assured do + change_column :status_mutes, :account_id, :bigint, null: false + end + end +end diff --git a/db/migrate/20200817225525_add_footer_to_statuses.rb b/db/migrate/20200817225525_add_footer_to_statuses.rb new file mode 100644 index 000000000..e85d225bc --- /dev/null +++ b/db/migrate/20200817225525_add_footer_to_statuses.rb @@ -0,0 +1,5 @@ +class AddFooterToStatuses < ActiveRecord::Migration[5.2] + def change + add_column :statuses, :footer, :text + end +end diff --git a/db/migrate/20200818040629_add_last_synced_at_to_accounts.rb b/db/migrate/20200818040629_add_last_synced_at_to_accounts.rb new file mode 100644 index 000000000..0d64b5109 --- /dev/null +++ b/db/migrate/20200818040629_add_last_synced_at_to_accounts.rb @@ -0,0 +1,5 @@ +class AddLastSyncedAtToAccounts < ActiveRecord::Migration[5.2] + def change + add_column :accounts, :last_synced_at, :datetime + end +end diff --git a/db/migrate/20200818160057_create_collection_items.rb b/db/migrate/20200818160057_create_collection_items.rb new file mode 100644 index 000000000..88796ce0e --- /dev/null +++ b/db/migrate/20200818160057_create_collection_items.rb @@ -0,0 +1,12 @@ +class CreateCollectionItems < ActiveRecord::Migration[5.2] + def change + create_table :collection_items do |t| + t.references :account, index: true, foreign_key: { on_delete: :cascade } + t.string :uri, null: false, index: { unique: true } + t.boolean :processed, null: false, default: false + end + + add_index :collection_items, :id, name: 'unprocessed_collection_item_ids', where: 'processed = FALSE', order: { id: :desc } + add_index :collection_items, :account_id, name: 'unprocessed_collection_item_account_ids', where: 'processed = FALSE' + end +end diff --git a/db/migrate/20200818160106_create_collection_pages.rb b/db/migrate/20200818160106_create_collection_pages.rb new file mode 100644 index 000000000..d00e1ca1c --- /dev/null +++ b/db/migrate/20200818160106_create_collection_pages.rb @@ -0,0 +1,13 @@ +class CreateCollectionPages < ActiveRecord::Migration[5.2] + def change + create_table :collection_pages do |t| + t.references :account, index: true, foreign_key: { on_delete: :cascade } + t.string :uri, null: false, index: { unique: true } + t.string :next + end + + add_index :collection_pages, :id, name: 'unprocessed_collection_page_ids', where: 'next IS NULL' + add_index :collection_pages, :account_id, name: 'unprocessed_collection_page_account_ids', where: 'next IS NULL' + add_index :collection_pages, :uri, name: 'unprocessed_collection_pages_uris', where: 'next IS NULL' + end +end diff --git a/db/migrate/20200821051721_add_retries_to_collection_items.rb b/db/migrate/20200821051721_add_retries_to_collection_items.rb new file mode 100644 index 000000000..9cee437d9 --- /dev/null +++ b/db/migrate/20200821051721_add_retries_to_collection_items.rb @@ -0,0 +1,5 @@ +class AddRetriesToCollectionItems < ActiveRecord::Migration[5.2] + def change + add_column :collection_items, :retries, :integer, limit: 1, default: 0, null: false + end +end diff --git a/db/migrate/20200822054516_remove_public_column_from_conversations.rb b/db/migrate/20200822054516_remove_public_column_from_conversations.rb new file mode 100644 index 000000000..e015f3f63 --- /dev/null +++ b/db/migrate/20200822054516_remove_public_column_from_conversations.rb @@ -0,0 +1,7 @@ +class RemovePublicColumnFromConversations < ActiveRecord::Migration[5.2] + def change + def safety_assured + remove_column :conversations, :public + end + end +end diff --git a/db/migrate/20200823002835_unlink_blocked_replies.rb b/db/migrate/20200823002835_unlink_blocked_replies.rb new file mode 100644 index 000000000..6968fc93f --- /dev/null +++ b/db/migrate/20200823002835_unlink_blocked_replies.rb @@ -0,0 +1,28 @@ +class UnlinkBlockedReplies < ActiveRecord::Migration[5.2] + def up + Block.find_each do |block| + next if block.account.nil? || block.target_account.nil? + + unlink_replies!(block.account, block.target_account) + unlink_mentions!(block.account, block.target_account) + end + end + + def down + nil + end + + private + + def unlink_replies!(account, target_account) + target_account.statuses.where(in_reply_to_account_id: account.id) + .or(account.statuses.where(in_reply_to_account_id: target_account.id)) + .in_batches.update_all(in_reply_to_account_id: nil) + end + + def unlink_mentions!(account, target_account) + account.mentions.where(account_id: target_account.id) + .or(target_account.mentions.where(account_id: account.id)) + .in_batches.destroy_all + end +end diff --git a/db/migrate/20200826125821_add_username_and_nospam_to_users.rb b/db/migrate/20200826125821_add_username_and_nospam_to_users.rb new file mode 100644 index 000000000..9a964b980 --- /dev/null +++ b/db/migrate/20200826125821_add_username_and_nospam_to_users.rb @@ -0,0 +1,6 @@ +class AddUsernameAndNospamToUsers < ActiveRecord::Migration[5.2] + def change + add_column :users, :username, :string + add_column :users, :kobold, :string + end +end diff --git a/db/migrate/20200901035527_add_sticky_to_account_domain_permissions.rb b/db/migrate/20200901035527_add_sticky_to_account_domain_permissions.rb new file mode 100644 index 000000000..2acfce329 --- /dev/null +++ b/db/migrate/20200901035527_add_sticky_to_account_domain_permissions.rb @@ -0,0 +1,7 @@ +class AddStickyToAccountDomainPermissions < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :account_domain_permissions, :sticky, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20200901183004_backfill_user_username.rb b/db/migrate/20200901183004_backfill_user_username.rb new file mode 100644 index 000000000..e206aaae8 --- /dev/null +++ b/db/migrate/20200901183004_backfill_user_username.rb @@ -0,0 +1,11 @@ +class BackfillUserUsername < ActiveRecord::Migration[5.2] + def up + User.find_each do |user| + user.update!(username: user.account.username) + end + end + + def down + nil + end +end diff --git a/db/migrate/20200904002209_add_expires_at_to_statuses.rb b/db/migrate/20200904002209_add_expires_at_to_statuses.rb new file mode 100644 index 000000000..53049b159 --- /dev/null +++ b/db/migrate/20200904002209_add_expires_at_to_statuses.rb @@ -0,0 +1,8 @@ +class AddExpiresAtToStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + add_column :statuses, :expires_at, :datetime + add_index :statuses, :expires_at, algorithm: :concurrently, where: 'expires_at IS NOT NULL' + end +end diff --git a/db/migrate/20200904004330_add_publish_at_to_statuses.rb b/db/migrate/20200904004330_add_publish_at_to_statuses.rb new file mode 100644 index 000000000..35a32eb0e --- /dev/null +++ b/db/migrate/20200904004330_add_publish_at_to_statuses.rb @@ -0,0 +1,8 @@ +class AddPublishAtToStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + add_column :statuses, :publish_at, :datetime + add_index :statuses, :publish_at, algorithm: :concurrently, where: 'publish_at IS NOT NULL' + end +end diff --git a/db/migrate/20200904005553_drop_publishing_delay.rb b/db/migrate/20200904005553_drop_publishing_delay.rb new file mode 100644 index 000000000..509e591c7 --- /dev/null +++ b/db/migrate/20200904005553_drop_publishing_delay.rb @@ -0,0 +1,5 @@ +class DropPublishingDelay < ActiveRecord::Migration[5.2] + def change + drop_table :publishing_delays + end +end diff --git a/db/migrate/20200904005706_drop_destructing_status.rb b/db/migrate/20200904005706_drop_destructing_status.rb new file mode 100644 index 000000000..39885aabd --- /dev/null +++ b/db/migrate/20200904005706_drop_destructing_status.rb @@ -0,0 +1,5 @@ +class DropDestructingStatus < ActiveRecord::Migration[5.2] + def change + drop_table :destructing_statuses + end +end diff --git a/db/migrate/20200904184045_add_originally_local_only_to_statuses.rb b/db/migrate/20200904184045_add_originally_local_only_to_statuses.rb new file mode 100644 index 000000000..abff57b45 --- /dev/null +++ b/db/migrate/20200904184045_add_originally_local_only_to_statuses.rb @@ -0,0 +1,7 @@ +class AddOriginallyLocalOnlyToStatuses < ActiveRecord::Migration[5.2] + def change + safety_assured do + add_column :statuses, :originally_local_only, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20200904184155_backfill_originally_local_only.rb b/db/migrate/20200904184155_backfill_originally_local_only.rb new file mode 100644 index 000000000..d87609db9 --- /dev/null +++ b/db/migrate/20200904184155_backfill_originally_local_only.rb @@ -0,0 +1,14 @@ +class BackfillOriginallyLocalOnly < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + safety_assured do + execute('UPDATE statuses SET originally_local_only = false WHERE originally_local_only IS NULL') + execute('UPDATE statuses SET originally_local_only = true WHERE local_only') + end + end + + def down + nil + end +end diff --git a/db/migrate/20200904200803_backfill_default_false_to_local_only.rb b/db/migrate/20200904200803_backfill_default_false_to_local_only.rb new file mode 100644 index 000000000..236a01c14 --- /dev/null +++ b/db/migrate/20200904200803_backfill_default_false_to_local_only.rb @@ -0,0 +1,13 @@ +class BackfillDefaultFalseToLocalOnly < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + safety_assured do + execute('UPDATE statuses SET local_only = false WHERE local_only IS NULL') + end + end + + def down + nil + end +end diff --git a/db/migrate/20200904201028_add_default_false_to_local_only.rb b/db/migrate/20200904201028_add_default_false_to_local_only.rb new file mode 100644 index 000000000..7f9bb99d4 --- /dev/null +++ b/db/migrate/20200904201028_add_default_false_to_local_only.rb @@ -0,0 +1,7 @@ +class AddDefaultFalseToLocalOnly < ActiveRecord::Migration[5.2] + def change + safety_assured do + change_column :statuses, :local_only, :boolean, default: false, null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b56aa109a..87cca4ea1 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: 2020_06_30_190544) do +ActiveRecord::Schema.define(version: 2020_09_04_201028) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -44,6 +44,17 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.index ["account_id", "domain"], name: "index_account_domain_blocks_on_account_id_and_domain", unique: true end + create_table "account_domain_permissions", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "domain", default: "", null: false + t.integer "visibility", default: 0, null: false + t.boolean "sticky", default: false, null: false + t.index ["account_id", "domain"], name: "index_account_domain_permissions_on_account_id_and_domain", unique: true + t.index ["account_id"], name: "index_account_domain_permissions_on_account_id" + t.index ["domain"], name: "index_account_domain_permissions_on_domain" + t.index ["visibility"], name: "index_account_domain_permissions_on_visibility" + end + create_table "account_identity_proofs", force: :cascade do |t| t.bigint "account_id" t.string "provider", default: "", null: false @@ -56,6 +67,12 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.index ["account_id", "provider", "provider_username"], name: "index_account_proofs_on_account_and_provider_and_username", unique: true end + create_table "account_metadata", force: :cascade do |t| + t.bigint "account_id", null: false + t.jsonb "fields", default: {}, null: false + t.index ["account_id"], name: "index_account_metadata_on_account_id" + end + create_table "account_migrations", force: :cascade do |t| t.bigint "account_id" t.string "acct", default: "", null: false @@ -182,6 +199,12 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.integer "avatar_storage_schema_version" t.integer "header_storage_schema_version" t.string "devices_url" + t.boolean "require_dereference", default: false, null: false + t.boolean "show_replies", default: true, null: false + t.boolean "show_unlisted", default: true, null: false + t.boolean "private", default: false, null: false + t.boolean "require_auth", default: false, null: false + t.datetime "last_synced_at" t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id" @@ -272,9 +295,32 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.index ["status_id"], name: "index_bookmarks_on_status_id" end + create_table "collection_items", force: :cascade do |t| + t.bigint "account_id" + t.string "uri", null: false + t.boolean "processed", default: false, null: false + t.integer "retries", limit: 2, default: 0, null: false + t.index ["account_id"], name: "index_collection_items_on_account_id" + t.index ["account_id"], name: "unprocessed_collection_item_account_ids", where: "(processed = false)" + t.index ["id"], name: "unprocessed_collection_item_ids", order: :desc, where: "(processed = false)" + t.index ["uri"], name: "index_collection_items_on_uri", unique: true + end + + create_table "collection_pages", force: :cascade do |t| + t.bigint "account_id" + t.string "uri", null: false + t.string "next" + t.index ["account_id"], name: "index_collection_pages_on_account_id" + t.index ["account_id"], name: "unprocessed_collection_page_account_ids", where: "(next IS NULL)" + t.index ["id"], name: "unprocessed_collection_page_ids", where: "(next IS NULL)" + t.index ["uri"], name: "index_collection_pages_on_uri", unique: true + t.index ["uri"], name: "unprocessed_collection_pages_uris", where: "(next IS NULL)" + end + create_table "conversation_mutes", force: :cascade do |t| t.bigint "conversation_id", null: false t.bigint "account_id", null: false + t.boolean "hidden", default: false, null: false t.index ["account_id", "conversation_id"], name: "index_conversation_mutes_on_account_id_and_conversation_id", unique: true end @@ -282,6 +328,10 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.string "uri" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "account_id" + t.boolean "public", default: false, null: false + t.string "root" + t.index ["account_id"], name: "index_conversations_on_account_id" t.index ["uri"], name: "index_conversations_on_uri", unique: true end @@ -339,6 +389,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.string "domain", default: "", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "hidden", default: false, null: false t.index ["domain"], name: "index_domain_allows_on_domain", unique: true end @@ -440,6 +491,14 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.boolean "overwrite", default: false, null: false end + create_table "inline_media_attachments", force: :cascade do |t| + t.bigint "status_id" + t.bigint "media_attachment_id" + t.index ["media_attachment_id"], name: "index_inline_media_attachments_on_media_attachment_id" + t.index ["status_id", "media_attachment_id"], name: "uniq_index_on_status_and_attachment", unique: true + t.index ["status_id"], name: "index_inline_media_attachments_on_status_id" + end + create_table "invites", force: :cascade do |t| t.bigint "user_id", null: false t.string "code", default: "", null: false @@ -505,6 +564,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.integer "thumbnail_file_size" t.datetime "thumbnail_updated_at" t.string "thumbnail_remote_url" + t.boolean "inline", default: false, null: false t.index ["account_id"], name: "index_media_attachments_on_account_id" t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id" t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true @@ -527,6 +587,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.boolean "hide_notifications", default: true, null: false t.bigint "account_id", null: false t.bigint "target_account_id", null: false + t.boolean "timelines_only", default: false, null: false t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true t.index ["target_account_id"], name: "index_mutes_on_target_account_id" end @@ -667,6 +728,14 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.index ["status_id", "preview_card_id"], name: "index_preview_cards_statuses_on_status_id_and_preview_card_id" end + create_table "queued_boosts", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "status_id", null: false + t.index ["account_id", "status_id"], name: "index_queued_boosts_on_account_id_and_status_id", unique: true + t.index ["account_id"], name: "index_queued_boosts_on_account_id" + t.index ["status_id"], name: "index_queued_boosts_on_status_id" + end + create_table "relays", force: :cascade do |t| t.string "inbox_url", default: "", null: false t.string "follow_activity_id" @@ -744,6 +813,24 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.index ["var"], name: "index_site_uploads_on_var", unique: true end + create_table "status_domain_permissions", force: :cascade do |t| + t.bigint "status_id", null: false + t.string "domain", default: "", null: false + t.integer "visibility", default: 0, null: false + t.index ["domain"], name: "index_status_domain_permissions_on_domain" + t.index ["status_id", "domain"], name: "index_status_domain_permissions_on_status_id_and_domain", unique: true + t.index ["status_id"], name: "index_status_domain_permissions_on_status_id" + t.index ["visibility"], name: "index_status_domain_permissions_on_visibility" + end + + create_table "status_mutes", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "status_id", null: false + t.index ["account_id", "status_id"], name: "index_status_mutes_on_account_id_and_status_id", unique: true + t.index ["account_id"], name: "index_status_mutes_on_account_id" + t.index ["status_id"], name: "index_status_mutes_on_status_id" + end + create_table "status_pins", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "status_id", null: false @@ -780,17 +867,34 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.bigint "account_id", null: false t.bigint "application_id" t.bigint "in_reply_to_account_id" - t.boolean "local_only" + t.boolean "local_only", default: false, null: false t.bigint "poll_id" t.string "content_type" t.datetime "deleted_at" + t.integer "edited", default: 0, null: false + t.integer "nest_level", limit: 2, default: 0, null: false + t.boolean "published", default: true, null: false + t.text "title" + t.boolean "semiprivate", default: false, null: false + t.text "original_text" + t.text "footer" + t.datetime "expires_at" + t.datetime "publish_at" + t.boolean "originally_local_only", default: false, null: false t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" - t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" - t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" + t.index ["account_id", "id"], name: "index_unpublished_statuses", order: { id: :desc }, where: "((deleted_at IS NULL) AND (published = false))" + t.index ["conversation_id"], name: "index_statuses_on_conversation_id", where: "(deleted_at IS NULL)" + t.index ["expires_at"], name: "index_statuses_on_expires_at", where: "(expires_at IS NOT NULL)" + t.index ["id", "account_id"], name: "index_statuses_local", order: { id: :desc }, where: "((published = true) AND ((local = true) OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((reply = false) OR (in_reply_to_account_id = account_id)))" + t.index ["id", "account_id"], name: "index_statuses_local_reblogs", where: "(((local = true) OR (uri IS NULL)) AND (reblog_of_id IS NOT NULL))" + t.index ["id", "account_id"], name: "index_statuses_on_id_and_account_id" + t.index ["id", "account_id"], name: "index_statuses_public", order: { id: :desc }, where: "((published = true) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((reply = false) OR (in_reply_to_account_id = account_id)))" t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id" t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id" + t.index ["publish_at"], name: "index_statuses_on_publish_at", where: "(publish_at IS NOT NULL)" t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id" t.index ["uri"], name: "index_statuses_on_uri", unique: true + t.index ["visibility"], name: "index_statuses_on_visibility", where: "(deleted_at IS NULL)" end create_table "statuses_tags", id: false, force: :cascade do |t| @@ -883,6 +987,8 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do t.boolean "approved", default: true, null: false t.string "sign_in_token" t.datetime "sign_in_token_sent_at" + t.string "username" + t.string "kobold" t.string "webauthn_id" t.index ["account_id"], name: "index_users_on_account_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true @@ -929,7 +1035,9 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do add_foreign_key "account_conversations", "accounts", on_delete: :cascade add_foreign_key "account_conversations", "conversations", on_delete: :cascade add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade + add_foreign_key "account_domain_permissions", "accounts", on_delete: :cascade add_foreign_key "account_identity_proofs", "accounts", on_delete: :cascade + add_foreign_key "account_metadata", "accounts", on_delete: :cascade add_foreign_key "account_migrations", "accounts", column: "target_account_id", on_delete: :nullify add_foreign_key "account_migrations", "accounts", on_delete: :cascade add_foreign_key "account_moderation_notes", "accounts" @@ -954,8 +1062,11 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade add_foreign_key "bookmarks", "accounts", on_delete: :cascade add_foreign_key "bookmarks", "statuses", on_delete: :cascade + add_foreign_key "collection_items", "accounts", on_delete: :cascade + add_foreign_key "collection_pages", "accounts", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade + add_foreign_key "conversations", "accounts" add_foreign_key "custom_filters", "accounts", on_delete: :cascade add_foreign_key "devices", "accounts", on_delete: :cascade add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade @@ -972,6 +1083,8 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade add_foreign_key "identities", "users", name: "fk_bea040f377", on_delete: :cascade add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade + add_foreign_key "inline_media_attachments", "media_attachments", on_delete: :cascade + add_foreign_key "inline_media_attachments", "statuses", on_delete: :cascade add_foreign_key "invites", "users", on_delete: :cascade add_foreign_key "list_accounts", "accounts", on_delete: :cascade add_foreign_key "list_accounts", "follows", on_delete: :cascade @@ -997,6 +1110,8 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do add_foreign_key "poll_votes", "polls", on_delete: :cascade add_foreign_key "polls", "accounts", on_delete: :cascade add_foreign_key "polls", "statuses", on_delete: :cascade + add_foreign_key "queued_boosts", "accounts", on_delete: :cascade + add_foreign_key "queued_boosts", "statuses", on_delete: :cascade add_foreign_key "report_notes", "accounts", on_delete: :cascade add_foreign_key "report_notes", "reports", on_delete: :cascade add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify @@ -1006,6 +1121,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade + add_foreign_key "status_domain_permissions", "statuses", on_delete: :cascade add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade add_foreign_key "status_pins", "statuses", on_delete: :cascade add_foreign_key "status_stats", "statuses", on_delete: :cascade diff --git a/lib/mastodon/snowflake.rb b/lib/mastodon/snowflake.rb index 9e5bc7383..10ed51f0c 100644 --- a/lib/mastodon/snowflake.rb +++ b/lib/mastodon/snowflake.rb @@ -120,21 +120,10 @@ module Mastodon::Snowflake seq_name = data[:seq_prefix] + '_id_seq' - # If we were on Postgres 9.5+, we could do CREATE SEQUENCE IF - # NOT EXISTS, but we can't depend on that. Instead, catch the - # possible exception and ignore it. # Note that seq_name isn't a column name, but it's a # relation, like a column, and follows the same quoting rules # in Postgres. - connection.execute(<<~SQL) - DO $$ - BEGIN - CREATE SEQUENCE #{connection.quote_column_name(seq_name)}; - EXCEPTION WHEN duplicate_table THEN - -- Do nothing, we have the sequence already. - END - $$ LANGUAGE plpgsql; - SQL + connection.execute("CREATE SEQUENCE IF NOT EXISTS #{connection.quote_column_name(seq_name)};") end end diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 429bcb8a5..448518884 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -21,7 +21,7 @@ module Mastodon end def suffix - '+glitch' + '+glitch+monsterpit' end def to_a @@ -33,11 +33,11 @@ module Mastodon end def repository - ENV.fetch('GITHUB_REPOSITORY', 'glitch-soc/mastodon') + ENV.fetch('GITHUB_REPOSITORY') { 'monsterpit/monsterpit-mastodon' } end def source_base_url - ENV.fetch('SOURCE_BASE_URL', "https://github.com/#{repository}") + ENV.fetch('SOURCE_BASE_URL') { "https://monsterware.dev/#{repository}" } end # specify git tag or commit hash here @@ -56,5 +56,40 @@ module Mastodon def user_agent @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Version}; +http#{Rails.configuration.x.use_https ? 's' : ''}://#{Rails.configuration.x.web_domain}/)" end + + def server_metadata_json + @server_metadata_json ||= [ + { + '@context': { 'schema': 'http://schema.org/', name: 'schema:name', value: 'schema:value' }, + type: 'PropertyValue', + name: 'version', + value: to_s, + }, + { + '@context': { 'schema': 'http://schema.org/', name: 'schema:name', value: 'schema:value' }, + type: 'PropertyValue', + name: 'monsterpit:extensions', + value: '2020.09.05.1', + }, + { + '@context': { 'schema': 'http://schema.org/', name: 'schema:name', value: 'schema:value' }, + type: 'PropertyValue', + name: 'comment:0', + value: "big tails can't fail", + }, + { + '@context': { 'schema': 'http://schema.org/', name: 'schema:name', value: 'schema:value' }, + type: 'PropertyValue', + name: 'comment:1', + value: 'trans rights!', + }, + { + '@context': { 'schema': 'http://schema.org/', name: 'schema:name', value: 'schema:value' }, + type: 'PropertyValue', + name: 'comment:2', + value: 'gently the kobolds', + }, + ] + end end end diff --git a/lib/tasks/monsterfork.rake b/lib/tasks/monsterfork.rake new file mode 100644 index 000000000..041bdac3c --- /dev/null +++ b/lib/tasks/monsterfork.rake @@ -0,0 +1,22 @@ +# frozen_string_literal: true +namespace :monsterfork do + desc 'Compute post nesting levels (this may take a very long time!)' + task compute_nesting_levels: :environment do + Rails.logger.info('Setting post nesting level for orphaned replies...') + Status.select(:id, :account_id).where(reply: true, in_reply_to_id: nil).reorder(nil).in_batches.update_all(nest_level: 1) + + count = 1.0 + total = Conversation.count + + Conversation.reorder('conversations.id DESC').find_each do |conversation| + Rails.logger.info("(#{(count / total * 100).to_i}%) Computing post nesting levels for all threads...") + + conversation.statuses.where(reply: true).reorder('statuses.id ASC').find_each do |status| + level = [status.thread&.account_id == status.account_id ? status.thread&.nest_level.to_i : status.thread&.nest_level.to_i + 1, 127].min + status.update(nest_level: level) if level != status.nest_level + end + + count += 1 + end + end +end diff --git a/monsterfork.code-workspace b/monsterfork.code-workspace new file mode 100644 index 000000000..e67eae18c --- /dev/null +++ b/monsterfork.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "typescript.surveys.enabled": false, + "javascript.format.enable": false + } +} diff --git a/package.json b/package.json index 3982f7cde..b850e3976 100644 --- a/package.json +++ b/package.json @@ -73,8 +73,8 @@ "@github/webauthn-json": "^0.4.2", "@rails/ujs": "^6.0.3", "array-includes": "^3.1.1", - "atrament": "0.2.4", "arrow-key-navigation": "^1.2.0", + "atrament": "0.2.4", "autoprefixer": "^9.8.6", "axios": "^0.19.2", "babel-loader": "^8.1.0", @@ -122,6 +122,7 @@ "offline-plugin": "^5.0.7", "path-complete-extname": "^1.0.0", "pg": "^6.4.0", + "pg-native": "^3.0.0", "postcss-loader": "^3.0.0", "postcss-object-fit-images": "^1.1.2", "promise.prototype.finally": "^3.1.2", diff --git a/public/registration.js b/public/registration.js new file mode 100644 index 000000000..a82ec09c2 --- /dev/null +++ b/public/registration.js @@ -0,0 +1,54 @@ +function dragon(message) { + const msgBuffer = new TextEncoder('utf-8').encode(message); + return crypto.subtle.digest('SHA-512', msgBuffer).then(hashBuffer => { + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => ('00' + b.toString(16)).slice(-2)).join(''); + return hashHex; + }); +} + +function getForm() { + return document.getElementById('registration_new_user') || document.getElementById('new_user'); +} + +function getField(name) { + return document.getElementById(`registration_user_${name}`) || document.getElementById(`user_${name}`); +} + +function handleSubmit(e) { + e.preventDefault(); + + const form = getForm(); + const u1 = getField('account_attributes_username'); + const u2 = getField('username'); + const kobold = getField('kobold'); + + if (!!u1 && !!u2 && u1.value.toLowerCase() === u2.value.toLowerCase()) { + u2.value = u1.value; + } + + let values = []; + + for (let i = 0; i < form.elements.length; i++) { + const element = form.elements[i]; + const value = element.value; + + if (!!element && ['text', 'email', 'textarea'].includes(element.type) && !!value) { + values.push(value.trim().toLowerCase()); + } + } + + const value = values.join('\u{F0666}'); + dragon(value).then(digest => { + if (!!kobold) { kobold.value = digest.toUpperCase(); } + form.submit(); + }, _ => { form.submit(); }); +} + +function addSubmitHandler() { + const form = getForm(); + if (!!form) { form.addEventListener('submit', handleSubmit); } +} + +window.addEventListener("load", addSubmitHandler); + diff --git a/yarn.lock b/yarn.lock index b0d8622dc..b9423b781 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2353,7 +2353,7 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9" integrity sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ== -bindings@^1.5.0: +bindings@1.5.0, bindings@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== @@ -4539,6 +4539,13 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fastq@^1.6.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.8.0.tgz#550e1f9f59bbc65fe185cb6a9b4d95357107f481" + integrity sha512-SMIZoZdLh/fgofivvIkmknUXyPnvxRE3DhtZ5Me3Mrsk5gyPL42F0xr51TdRXskBxHfMp+07bcYzfsYEsSQA9Q== + dependencies: + reusify "^1.0.4" + favico.js@^0.3.10: version "0.3.10" resolved "https://registry.yarnpkg.com/favico.js/-/favico.js-0.3.10.tgz#80586e27a117f24a8d51c18a99bdc714d4339301" @@ -6673,6 +6680,14 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +libpq@^1.7.0: + version "1.8.9" + resolved "https://registry.yarnpkg.com/libpq/-/libpq-1.8.9.tgz#6e0c6eecb176f6656ad092d67cc0131980cba897" + integrity sha512-herU0STiW3+/XBoYRycKKf49O9hBKK0JbdC2QmvdC5pyCSu8prb9idpn5bUSbxj8XwcEsWPWWWwTDZE9ZTwJ7g== + dependencies: + bindings "1.5.0" + nan "^2.14.0" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -7165,7 +7180,7 @@ mute-stream@0.0.5: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" integrity sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA= -nan@^2.12.1: +nan@^2.12.1, nan@^2.14.0: version "2.14.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== @@ -7862,6 +7877,15 @@ pg-int8@1.0.1: resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== +pg-native@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pg-native/-/pg-native-3.0.0.tgz#20c64e651e20b28f5c060b3823522d1c8c4429c3" + integrity sha512-qZZyywXJ8O4lbiIN7mn6vXIow1fd3QZFqzRe+uET/SZIXvCa3HBooXQA4ZU8EQX8Ae6SmaYtDGLp5DwU+8vrfg== + dependencies: + libpq "^1.7.0" + pg-types "^1.12.1" + readable-stream "1.0.31" + pg-pool@1.*: version "1.8.0" resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-1.8.0.tgz#f7ec73824c37a03f076f51bfdf70e340147c4f37" @@ -7870,7 +7894,7 @@ pg-pool@1.*: generic-pool "2.4.3" object-assign "4.1.0" -pg-types@1.*: +pg-types@1.*, pg-types@^1.12.1: version "1.13.0" resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-1.13.0.tgz#75f490b8a8abf75f1386ef5ec4455ecf6b345c63" integrity sha512-lfKli0Gkl/+za/+b6lzENajczwZHc7D5kiUCZfgm914jipD2kIOIvEkAhZ8GrW3/TUoP9w8FHjwpPObBye5KQQ== @@ -8928,6 +8952,16 @@ read-pkg@^5.2.0: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@1.0.31: + version "1.0.31" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.31.tgz#8f2502e0bc9e3b0da1b94520aabb4e2603ecafae" + integrity sha1-jyUC4LyeOw2huUUgqrtOJgPsr64= + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + readable-stream@^3.0.6, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" @@ -10068,6 +10102,11 @@ string_decoder@^1.0.0, string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= + string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" |