diff options
Diffstat (limited to 'app')
318 files changed, 7771 insertions, 1047 deletions
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 052394ab4..f97eeb80b 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -11,20 +11,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 @@ -40,16 +44,19 @@ class AccountsController < ApplicationController end format.rss do - expires_in 1.minute, public: true + return render xml: '', status: 404 if rss_disabled? || 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 end end @@ -62,19 +69,27 @@ 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_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 @@ -85,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 mentions_scope + return Status.none unless current_account? - def hashtag_scope - tag = Tag.find_normalized(params[:tag]) - - if tag - Status.tagged_with(tag.id) - else - Status.none - end + Status.mentions_between(@account, current_account) end def username_param @@ -128,8 +135,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 @@ -139,7 +152,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 @@ -147,6 +166,26 @@ class AccountsController < ApplicationController request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) 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 blocked? + @blocked ||= current_account && @account.blocking?(current_account) + end + + def unauthorized? + @unauthorized ||= blocked? || (@account.private? && !following?(@account)) + end + + def rss_disabled? + @account.user&.setting_rss_disabled + end + def cached_filtered_status_page cache_collection_paginated_by_id( filtered_statuses, 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 d3044f180..0a3bceb7b 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 5fd735ad6..7c914298b 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 @@ -54,11 +57,38 @@ 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?), + 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 = cache_collection_paginated_by_id( - @account.statuses.permitted_for(@account, signed_request_account), + 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 fde6c861f..a12a23fbb 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 diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb deleted file mode 100644 index 71efb543e..000000000 --- a/app/controllers/admin/custom_emojis_controller.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module Admin - class CustomEmojisController < BaseController - def index - authorize :custom_emoji, :index? - - @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) - @form = Form::CustomEmojiBatch.new - end - - def new - authorize :custom_emoji, :create? - - @custom_emoji = CustomEmoji.new - end - - def create - authorize :custom_emoji, :create? - - @custom_emoji = CustomEmoji.new(resource_params) - - if @custom_emoji.save - log_action :create, @custom_emoji - redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg') - else - render :new - end - end - - def batch - @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button)) - @form.save - rescue ActionController::ParameterMissing - flash[:alert] = I18n.t('admin.accounts.no_account_selected') - rescue Mastodon::NotPermittedError - flash[:alert] = I18n.t('admin.custom_emojis.not_permitted') - ensure - redirect_to admin_custom_emojis_path(filter_params) - end - - private - - def resource_params - params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker) - end - - def filtered_custom_emojis - CustomEmojiFilter.new(filter_params).results - end - - def filter_params - params.slice(:page, *CustomEmojiFilter::KEYS).permit(:page, *CustomEmojiFilter::KEYS) - end - - def action_from_button - if params[:update] - 'update' - elsif params[:list] - 'list' - elsif params[:unlist] - 'unlist' - elsif params[:enable] - 'enable' - elsif params[:disable] - 'disable' - elsif params[:copy] - 'copy' - elsif params[:delete] - 'delete' - end - end - - def form_custom_emoji_batch_params - params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: []) - end - end -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 e962c4e97..818819a3f 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 92ccb8061..a0ce810ad 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 @account.suspended? ? [] : 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,65 @@ 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_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 +104,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 3e66ff212..6e909bbf2 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -42,7 +42,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), duration: (params[:duration] || 0)) + MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), timelines_only: truthy_param?(:timelines_only), duration: (params[:duration] || 0)) 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..418c19840 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) @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/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..c7c429bfb 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,82 @@ 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], + local_only: status_params[:local_only], 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], + local_only: status_params[:local_only], + 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, unpublished: 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 +142,18 @@ class Api::V1::StatusesController < Api::BaseController :in_reply_to_id, :sensitive, :spoiler_text, + :title, + :footer, + :notify, + :publish, :visibility, + :local_only, :scheduled_at, :content_type, + :expires_at, + :publish_at, + tags: [], + mentions: [], media_ids: [], poll: [ :multiple, @@ -100,4 +167,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/application_controller.rb b/app/controllers/application_controller.rb index e996c2217..5e12e89c8 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 @@ -44,11 +45,11 @@ class ApplicationController < ActionController::Base private def https_enabled? - Rails.env.production? && !request.path.start_with?('/health') + Rails.env.production? && !request.path.start_with?('/health', '/_matrix-internal/') 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 23e5a22e1..4dedef817 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, notice: I18n.t('about.registration.failed_kobold') 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 62e379846..568011c9e 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/custom_emojis_controller.rb b/app/controllers/custom_emojis_controller.rb new file mode 100644 index 000000000..0ef8d0a50 --- /dev/null +++ b/app/controllers/custom_emojis_controller.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +class CustomEmojisController < ApplicationController + include Authorization + include AccountableConcern + + layout 'admin' + + before_action :authenticate_user! + before_action :set_pack + before_action :set_body_classes + + def index + authorize :custom_emoji, :index? + + @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) + @form = Form::CustomEmojiBatch.new + end + + def new + authorize :custom_emoji, :create? + + @custom_emoji = CustomEmoji.new(account: current_account) + end + + def create + authorize :custom_emoji, :create? + + @custom_emoji = CustomEmoji.new(resource_params.merge(account: current_account)) + + if @custom_emoji.save + log_action :create, @custom_emoji + redirect_to custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg') + else + render :new + end + end + + def batch + @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.accounts.no_account_selected') + rescue Mastodon::NotPermittedError + flash[:alert] = I18n.t('admin.custom_emojis.not_permitted') + ensure + redirect_to custom_emojis_path(filter_params) + end + + private + + def resource_params + params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker) + end + + def filtered_custom_emojis + CustomEmojiFilter.new(filter_params, current_account).results + end + + def filter_params + params.slice(:page, *CustomEmojiFilter::KEYS).permit(:page, *CustomEmojiFilter::KEYS) + end + + def action_from_button + if params[:update] + 'update' + elsif params[:list] + 'list' + elsif params[:unlist] + 'unlist' + elsif params[:enable] + 'enable' + elsif params[:disable] + 'disable' + elsif params[:copy] + 'copy' + elsif params[:delete] + 'delete' + elsif params[:claim] + 'claim' + elsif params[:unclaim] + 'unclaim' + end + end + + def form_custom_emoji_batch_params + params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: []) + end + + def set_pack + use_pack 'settings' + end + + def set_body_classes + @body_classes = 'admin' + end +end 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/matrix/base_controller.rb b/app/controllers/matrix/base_controller.rb new file mode 100644 index 000000000..5922501ec --- /dev/null +++ b/app/controllers/matrix/base_controller.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +class Matrix::BaseController < ApplicationController + include RateLimitHeaders + + skip_before_action :store_current_location + skip_before_action :require_functional! + + before_action :set_cache_headers + + protect_from_forgery with: :null_session + + skip_around_action :set_locale + + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| + render json: { success: false, error: e.to_s }, status: 422 + end + + rescue_from ActiveRecord::RecordNotUnique do + render json: { success: false, error: 'Duplicate record' }, status: 422 + end + + rescue_from ActiveRecord::RecordNotFound do + render json: { success: false, error: 'Record not found' }, status: 404 + end + + rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do + render json: { success: false, error: 'Remote data could not be fetched' }, status: 503 + end + + rescue_from OpenSSL::SSL::SSLError do + render json: { success: false, error: 'Remote SSL certificate could not be verified' }, status: 503 + end + + rescue_from Mastodon::NotPermittedError do + render json: { success: false, error: 'This action is not allowed' }, status: 403 + end + + rescue_from Mastodon::RaceConditionError do + render json: { success: false, error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from Mastodon::RateLimitExceededError do + render json: { auth: { success: false }, success: false, error: I18n.t('errors.429') }, status: 429 + end + + rescue_from ActionController::ParameterMissing do |e| + render json: { success: false, error: e.to_s }, status: 400 + end + + def doorkeeper_unauthorized_render_options(error: nil) + { json: { success: false, error: (error.try(:description) || 'Not authorized') } } + end + + def doorkeeper_forbidden_render_options(*) + { json: { success: false, error: 'This action is outside the authorized scopes' } } + end + + protected + + def current_resource_owner + @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token + end + + def current_user + current_resource_owner || super + rescue ActiveRecord::RecordNotFound + nil + end + + def require_authenticated_user! + render json: { success: false, error: 'This method requires an authenticated user' }, status: 401 unless current_user + end + + def require_user! + if !current_user + render json: { success: false, error: 'This method requires an authenticated user' }, status: 422 + elsif current_user.disabled? + render json: { success: false, error: 'Your login is currently disabled' }, status: 403 + elsif !current_user.confirmed? + render json: { success: false, error: 'Your login is missing a confirmed e-mail address' }, status: 403 + elsif !current_user.approved? + render json: { success: false, error: 'Your login is currently pending approval' }, status: 403 + else + set_user_activity + end + end + + def render_empty + render json: {}, status: 200 + end + + def authorize_if_got_token!(*scopes) + doorkeeper_authorize!(*scopes) if doorkeeper_token + end + + def set_cache_headers + response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' + end +end diff --git a/app/controllers/matrix/identity/v1/check_credentials_controller.rb b/app/controllers/matrix/identity/v1/check_credentials_controller.rb new file mode 100644 index 000000000..1770c6767 --- /dev/null +++ b/app/controllers/matrix/identity/v1/check_credentials_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class Matrix::Identity::V1::CheckCredentialsController < Matrix::BaseController + def create + matrix_profile = matrix_profile_json + return render json: fail_json if matrix_profile.blank? + + render json: matrix_profile + rescue ActionController::ParameterMissing, ActiveRecord::RecordNotFound + render json: fail_json + end + + private + + def resource_params + params.require(:user).permit(:id, :password) + end + + def matrix_domains + ENV.fetch('MATRIX_AUTH_DOMAINS', '').delete(',').split.to_set + end + + def matrix_profile_json + user_params = resource_params + return unless user_params[:id].present? && user_params[:password].present? && user_params[:id][0] == '@' + + (username, domain) = user_params[:id].downcase.split(':', 2) + return unless matrix_domains.include?(domain) + + user = User.find_by_lower_username!(username[1..-1]) + return unless user.valid_password?(user_params[:password]) + + { + auth: { + success: true, + mxid: "@#{username}:#{domain}", + profile: { + display_name: user.account.display_name.presence || user.username, + three_pids: [ + { + medium: 'email', + address: user.email, + }, + ] + } + } + } + end + + def fail_json + { auth: { success: false } } + end +end 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/privacy_controller.rb b/app/controllers/settings/preferences/privacy_controller.rb new file mode 100644 index 000000000..f447fa598 --- /dev/null +++ b/app/controllers/settings/preferences/privacy_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Settings::Preferences::PrivacyController < Settings::PreferencesController + private + + def after_update_redirect_path + settings_preferences_privacy_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 d05ceb53f..7e42d4e40 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -4,7 +4,11 @@ class Settings::PreferencesController < Settings::BaseController def show; end def update - user_settings.update(user_settings_params.to_h) + old_home_reblogs = current_user.home_reblogs? + + if user_settings.update(user_settings_params.to_h) + ClearReblogsWorker.perform_async(current_user.account_id) unless old_home_reblogs == current_user.home_reblogs? || current_user.home_reblogs? + end if current_user.update(user_params) I18n.locale = current_user.locale @@ -50,7 +54,6 @@ class Settings::PreferencesController < Settings::BaseController :setting_noindex, :setting_hide_network, :setting_hide_followers_count, - :setting_aggregate_reblogs, :setting_show_application, :setting_advanced_layout, :setting_default_content_type, @@ -58,6 +61,26 @@ 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_style_lowercase, + :setting_publish_in, + :setting_unpublish_in, + :setting_unpublish_delete, + :setting_boost_every, + :setting_boost_jitter, + :setting_boost_random, + :setting_filter_unknown, + :setting_unpublish_on_delete, + :setting_rss_disabled, + :setting_home_reblogs, + :setting_max_history_public, + :setting_max_history_private, + :setting_web_push, 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 0c15447a6..541ba2d5d 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -20,7 +20,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 64736e77f..d8b6019f5 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -9,14 +9,14 @@ 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_local before_action :set_tag before_action :set_statuses 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| @@ -32,7 +32,7 @@ class TagsController < ApplicationController format.json do expires_in 3.minutes, public: public_fetch_mode? - 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..3acb21bb1 --- /dev/null +++ b/app/controllers/user_webapp_css_controller.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +class UserWebappCssController < ApplicationController + skip_before_action :store_current_location + skip_before_action :require_functional! + + before_action :set_account + + def show + 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_lowercase + return unless @account.user&.setting_style_lowercase + + %( + div, button, span + { text-transform: lowercase; } + + code, pre + { text-transform: initial !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_lowercase}\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..cd51a8b7e 100644 --- a/app/helpers/domain_control_helper.rb +++ b/app/helpers/domain_control_helper.rb @@ -12,14 +12,12 @@ module DomainControlHelper end end - if whitelist_mode? - !DomainAllow.allowed?(domain) - else - DomainBlock.blocked?(domain) - end + domain != Rails.configuration.x.local_domain && (!DomainAllow.allowed?(domain) || DomainBlock.blocked?(domain)) + rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError + nil 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 5b39497b6..3fd15548d 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 912a3d179..a4eef2fd2 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -274,11 +274,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id, notifications, duration=0) { +export function muteAccount(id, notifications, timelinesOnly, duration=0) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, timelinesOnly, duration }).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..8c126c6e2 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -147,16 +147,16 @@ 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; } dispatch(submitComposeRequest()); - 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), @@ -165,6 +165,7 @@ export function submitCompose(routerHistory) { spoiler_text: spoilerText, visibility: getState().getIn(['compose', 'privacy']), poll: getState().getIn(['compose', 'poll'], null), + local_only: getState().getIn(['compose', 'advanced_options', 'do_not_federate']), }, { headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), 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 2bacfadb7..b85cc7863 100644 --- a/app/javascript/flavours/glitch/actions/mutes.js +++ b/app/javascript/flavours/glitch/actions/mutes.js @@ -14,6 +14,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_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; +export const MUTES_TOGGLE_TIMELINES_ONLY = 'MUTES_TOGGLE_TIMELINES_ONLY'; export function fetchMutes() { return (dispatch, getState) => { @@ -114,3 +115,9 @@ export function changeMuteDuration(duration) { }); }; } + +export function toggleTimelinesOnly() { + return dispatch => { + dispatch({ type: MUTES_TOGGLE_TIMELINES_ONLY }); + }; +} diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 7273191b2..091a4b435 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -13,7 +13,7 @@ import { defineMessages } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { unescapeHTML } from 'flavours/glitch/util/html'; import { getFiltersRegex } from 'flavours/glitch/selectors'; -import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; +import { usePendingItems as preferPendingItems, webPushEnabled } from 'flavours/glitch/util/initial_state'; import compareId from 'flavours/glitch/util/compare_id'; import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; import { requestNotificationPermission } from 'flavours/glitch/util/notifications'; @@ -337,6 +337,11 @@ export function markNotificationsAsRead() { // Browser support export function setupBrowserNotifications() { return dispatch => { + if (!webPushEnabled) { + dispatch(setBrowserSupport(false)); + return; + } + dispatch(setBrowserSupport('Notification' in window)); if ('Notification' in window) { dispatch(setBrowserPermission(Notification.permission)); diff --git a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js index 8fdb239f7..1f9ebe569 100644 --- a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js +++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js @@ -1,7 +1,9 @@ import api from 'flavours/glitch/util/api'; +import { webPushEnabled } from 'flavours/glitch/util/initial_state'; import { pushNotificationsSetting } from 'flavours/glitch/util/settings'; import { setBrowserSupport, setSubscription, clearSubscription } from './setter'; + // Taken from https://www.npmjs.com/package/web-push const urlBase64ToUint8Array = (base64String) => { const padding = '='.repeat((4 - base64String.length % 4) % 4); @@ -49,7 +51,7 @@ const sendSubscriptionToBackend = (getState, subscription, me) => { }; // Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload -const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); +const supportsPushNotifications = (webPushEnabled && 'serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); export function register () { return (dispatch, getState) => { 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 890a422d3..7521d4645 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -385,6 +385,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 })}> @@ -397,6 +457,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 1b7dce4c4..0de36ce2e 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -74,6 +74,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, @@ -373,7 +375,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); } }; @@ -689,6 +691,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) { @@ -710,6 +715,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'), unread, diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index 2ccb02c62..b844590c0 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}' }, @@ -53,6 +55,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, @@ -115,7 +119,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); @@ -125,6 +129,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); } @@ -211,10 +223,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) { @@ -223,6 +233,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 7782246a6..5d60ffd97 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'; @@ -39,6 +39,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}}' }, @@ -168,6 +170,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 2d4cc7f49..0f1b83b2d 100644 --- a/app/javascript/flavours/glitch/features/account/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js @@ -44,14 +44,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> ); } @@ -64,17 +70,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 a6b57d331..d399d4aa9 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 { @@ -128,9 +129,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 c56cc9b8e..c88f6ac89 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.js +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -19,15 +19,15 @@ import TimelineHint from 'flavours/glitch/components/timeline_hint'; const emptyList = ImmutableList(); -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']), suspended: state.getIn(['accounts', accountId, 'suspended'], false), @@ -52,7 +52,7 @@ class AccountTimeline extends ImmutablePureComponent { featuredStatusIds: ImmutablePropTypes.list, isLoading: PropTypes.bool, hasMore: PropTypes.bool, - withReplies: PropTypes.bool, + filter: PropTypes.string, isAccount: PropTypes.bool, suspended: PropTypes.bool, remote: PropTypes.bool, @@ -61,24 +61,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 })); } } @@ -87,7 +87,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..e812ba982 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -63,6 +63,8 @@ class ComposeForm extends ImmutablePureComponent { layout: PropTypes.string, media: ImmutablePropTypes.list, sideArm: PropTypes.string, + sideArmWarning: PropTypes.bool, + privacyWarning: PropTypes.bool, sensitive: PropTypes.bool, spoilersAlwaysOn: PropTypes.bool, mediaDescriptionConfirmation: PropTypes.bool, @@ -71,10 +73,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 +153,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 +271,7 @@ class ComposeForm extends ImmutablePureComponent { handleSecondarySubmit, handleSelect, handleSubmit, + handleClearAll, handleRefTextarea, } = this; const { @@ -273,19 +289,22 @@ class ComposeForm extends ImmutablePureComponent { onFetchSuggestions, onPaste, privacy, + privacyWarning, sensitive, showSearch, sideArm, + sideArmWarning, spoiler, spoilerText, suggestions, text, spoilersAlwaysOn, + clearTimeout, } = this.props; let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia); - const countText = `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`; + const countText = `${spoilerText}${countableText(text)}`; return ( <div className='composer'> @@ -356,8 +375,11 @@ class ComposeForm extends ImmutablePureComponent { disabled={disabledButton} onSecondarySubmit={handleSecondarySubmit} onSubmit={handleSubmit} + onClearAll={handleClearAll} privacy={privacy} + privacyWarning={privacyWarning} sideArm={sideArm} + sideArmWarning={sideArmWarning} /> </div> ); diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js index 97890f40d..d42c578aa 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,8 +38,11 @@ class Publisher extends ImmutablePureComponent { intl: PropTypes.object.isRequired, onSecondarySubmit: PropTypes.func, onSubmit: PropTypes.func, + onClearAll: PropTypes.func, privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']), + privacyWarning: PropTypes.bool, sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']), + sideArmWarning: PropTypes.bool, }; handleSubmit = () => { @@ -43,7 +50,7 @@ class Publisher extends ImmutablePureComponent { }; render () { - const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm } = this.props; + const { countText, disabled, intl, onClearAll, onSecondarySubmit, privacy, privacyWarning, sideArm, sideArmWarning } = this.props; const diff = maxChars - length(countText || ''); const computedClass = classNames('composer--publisher', { @@ -53,9 +60,20 @@ 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' + className={classNames('side_arm', {privacy_warning: sideArmWarning})} disabled={disabled || diff < 0} onClick={onSecondarySubmit} style={{ padding: null }} @@ -75,7 +93,7 @@ class Publisher extends ImmutablePureComponent { /> ) : null} <Button - className='primary' + className={classNames('primary', {privacy_warning: privacyWarning})} text={function () { switch (true) { case !!sideArm && sideArm !== 'none': 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..cf953ec3d 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,13 +12,14 @@ import { selectComposeSuggestion, submitCompose, uploadCompose, + resetCompose, } from 'flavours/glitch/actions/compose'; import { openModal, } from 'flavours/glitch/actions/modal'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; -import { privacyPreference } from 'flavours/glitch/util/privacy_preference'; +import { privacyPreference, order as privacyOrder } from 'flavours/glitch/util/privacy_preference'; const messages = defineMessages({ missingDescriptionMessage: { id: 'confirmations.missing_media_description.message', @@ -57,7 +58,9 @@ function mapStateToProps (state) { media: state.getIn(['compose', 'media_attachments']), preselectDate: state.getIn(['compose', 'preselectDate']), privacy: state.getIn(['compose', 'privacy']), + privacyWarning: replyPrivacy && privacyOrder.indexOf(state.getIn(['compose', 'privacy'])) < privacyOrder.indexOf(replyPrivacy), sideArm: sideArmPrivacy, + sideArmWarning: sideArmPrivacy && sideArmRestrictedPrivacy && privacyOrder.indexOf(sideArmPrivacy) < privacyOrder.indexOf(sideArmRestrictedPrivacy), sensitive: state.getIn(['compose', 'sensitive']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']), @@ -82,6 +85,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(submitCompose(routerHistory)); }, + onClearAll() { + dispatch(resetCompose()); + }, + onClearSuggestions() { dispatch(clearComposeSuggestions()); }, diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js index b4549fdf8..43d535ac5 100644 --- a/app/javascript/flavours/glitch/features/getting_started/index.js +++ b/app/javascript/flavours/glitch/features/getting_started/index.js @@ -46,7 +46,10 @@ const makeMapStateToProps = () => { return lists; } - return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); + return lists.toList().filter(item => !!item).sort((a, b) => { + const r = (b.get('reblogs') ? 1 : 0) - (a.get('reblogs') ? 1 : 0); + return r === 0 ? a.get('title').localeCompare(b.get('title')) : r; + }); }); const mapStateToProps = state => ({ diff --git a/app/javascript/flavours/glitch/features/list_adder/index.js b/app/javascript/flavours/glitch/features/list_adder/index.js index cb8a15e8c..b7f3d1ef7 100644 --- a/app/javascript/flavours/glitch/features/list_adder/index.js +++ b/app/javascript/flavours/glitch/features/list_adder/index.js @@ -16,7 +16,10 @@ const getOrderedLists = createSelector([state => state.get('lists')], lists => { return lists; } - return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); + return lists.toList().filter(item => !!item).sort((a, b) => { + const r = (b.get('reblogs') ? 1 : 0) - (a.get('reblogs') ? 1 : 0); + return r === 0 ? a.get('title').localeCompare(b.get('title')) : r; + }); }); const mapStateToProps = state => ({ diff --git a/app/javascript/flavours/glitch/features/lists/index.js b/app/javascript/flavours/glitch/features/lists/index.js index e384f301b..3863b8e25 100644 --- a/app/javascript/flavours/glitch/features/lists/index.js +++ b/app/javascript/flavours/glitch/features/lists/index.js @@ -24,7 +24,10 @@ const getOrderedLists = createSelector([state => state.get('lists')], lists => { return lists; } - return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); + return lists.toList().filter(item => !!item).sort((a, b) => { + const r = (b.get('reblogs') ? 1 : 0) - (a.get('reblogs') ? 1 : 0); + return r === 0 ? a.get('title').localeCompare(b.get('title')) : r; + }); }); const mapStateToProps = state => ({ diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js index 0d3162fc9..7b47f411b 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/follow.js +++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js @@ -84,11 +84,13 @@ export default class NotificationFollow extends ImmutablePureComponent { <Icon fixedWidth id='user-plus' /> </div> - <FormattedMessage - id='notification.follow' - defaultMessage='{name} followed you' - values={{ name: link }} - /> + <span title={notification.get('created_at')}> + <FormattedMessage + id='notification.follow' + defaultMessage='{name} followed you' + values={{ name: link }} + /> + </span> </div> <AccountContainer hidden={hidden} id={account.get('id')} withNote={false} /> diff --git a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js index 73fc05dea..4cac53266 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js +++ b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js @@ -20,6 +20,10 @@ class NotificationsPermissionBanner extends React.PureComponent { intl: PropTypes.object.isRequired, }; + handleClickDisable = () => { + window.location = '/settings/preferences/notifications'; + } + handleClick = () => { this.props.dispatch(requestBrowserPermission()); } @@ -39,7 +43,8 @@ class NotificationsPermissionBanner extends React.PureComponent { <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2> <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p> - <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button> + <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Request' /></Button> + <Button onClick={this.handleClickDisable}><FormattedMessage id='notifications_permission_banner.disable' defaultMessage='Disable' /></Button> </div> ); } 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 04d350bcb..d03a425eb 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'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; export default class DetailedStatus extends ImmutablePureComponent { @@ -200,7 +200,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'; @@ -208,7 +208,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 = ( @@ -216,9 +216,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> ); @@ -228,37 +225,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} /> @@ -275,13 +278,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 b330adf3f..74d51f8f8 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' }, @@ -306,6 +308,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)); } @@ -591,6 +607,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/list_panel.js b/app/javascript/flavours/glitch/features/ui/components/list_panel.js index 354e35027..f351e2a01 100644 --- a/app/javascript/flavours/glitch/features/ui/components/list_panel.js +++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.js @@ -13,7 +13,10 @@ const getOrderedLists = createSelector([state => state.get('lists')], lists => { return lists; } - return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4); + return lists.toList().filter(item => !!item).sort((a, b) => { + const r = (b.get('reblogs') ? 1 : 0) - (a.get('reblogs') ? 1 : 0); + return r === 0 ? a.get('title').localeCompare(b.get('title')) : r; + }); }); const mapStateToProps = state => ({ 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 7d25db316..5970ceddb 100644 --- a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js @@ -7,6 +7,7 @@ import Button from 'flavours/glitch/components/button'; import { closeModal } from 'flavours/glitch/actions/modal'; import { muteAccount } from 'flavours/glitch/actions/accounts'; import { toggleHideNotifications, changeMuteDuration } from 'flavours/glitch/actions/mutes'; +import { toggleTimelinesOnly } from 'flavours/glitch/actions/mutes'; const messages = defineMessages({ minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, @@ -20,13 +21,14 @@ const mapStateToProps = state => { account: state.getIn(['mutes', 'new', 'account']), notifications: state.getIn(['mutes', 'new', 'notifications']), muteDuration: state.getIn(['mutes', 'new', 'duration']), + timelinesOnly: state.getIn(['mutes', 'new', 'timelines_only']), }; }; const mapDispatchToProps = dispatch => { return { - onConfirm(account, notifications, muteDuration) { - dispatch(muteAccount(account.get('id'), notifications, muteDuration)); + onConfirm(account, notifications, timelinesOnly, muteDuration) { + dispatch(muteAccount(account.get('id'), notifications, timelinesOnly, muteDuration)); }, onClose() { @@ -40,6 +42,10 @@ const mapDispatchToProps = dispatch => { onChangeMuteDuration(e) { dispatch(changeMuteDuration(e.target.value)); }, + + onToggleTimelinesOnly() { + dispatch(toggleTimelinesOnly()); + }, }; }; @@ -50,9 +56,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, muteDuration: PropTypes.number.isRequired, onChangeMuteDuration: PropTypes.func.isRequired, @@ -64,7 +72,7 @@ class MuteModal extends React.PureComponent { handleClick = () => { this.props.onClose(); - this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.timelinesOnly, this.props.muteDuration); } handleCancel = () => { @@ -83,8 +91,12 @@ class MuteModal extends React.PureComponent { this.props.onChangeMuteDuration(e); } + toggleTimelinesOnly = () => { + this.props.onToggleTimelinesOnly(); + } + render () { - const { account, notifications, muteDuration, intl } = this.props; + const { account, notifications, timelinesOnly, muteDuration, intl } = this.props; return ( <div className='modal-root__modal mute-modal'> @@ -109,6 +121,13 @@ class MuteModal extends React.PureComponent { </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> <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> {/* eslint-disable-next-line jsx-a11y/no-onchange */} 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 61a34fd2b..fa18f84b3 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -215,8 +215,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 ea37ae4aa..53360b8f0 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', @@ -53,7 +53,7 @@ const initialState = ImmutableMap({ pop_in_position : 'right', }), notifications : ImmutableMap({ - favicon_badge : false, + favicon_badge : true, tab_badge : true, show_unread : true, }), diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js index d346d9a78..f116e106a 100644 --- a/app/javascript/flavours/glitch/reducers/mutes.js +++ b/app/javascript/flavours/glitch/reducers/mutes.js @@ -4,6 +4,7 @@ import { MUTES_INIT_MODAL, MUTES_TOGGLE_HIDE_NOTIFICATIONS, MUTES_CHANGE_DURATION, + MUTES_TOGGLE_TIMELINES_ONLY, } from 'flavours/glitch/actions/mutes'; const initialState = Immutable.Map({ @@ -11,6 +12,7 @@ const initialState = Immutable.Map({ account: null, notifications: true, duration: 0, + timelinesOnly: false, }), }); @@ -20,11 +22,14 @@ 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_CHANGE_DURATION: return state.setIn(['new', 'duration'], Number(action.duration)); + case MUTES_TOGGLE_TIMELINES_ONLY: + return state.updateIn(['new', 'timelines_only'], (old) => !old); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index bf0545c48..64d13e18c 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -91,8 +91,9 @@ const initialState = ImmutableMap({ const defaultColumns = fromJS([ { id: 'COMPOSE', uuid: uuid(), params: {} }, - { id: 'HOME', uuid: uuid(), params: {} }, { id: 'NOTIFICATIONS', uuid: uuid(), params: {} }, + { id: 'HOME', uuid: uuid(), params: {} }, + { id: 'COMMUNITY', uuid: uuid(), params: {} }, ]); const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val); 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/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index 2550f50f4..d90327211 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -696,12 +696,9 @@ } .notifications-permission-banner { - padding: 30px; + padding: 14px; border-bottom: 1px solid lighten($ui-base-color, 8%); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + text-align: center; position: relative; &__close { @@ -711,7 +708,7 @@ } h2 { - font-size: 16px; + font-size: 14px; font-weight: 500; margin-bottom: 15px; text-align: center; @@ -722,4 +719,9 @@ margin-bottom: 15px; text-align: center; } + + .button { + margin-left: 7px; + margin-right: 7px; + } } diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index 460f75c1f..7db2dd2aa 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -654,6 +654,19 @@ text-align: center; } + & > .privacy_warning { + background-color: $error-value-color; + + &:hover { + background-color: lighten($error-value-color, 5%); + } + + &:active, + &:focus { + background-color: darken($error-value-color, 5%); + } + } + &.over { & > .count { color: $warning-red } } diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index bc0965864..d9d962e34 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -876,7 +876,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 6e242281b..f4e971996 100644 --- a/app/javascript/flavours/glitch/styles/variables.scss +++ b/app/javascript/flavours/glitch/styles/variables.scss @@ -51,11 +51,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 b397844a2..d2f4450fc 100644 --- a/app/javascript/flavours/glitch/styles/widgets.scss +++ b/app/javascript/flavours/glitch/styles/widgets.scss @@ -576,7 +576,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/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js index 911468e6f..05868970c 100644 --- a/app/javascript/flavours/glitch/util/initial_state.js +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -11,6 +11,8 @@ const initialState = element && function () { const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop]; +export const webPushEnabled = getMeta('web_push') === true; + export const reduceMotion = getMeta('reduce_motion'); export const autoPlayGif = getMeta('auto_play_gif'); export const displaySensitiveMedia = getMeta('display_sensitive_media'); 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 66b5a17ac..adcdb8a4e 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -240,10 +240,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..3e021a5c1 --- /dev/null +++ b/app/javascript/mastodon/locales/en-MP.json @@ -0,0 +1,181 @@ +{ + "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": "Tavern", + "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.", + "confirmations.domain_block.message": "You are about to block and defederate from the {domain} server. If you proceed, Monsterpit will ask this server to permanently delete any data associated with your account.", + "content-type.change": "Content type", + "directory.federated": "From Tavern", + "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": "Tavern", + "introduction.federation.federated.text": "Public roars from other servers will appear in the Tavern 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 Tavern 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": "Tavern", + "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", + "notifications_permission_banner.title": "You have desktop notifications enabled.", + "notifications_permission_banner.enable": "Request", + "notifications_permission_banner.disable": "Disable", + "notifications_permission_banner.how_to_control": "Permission is needed from your Web browser to use this feature.", + "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": "Tavern", + "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 f463419c8..4d232ad12 100644 --- a/app/javascript/styles/mastodon/variables.scss +++ b/app/javascript/styles/mastodon/variables.scss @@ -53,6 +53,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 2b5d3ffc2..968dd3f67 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/block.rb b/app/lib/activitypub/activity/block.rb index 90477bf33..d8ca9951e 100644 --- a/app/lib/activitypub/activity/block.rb +++ b/app/lib/activitypub/activity/block.rb @@ -11,7 +11,7 @@ class ActivityPub::Activity::Block < ActivityPub::Activity return end - UnfollowService.new.call(target_account, @account) if target_account.following?(@account) + BlockService.new.call(target_account, @account) @account.block!(target_account, uri: @json['id']) unless delete_arrived_first?(@json['id']) end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index d56d47a2d..98bcada7a 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true +# rubocop:disable Metrics/ClassLength class ActivityPub::Activity::Create < ActivityPub::Activity + include ImgProxyHelper + include DomainControlHelper + def perform dereference_object! @@ -43,7 +47,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 +55,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,22 +76,38 @@ 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) unless @account.silenced? + 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 resolve_thread(@status) - fetch_replies(@status) + fetch_replies(@status) unless @account.silenced? check_for_spam distribute(@status) forward_for_reply @@ -108,7 +128,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: @account.sensitized? || @object['sensitive'] || false, @@ -121,9 +144,61 @@ 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 + @params[:visibility] = :unlisted if @account.silenced? && @params[:visibility] == :public + (audience_to + audience_cc).uniq.each do |audience| next if audience == ActivityPub::TagManager::COLLECTIONS[:public] + next (@params[:visibility] = :limited) if domain_not_allowed?(audience) # Unlike with tags, there is no point in resolving accounts we don't already # know here, because silent mentions would only be used for local access @@ -133,15 +208,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity next if account.nil? || @mentions.any? { |mention| mention.account_id == account.id } @mentions << Mention.new(account: account, silent: true) + @params[:visibility] = :unlisted if account.silenced? && @params[:visibility] == :public # If there is at least one silent mention, then the status can be considered # as a limited-audience status, and not strictly a direct message, but only # if we considered a direct message in the first place - next unless @params[:visibility] == :direct && direct_message.nil? + next unless account.suspended? || (@params[:visibility] == :direct && direct_message.nil?) @params[:visibility] = :limited end + @params[:visibility] = :limited if @params[:reply] && @params[:visibility] == :private && @mentions.pluck(:account_id).without(@account.id).present? + # If the payload was delivered to a specific inbox, the inbox owner must have # access to it, unless they already have access to it anyway return if @options[:delivered_to_account_id].nil? || @mentions.any? { |mention| mention.account_id == @options[:delivered_to_account_id] } @@ -204,11 +282,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def process_mention(tag) return if tag['href'].blank? + return (@params[:visibility] = :limited) if domain_not_allowed?(tag['href']) account = account_from_uri(tag['href']) account = ActivityPub::FetchRemoteAccountService.new.call(tag['href']) if account.nil? - return if account.nil? + return (@params[:visibility] = :limited) if account.nil? || account.suspended? @mentions << Mention.new(account: account, silent: false) end @@ -240,7 +319,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 +423,38 @@ 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? + if conversation.blank? + conversation = Conversation.create!(uri: uri, root: object_uri) + elsif conversation.root.blank? + conversation.update!(uri: uri, root: object_uri) + end + elsif conversation.blank? + conversation = Conversation.create!(uri: uri) + end + + conversation rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique retry end @@ -377,7 +486,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 +499,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 +532,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 +576,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 +626,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 +664,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 09b9e5e0e..ab2c34cfd 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 ef00a4e2e..bf5a49f05 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 3f2ae1106..fb1c9d7b2 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -64,29 +64,21 @@ 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 - when 'public' - [COLLECTIONS[:public]] - when 'unlisted', 'private' - [account_followers_url(status.account)] - when 'direct', 'limited' - if status.account.silenced? - # Only notify followers if the account is locally silenced - account_ids = status.active_mentions.pluck(:account_id) - to = 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 - to.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 - status.active_mentions.each_with_object([]) do |mention, result| - result << uri_for(mention.account) - result << account_followers_url(mention.account) if mention.account.group? - end + def to(status, target_domain: nil) + visibility = status.visibility_for_domain(target_domain) + case visibility + when 'public', 'unlisted' + [status.tags.present? ? COLLECTIONS[:public] : account_followers_url(status.account)] + else + account_ids = status.active_mentions.pluck(:account_id) + account_ids |= status.account.follower_ids if visibility == 'private' + + accounts = status.account.silenced? ? status.account.followers.where(id: account_ids) : Account.where(id: account_ids) + accounts = accounts.where(domain: target_domain) if target_domain.present? + + accounts.each_with_object([]) do |account, result| + result << uri_for(account) + result << account_followers_url(account) if account.group? end end end @@ -96,36 +88,32 @@ 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 - when 'public' - cc << account_followers_url(status.account) - when 'unlisted' - cc << COLLECTIONS[:public] + visibility = status.visibility_for_domain(target_domain) + + case visibility + when 'public', 'unlisted' + cc << (status.tags.present? ? account_followers_url(status.account) : COLLECTIONS[:public]) + account_ids = status.active_mentions.pluck(:account_id) + when 'private', 'limited' + # Work around Mastodon visibility heuritic bug by addressing instance actor. + cc << instance_actor_url + account_ids = status.silent_mentions.pluck(:account_id) + else + account_ids = [] end - unless status.direct_visibility? || status.limited_visibility? - if status.account.silenced? - # Only notify followers if the account is locally silenced - account_ids = status.active_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.active_mentions.each_with_object([]) do |mention, result| - result << uri_for(mention.account) - result << account_followers_url(mention.account) if mention.account.group? - end) - end + if account_ids.present? + accounts = status.account.silenced? ? status.account.followers.where(id: account_ids) : Account.where(id: account_ids) + accounts = accounts.where(domain: target_domain) if target_domain.present? + + cc.concat(accounts.each_with_object([]) do |account, result| + result << uri_for(account) + result << account_followers_url(account) if account.group? + end) end cc 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..6b37b66b7 --- /dev/null +++ b/app/lib/command_tag/command/text_tools.rb @@ -0,0 +1,89 @@ +# 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 + + def handle_keysmash_with_return(args) + keyboard = [ + 'asdf', 'jkl;', + 'gh', "'", + 'we', 'io', + 'r', 'u', + 'cv', 'nm', + 't', 'x', ',', + 'q', 'z', + 'y', 'b', + 'p', '.', + '[', ']' + ] + + min_size = [[5, args[1].to_i].max, 100].min + max_size = [args[0].to_i, 100].min + max_size = 33 unless max_size.positive? + + min_size, max_size = [max_size, min_size] if min_size > max_size + + chunk = rand(min_size..max_size).times.map do + keyboard[(keyboard.size * (rand**3)).floor].split('').sample + end + + chunk.join + end + + def transform_keysmash_template_return(_, args) + handle_keysmash_with_return([args[0], args[2]]) + end +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..77be29eba --- /dev/null +++ b/app/lib/command_tag/processor.rb @@ -0,0 +1,343 @@ +# 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 + + # Calls an arbitary public method (if it exists) on a given value and returns the result. + def transform_using(name, value, args = []) + respond_to?(name) ? public_send(name, value, args) : value + 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 = to_integer(parts[1]) + str_start = to_integer(parts[2]) + str_end = to_integer(parts[3]) + + str_start, str_end = [str_end, str_start] if str_start > str_end + + old_value = (['all', '[]'].include?(parts[1]) ? var(name).join(separator) : var(name)[index].to_s) + name = name.gsub(/[^\w_]+/, '_') + new_value = transform_using("transform_#{name}_template_return", old_value, [index, str_start, str_end]) + next new_value if new_value != old_value + + new_value = transform_using("transform_#{name}_template_value", new_value, [index, str_start, str_end]) + (str_end - str_start).zero? ? new_value : new_value[str_start..str_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 3c1f8d6e2..665869b26 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -1,18 +1,17 @@ # frozen_string_literal: true require 'singleton' - class FeedManager include Singleton include Redisable # Maximum number of items stored in a single feed - MAX_ITEMS = 400 + MAX_ITEMS = 1000 # Number of items in the feed since last reblog of status # before the new reblog will be inserted. Must be <= MAX_ITEMS # or the tracking sets will grow forever - REBLOG_FALLOFF = 40 + REBLOG_FALLOFF = 50 # Execute block for every active account # @yield [Account] @@ -40,9 +39,9 @@ class FeedManager def filter?(timeline_type, status, receiver) case timeline_type when :home - filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status])) + filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]), receiver.user&.filters_unknown?) when :list - filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status])) + filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]), receiver.account.user&.filters_unknown?) when :mentions filter_from_mentions?(status, receiver.id) when :direct @@ -57,7 +56,7 @@ class FeedManager # @param [Status] status # @return [Boolean] def push_to_home(account, status) - return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?) + return false unless add_to_feed(:home, account.id, status, account.user&.home_reblogs?) trim(:home, account.id) PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}") @@ -68,8 +67,8 @@ class FeedManager # @param [Account] account # @param [Status] status # @return [Boolean] - def unpush_from_home(account, status) - return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?) + def unpush_from_home(account, status, include_reblogs_list = true) + return false unless remove_from_feed(:home, account.id, status, include_reblogs_list) redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) true @@ -80,7 +79,8 @@ class FeedManager # @param [Status] status # @return [Boolean] def push_to_list(list, status) - return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + return false if filter_from_list?(status, list) + return false unless add_to_feed(:list, list.id, status, list.reblogs?) trim(:list, list.id) PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}") @@ -92,7 +92,7 @@ class FeedManager # @param [Status] status # @return [Boolean] def unpush_from_list(list, status) - return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + return false unless remove_from_feed(:list, list.id, status) redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) true @@ -121,13 +121,33 @@ class FeedManager true 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 + # Fill a home feed with an account's statuses # @param [Account] from_account # @param [Account] into_account # @return [void] def merge_into_home(from_account, into_account) timeline_key = key(:home, into_account.id) - aggregate = into_account.user&.aggregates_reblogs? + reblogs = into_account.user&.home_reblogs? + no_unknown = into_account.user&.filters_unknown? query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4) if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4 @@ -139,9 +159,9 @@ class FeedManager crutches = build_crutches(into_account.id, statuses) statuses.each do |status| - next if filter_from_home?(status, into_account.id, crutches) + next if filter_from_home?(status, into_account.id, crutches, no_unknown) - add_to_feed(:home, into_account.id, status, aggregate) + add_to_feed(:home, into_account.id, status, reblogs) end trim(:home, into_account.id) @@ -153,7 +173,8 @@ class FeedManager # @return [void] def merge_into_list(from_account, list) timeline_key = key(:list, list.id) - aggregate = list.account.user&.aggregates_reblogs? + reblogs = list.account.user&.home_reblogs? + no_unknown = list.account.user&.filters_unknown? query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4) if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4 @@ -165,9 +186,9 @@ class FeedManager crutches = build_crutches(list.account_id, statuses) statuses.each do |status| - next if filter_from_home?(status, list.account_id, crutches) || filter_from_list?(status, list) + next if filter_from_home?(status, list.account_id, crutches, no_unknown) || filter_from_list?(status, list) - add_to_feed(:list, list.id, status, aggregate) + add_to_feed(:list, list.id, status, reblogs) end trim(:list, list.id) @@ -182,7 +203,7 @@ class FeedManager oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status| - remove_from_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?) + remove_from_feed(:home, into_account.id, status) end end @@ -195,7 +216,7 @@ class FeedManager oldest_list_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_list_score).reorder(nil).find_each do |status| - remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + remove_from_feed(:list, list.id, status, !list.reblogs?) end end @@ -219,16 +240,63 @@ class FeedManager end end + # Clear all reblogs from a home feed + # @param [Account] account + # @return [void] + def clear_reblogs_from_home(account) + timeline_key = key(:home, account.id) + timeline_status_ids = redis.zrange(timeline_key, 0, -1) + + Status.reblogs.joins(:reblog).where(reblogs_statuses: { local: false }).where(id: timeline_status_ids).find_each do |status| + unpush_from_home(account, status, false) + end + end + + # Populate list feeds of account from scratch + # @param [Account] account + # @return [void] + def populate_lists(account) + limit = FeedManager::MAX_ITEMS / 2 + + account.owned_lists.includes(:accounts) do |list| + timeline_key = key(:list, list.id) + + list.accounts.includes(:account_stat).find_each do |target_account| + if redis.zcard(timeline_key) >= limit + oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i + last_status_score = Mastodon::Snowflake.id_at(account.last_status_at) + + # If the feed is full and this account has not posted more recently + # than the last item on the feed, then we can skip the whole account + # because none of its statuses would stay on the feed anyway + next if last_status_score < oldest_home_score + end + + statuses = target_account.statuses.published.without_reblogs.where(visibility: [:public, :unlisted, :private]).includes(:mentions, :preloadable_poll).limit(limit) + crutches = build_crutches(account.id, statuses) + + statuses.each do |status| + next if filter_from_list?(status, account.id) || filter_from_home?(status, account.id, crutches, account.user&.filters_unknown?) + + add_to_feed(:list, list.id, status, list.reblogs?) + end + + trim(:list, list.id) + end + end + end + # Populate home feed of account from scratch # @param [Account] account # @return [void] def populate_home(account) limit = FeedManager::MAX_ITEMS / 2 - aggregate = account.user&.aggregates_reblogs? + reblogs = account.user&.home_reblogs? + no_unknown = account.user&.filters_unknown? timeline_key = key(:home, account.id) account.statuses.limit(limit).each do |status| - add_to_feed(:home, account.id, status, aggregate) + add_to_feed(:home, account.id, status, reblogs) end account.following.includes(:account_stat).find_each do |target_account| @@ -242,13 +310,13 @@ 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.where(visibility: [:public, :unlisted, :private]).includes(:mentions, :preloadable_poll, reblog: [:account, :mentions]).limit(limit) crutches = build_crutches(account.id, statuses) statuses.each do |status| - next if filter_from_home?(status, account.id, crutches) + next if filter_from_home?(status, account.id, crutches, no_unknown) - add_to_feed(:home, account.id, status, aggregate) + add_to_feed(:home, account.id, status, reblogs, false) end trim(:home, account.id) @@ -270,6 +338,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 @@ -334,36 +403,55 @@ class FeedManager # @param [Integer] receiver_id # @param [Hash] crutches # @return [Boolean] - def filter_from_home?(status, receiver_id, crutches) + def filter_from_home?(status, receiver_id, crutches, followed_only = false) return false if receiver_id == status.account_id + return true if !status.published? || crutches[:hiding_thread][status.conversation_id] 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.in_reply_to_account_id]) if status.reply? if status.reblog? check_for_blocks.concat([status.reblog.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 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 participants... + should_filter = (status.mentions.pluck(:account_id) - crutches[:following].keys).present? + # ...and the author isn't replying to you... + should_filter &&= receiver_id != status.in_reply_to_account_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... + # ...you don't follow the OP and they're non-local or they're silenced... + should_filter = (followed_only || status.reblog.account.silenced?) && !crutches[:following][status.reblog.account_id] + + # ..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] + + # ...or it's a reply... + if !(should_filter || status.reblog.in_reply_to_account_id.nil?) && status.reblog.reply? + # ...and you don't follow the participants... + should_filter ||= (status.reblog.mentions.pluck(:account_id) - crutches[:following].keys).present? + # ...and the author isn't replying to you... + should_filter &&= receiver_id != status.in_reply_to_account_id + end return !!should_filter end - false + !crutches[:following][status.account_id] end # Check if status should not be added to the mentions feed @@ -381,18 +469,29 @@ 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 following?(account_id, target_account_id) + Follow.where(account_id: account_id, target_account_id: target_account_id).exists? + 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 + # Check if status should not be added to the linear direct message feed # @param [Status] status # @param [Integer] receiver_id # @return [Boolean] def filter_from_direct?(status, receiver_id) return false if receiver_id == status.account_id + filter_from_mentions?(status, receiver_id) end @@ -401,6 +500,9 @@ class FeedManager # @param [List] list # @return [Boolean] def filter_from_list?(status, list) + return true if (list.reblogs? && !status.reblog?) || (!list.reblogs? && status.reblog?) + return true if status.reblog? ? status.reblog.account_id == list.account_id : status.account_id == list.account_id + 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 &&= !list.show_all_replies? @@ -455,13 +557,19 @@ class FeedManager # @param [Symbol] timeline_type # @param [Integer] account_id # @param [Status] status - # @param [Boolean] aggregate_reblogs + # @param [Boolean] home_reblogs + # @param [Boolean] stream # @return [Boolean] - def add_to_feed(timeline_type, account_id, status, aggregate_reblogs = true) + def add_to_feed(timeline_type, account_id, status, home_reblogs = true, stream = true) timeline_key = key(timeline_type, account_id) reblog_key = key(timeline_type, account_id, 'reblogs') - if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs) + if status.reblog? + add_to_reblogs(account_id, status, stream) if timeline_type == :home + return false unless home_reblogs || (timeline_type == :home && (status.reblog.local? || following?(account_id, status.reblog.account_id))) + end + + if status.reblog? # If the original status or a reblog of it is within # REBLOG_FALLOFF statuses from the top, do not re-insert it into # the feed @@ -493,6 +601,8 @@ class FeedManager redis.zadd(timeline_key, status.id, status.id) end + add_to_reblogs(account_id, status, stream) if timeline_type == :home && status.reblog? + true end @@ -503,13 +613,15 @@ class FeedManager # @param [Symbol] timeline_type # @param [Integer] account_id # @param [Status] status - # @param [Boolean] aggregate_reblogs + # @param [Boolean] include_reblogs_list # @return [Boolean] - def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true) + def remove_from_feed(timeline_type, account_id, status, include_reblogs_list = true) timeline_key = key(timeline_type, account_id) reblog_key = key(timeline_type, account_id, 'reblogs') - if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs) + remove_from_reblogs(account_id, status) if include_reblogs_list && timeline_type == :home && status.reblog? + + if status.reblog? # 1. If the reblogging status is not in the feed, stop. status_rank = redis.zrevrank(timeline_key, status.id) return false if status_rank.nil? @@ -547,27 +659,43 @@ class FeedManager def build_crutches(receiver_id, statuses) crutches = {} - crutches[:active_mentions] = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact).pluck(:status_id, :account_id).each_with_object({}) { |(id, account_id), mapping| (mapping[id] ||= []).push(account_id) } - - check_for_blocks = statuses.flat_map do |s| - arr = crutches[:active_mentions][s.id] || [] - arr.concat([s.account_id]) - - if s.reblog? - arr.concat([s.reblog.account_id]) - arr.concat(crutches[:active_mentions][s.reblog_of_id] || []) - end + mentions = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact).pluck(:status_id, :account_id) + participants = statuses.flat_map { |s| [s.account_id, s.in_reply_to_account_id, s.reblog&.account_id, s.reblog&.in_reply_to_account_id].compact } | mentions.map { |m| m[1] } - arr - end + crutches[:active_mentions] = mentions.each_with_object({}) { |(id, account_id), mapping| (mapping[id] ||= []).push(account_id) } - crutches[:following] = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:in_reply_to_account_id).compact).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true } + crutches[:following] = Follow.where(account_id: receiver_id, target_account_id: participants).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true } crutches[:hiding_reblogs] = Follow.where(account_id: receiver_id, target_account_id: statuses.map { |s| s.account_id if s.reblog? }.compact, show_reblogs: false).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true } - crutches[:blocking] = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true } - 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[:blocking] = Block.where(account_id: receiver_id, target_account_id: participants).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true } + crutches[:muting] = Mute.where(account_id: receiver_id, target_account_id: participants).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).pluck(:conversation_id).each_with_object({}) { |id, mapping| mapping[id] = true } crutches end + + def find_or_create_reblogs_list(account_id) + List.find_or_create_by!(account_id: account_id, reblogs: true) do |list| + list.title = I18n.t('accounts.reblogs') + list.replies_policy = :no_replies + end + end + + def add_to_reblogs(account_id, status, stream = true) + reblogs_list_id = find_or_create_reblogs_list(account_id).id + return unless add_to_feed(:list, reblogs_list_id, status) + + trim(:list, reblogs_list_id) + return unless stream && push_update_required?("timeline:list:#{reblogs_list_id}") + + PushUpdateWorker.perform_async(account_id, status.id, "timeline:list:#{reblogs_list_id}") + end + + def remove_from_reblogs(account_id, status) + reblogs_list_id = find_or_create_reblogs_list(account_id).id + return unless remove_from_feed(:list, reblogs_list_id, status) + + redis.publish("timeline:list:#{reblogs_list_id}", Oj.dump(event: :delete, payload: status.id.to_s)) + end end diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index e7bb0743d..e0c1e94db 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -26,6 +26,7 @@ class HTMLRenderer < Redcarpet::Render::HTML end end +# rubocop:disable Metrics/ClassLength class Formatter include Singleton include RoutingHelper @@ -33,50 +34,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 @@ -91,7 +137,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 @@ -100,8 +150,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 @@ -124,8 +178,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") @@ -156,7 +210,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, @@ -392,4 +446,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 0fb415bd1..3bc25fe9f 100644 --- a/app/lib/sanitize_config.rb +++ b/app/lib/sanitize_config.rb @@ -31,28 +31,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| @@ -84,15 +79,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: { @@ -108,7 +105,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..bd3e5245e 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 diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index 581101782..0f927d5d0 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) @@ -32,18 +36,38 @@ 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['style_lowercase'] = style_lowercase_preference if change?('setting_style_lowercase') + 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_unknown'] = filter_unknown_preference if change?('setting_filter_unknown') + user.settings['unpublish_on_delete'] = unpublish_on_delete_preference if change?('setting_unpublish_on_delete') + user.settings['rss_disabled'] = rss_disabled_preference if change?('setting_rss_disabled') + user.settings['home_reblogs'] = home_reblogs_preference if change?('setting_home_reblogs') + user.settings['max_history_public'] = max_history_public_preference if change?('setting_max_history_public') + user.settings['max_history_private'] = max_history_private_preference if change?('setting_max_history_private') + user.settings['web_push'] = web_push_preferences if change?('setting_web_push') end def merged_notification_emails @@ -134,10 +158,6 @@ class UserSettingsDecorator settings['setting_default_language'] end - def aggregate_reblogs_preference - boolean_cast_setting 'setting_aggregate_reblogs' - end - def advanced_layout_preference boolean_cast_setting 'setting_advanced_layout' end @@ -162,6 +182,90 @@ 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 style_lowercase_preference + boolean_cast_setting 'setting_style_lowercase' + 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_unknown_preference + boolean_cast_setting 'setting_filter_unknown' + end + + def unpublish_on_delete_preference + boolean_cast_setting 'setting_unpublish_on_delete' + end + + def rss_disabled_preference + boolean_cast_setting 'setting_rss_disabled' + end + + def home_reblogs_preference + boolean_cast_setting 'setting_home_reblogs' + end + + def max_history_public_preference + settings['setting_max_history_public'].to_i + end + + def max_history_private_preference + settings['setting_max_history_private'].to_i + end + + def web_push_preferences + boolean_cast_setting 'setting_web_push' + end + def boolean_cast_setting(key) ActiveModel::Type::Boolean.new.cast(settings[key]) end @@ -177,4 +281,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 641e984cd..b0def4028 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -52,6 +52,12 @@ # devices_url :string # sensitized_at :datetime # suspension_origin :integer +# 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 @@ -119,6 +125,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, @@ -384,6 +391,38 @@ class Account < ApplicationRecord @synchronization_uri_prefix ||= uri[/http(s?):\/\/[^\/]+\//] 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 @@ -552,6 +591,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 @@ -595,4 +636,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 98849f8fc..ec4e18699 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -63,5 +63,23 @@ module AccountAssociations # Account deletion requests has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy + + # 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 + + # Custom emojis + has_many :custom_emojis, inverse_of: :account, dependent: :nullify end end diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb index 04b2c981b..c414a6d87 100644 --- a/app/models/concerns/account_finder_concern.rb +++ b/app/models/concerns/account_finder_concern.rb @@ -16,6 +16,10 @@ module AccountFinderConcern Account.find(-99) end + def site_contact + Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')).presence || representative + end + def find_local(username) find_remote(username, nil) end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index e2c4b8acf..98d06d586 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -26,7 +26,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?, } @@ -92,9 +92,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, notify: nil, uri: nil, rate_limit: false) @@ -131,17 +132,18 @@ module AccountInteractions .find_or_create_by!(target_account: other_account) end - def mute!(other_account, notifications: nil, duration: 0) + def mute!(other_account, notifications: nil, timelines_only: nil, duration: 0) notifications = true if notifications.nil? - mute = mute_relationships.create_with(hide_notifications: notifications).find_or_initialize_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_initialize_by(target_account: other_account) mute.expires_in = duration.zero? ? nil : duration mute.save! 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) + if mute.hide_notifications? != notifications || mute.timelines_only? != timelines_only + mute.update!(hide_notifications: notifications, timelines_only: timelines_only) end mute @@ -180,6 +182,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 @@ -193,7 +204,7 @@ 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) @@ -208,6 +219,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..6f776e052 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -7,12 +7,14 @@ # uri :string # created_at :datetime not null # updated_at :datetime 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 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/custom_emoji.rb b/app/models/custom_emoji.rb index 7cb03b819..c819288ba 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -18,6 +18,7 @@ # visible_in_picker :boolean default(TRUE), not null # category_id :bigint(8) # image_storage_schema_version :integer +# account_id :bigint(8) # class CustomEmoji < ApplicationRecord @@ -32,6 +33,7 @@ class CustomEmoji < ApplicationRecord IMAGE_MIME_TYPES = %w(image/png image/gif).freeze belongs_to :category, class_name: 'CustomEmojiCategory', optional: true + belongs_to :account, inverse_of: :custom_emojis, optional: true has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } } @@ -46,6 +48,7 @@ class CustomEmoji < ApplicationRecord scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) } scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) } scope :listed, -> { local.where(disabled: false).where(visible_in_picker: true) } + scope :owned_by, ->(account) { where(account: account) } remotable_attachment :image, LIMIT @@ -61,8 +64,11 @@ class CustomEmoji < ApplicationRecord :emoji end - def copy! + def copy!(current_account = nil) copy = self.class.find_or_initialize_by(domain: nil, shortcode: shortcode) + return copy if copy.account_id.present? && copy.account_id != current_account&.id + + copy.account = current_account copy.image = image copy.tap(&:save!) end diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb index 414e1fcdd..58c888518 100644 --- a/app/models/custom_emoji_filter.rb +++ b/app/models/custom_emoji_filter.rb @@ -5,13 +5,16 @@ class CustomEmojiFilter local remote by_domain + claimed + unclaimed shortcode ).freeze attr_reader :params - def initialize(params) + def initialize(params, account) @params = params + @account = account end def results @@ -36,6 +39,10 @@ class CustomEmojiFilter CustomEmoji.remote when 'by_domain' CustomEmoji.where(domain: value.strip.downcase) + when 'claimed' + CustomEmoji.where(account: @account) + when 'unclaimed' + CustomEmoji.where(account: nil) when 'shortcode' CustomEmoji.search(value.strip) else 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 c1f19149b..5899a7f0e 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -30,7 +30,10 @@ class FollowRequest < ApplicationRecord def authorize! account.follow!(target_account, reblogs: show_reblogs, notify: notify, 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/form/custom_emoji_batch.rb b/app/models/form/custom_emoji_batch.rb index f4fa84c10..54a15dc18 100644 --- a/app/models/form/custom_emoji_batch.rb +++ b/app/models/form/custom_emoji_batch.rb @@ -24,13 +24,17 @@ class Form::CustomEmojiBatch copy! when 'delete' delete! + when 'claim' + claim! + when 'unclaim' + unclaim! end end private - def custom_emojis - @custom_emojis ||= CustomEmoji.where(id: custom_emoji_ids) + def custom_emojis(include_all = false) + @custom_emojis ||= (include_all || current_account&.user&.staff? ? CustomEmoji.where(id: custom_emoji_ids) : CustomEmoji.local.where(id: custom_emoji_ids, account: current_account)) end def update! @@ -40,10 +44,12 @@ class Form::CustomEmojiBatch if category_id.present? CustomEmojiCategory.find(category_id) elsif category_name.present? - CustomEmojiCategory.find_or_create_by!(name: category_name) + CustomEmojiCategory.find_or_create_by!(name: current_account&.user&.staff? ? category_name.strip : "(@#{current_account.username}) #{category_name}".rstrip) end end + return if category.name.start_with?('(@') && !category.name.start_with?("(@#{current_account.username}) ") + custom_emojis.each do |custom_emoji| custom_emoji.update(category_id: category&.id) log_action :update, custom_emoji @@ -87,10 +93,10 @@ class Form::CustomEmojiBatch end def copy! - custom_emojis.each { |custom_emoji| authorize(custom_emoji, :copy?) } + custom_emojis(true).each { |custom_emoji| authorize(custom_emoji, :copy?) } custom_emojis.each do |custom_emoji| - copied_custom_emoji = custom_emoji.copy! + copied_custom_emoji = custom_emoji.copy!(current_account) log_action :create, copied_custom_emoji end end @@ -103,4 +109,27 @@ class Form::CustomEmojiBatch log_action :destroy, custom_emoji end end + + def claim! + custom_emojis(true).each { |custom_emoji| authorize(custom_emoji, :claim?) } + + custom_emojis.each do |custom_emoji| + if custom_emoji.local? + custom_emoji.update(account: current_account) + log_action :update, custom_emoji + else + copied_custom_emoji = custom_emoji.copy!(current_account) + log_action :create, copied_custom_emoji + end + end + end + + def unclaim! + custom_emojis.each { |custom_emoji| authorize(custom_emoji, :unclaim?) } + + custom_emojis.each do |custom_emoji| + custom_emoji.update(account: nil) + log_action :update, custom_emoji + end + end end 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 7ea4e2f98..d60866ad6 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/list.rb b/app/models/list.rb index 8493046e5..006b7e745 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -9,12 +9,13 @@ # created_at :datetime not null # updated_at :datetime not null # replies_policy :integer default("list_replies"), not null +# reblogs :boolean default(FALSE), not null # class List < ApplicationRecord include Paginable - PER_ACCOUNT_LIMIT = 50 + PER_ACCOUNT_LIMIT = 100 enum replies_policy: [:list_replies, :all_replies, :no_replies], _prefix: :show 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 fe8b6f42c..07135b215 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -9,7 +9,7 @@ # hide_notifications :boolean default(TRUE), not null # account_id :bigint(8) not null # target_account_id :bigint(8) not null -# hide_notifications :boolean default(TRUE), not null +# timelines_only :boolean default(FALSE), not null # expires_at :datetime # diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb index 2839da5cb..7418a1c9d 100644 --- a/app/models/public_feed.rb +++ b/app/models/public_feed.rb @@ -26,7 +26,8 @@ class PublicFeed < Feed scope.merge!(without_replies_scope) unless with_replies? scope.merge!(without_reblogs_scope) unless with_reblogs? scope.merge!(local_only_scope) if local_only? - scope.merge!(remote_only_scope) if remote_only? + #scope.merge!(remote_only_scope) if remote_only? + scope.merge!(curated_scope) unless local_only? || remote_only? scope.merge!(account_filters_scope) if account? scope.merge!(media_only_scope) if media_only? @@ -79,6 +80,10 @@ class PublicFeed < Feed Status.remote end + def curated_scope + Status.curated + end + def without_replies_scope Status.without_replies end 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 d1ac2e4f2..73eda2c4c 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 +# original_text :text +# footer :text +# expires_at :datetime +# publish_at :datetime +# originally_local_only :boolean default(FALSE), not null +# curated :boolean default(FALSE), not null # +# rubocop:disable Metrics/ClassLength class Status < ApplicationRecord before_destroy :unlink_from_conversations @@ -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 @@ -90,9 +107,10 @@ class Status < ApplicationRecord scope :remote, -> { where(local: false).where.not(uri: nil) } scope :local, -> { where(local: true).or(where(uri: nil)) } 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_replies, -> { where(reply: false) } 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_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) } scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) } scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) } @@ -113,6 +131,21 @@ 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 :reblogs, -> { where('statuses.reblog_of_id IS NOT NULL') } + scope :locally_reblogged, -> { where(id: Status.unscoped.local.reblogs.select(:reblog_of_id)) } + scope :mentioning_account, ->(account) { joins(:mentions).where(mentions: { account: account }) } + scope :replies, -> { where(reply: true) } + 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 :curated, -> { where(curated: true) } + + 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 != ?)', account.id, account.id) + end + cache_associated :application, :media_attachments, :conversation, @@ -136,8 +169,21 @@ 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 + HISTORY_VALUES = [0, 1, 2, 3, 6, 12, 18, 24, 36, 52, 104, 156].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 curate! + update_column(:curated, true) if public_visibility? && !curated + end + + def uncurate! + update_column(:curated, false) if curated + 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_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_conversation_root + class << self def selectable_visibilities visibilities.keys - %w(direct limited) @@ -346,6 +467,10 @@ 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_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 @@ -370,26 +495,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) @@ -406,6 +531,67 @@ class Status < ApplicationRecord status&.distributable? ? status : nil end.compact end + + private + + # 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] + + 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 + + if options[:include_replies] + query = query.replies if options[:only_replies] + else + query = query.without_replies + end + end + + if target_account.id != account&.id && target_account&.user&.max_history_public.present? + history_limit = account&.following?(target_account) ? target_account.user.max_history_private : target_account.user.max_history_public + query = query.where('statuses.updated_at >= ?', history_limit.weeks.ago) if history_limit.positive? + 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 end def marked_local_only? @@ -433,9 +619,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 @@ -446,31 +638,35 @@ 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.visibility = :limited if visibility.to_s == 'private' && in_reply_to_account_id != account_id end end + def set_conversation_root + conversation.update!(root: uri) if !reply && conversation.present? && 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 @@ -483,6 +679,28 @@ 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 update_statistics return unless distributable? @@ -516,3 +734,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 3dcfd820e..167bfd605 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -42,6 +42,8 @@ # sign_in_token_sent_at :datetime # webauthn_id :string # sign_up_ip :inet +# username :string +# kobold :string # class User < ApplicationRecord @@ -90,7 +92,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) } @@ -100,6 +102,7 @@ class User < ApplicationRecord scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) } scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.sign_up_ip <<= ?', value)).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) } scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) } + scope :lower_username, ->(username) { where('lower(users.username) = lower(?)', username) } before_validation :sanitize_languages before_create :set_approved @@ -114,9 +117,15 @@ class User < ApplicationRecord delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal, :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_network, :hide_followers_count, - :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, + :expand_spoilers, :default_language, :show_application, :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, :disable_swiping, :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, :style_lowercase, + :publish_in, :unpublish_in, :unpublish_delete, :boost_every, :boost_jitter, + :boost_random, :unpublish_on_delete, :rss_disabled, :home_reblogs, + :filter_unknown, :max_history_public, :max_history_private, :web_push, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code, :sign_in_token_attempt @@ -150,7 +159,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 @@ -174,7 +183,7 @@ class User < ApplicationRecord end def suspicious_sign_in?(ip) - !otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 2.weeks.ago && !recent_ip?(ip) + !otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 12.weeks.ago && !recent_ip?(ip) end def functional? @@ -247,14 +256,26 @@ class User < ApplicationRecord @hides_network ||= settings.hide_network end - def aggregates_reblogs? - @aggregates_reblogs ||= settings.aggregate_reblogs - end - def shows_application? @shows_application ||= settings.show_application end + def home_reblogs? + @home_reblogs ||= settings.home_reblogs + end + + def filters_unknown? + @filters_unknown ||= settings.filter_unknown + end + + def max_history_private + @max_history_private ||= settings.max_history_private.to_i + end + + def max_history_public + @max_history_public ||= [settings.max_history_public.to_i, max_history_private].min + end + # rubocop:disable Naming/MethodParameterName def token_for_app(a) return nil if a.nil? || a.owner != self @@ -308,6 +329,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? @@ -427,7 +459,7 @@ class User < ApplicationRecord def notify_staff_about_pending_account! User.staff.includes(:account).find_each do |u| - next unless u.allows_pending_account_emails? + next unless u.account.actor_type == 'Person' && u.allows_pending_account_emails? AdminMailer.new_pending_account(u.account, self).deliver_later end end @@ -445,4 +477,30 @@ class User < ApplicationRecord def validate_email_dns? email_changed? && !(Rails.env.test? || Rails.env.development?) end + + def user_might_not_be_a_spam_bot + return false unless username.downcase == account.username.downcase + + update(username: account.username) unless username == account.username + invited? || (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.gsub(/\r\n?/, "\n")].compact.map(&:downcase).join("\u{F0666}") + Digest::SHA512.hexdigest(value).upcase + end + + class << self + def find_by_lower_username(username) + lower_username(username).first + end + + def find_by_lower_username!(username) + lower_username(username).first! + end + 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/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb index a8c3cbc73..7e585a3d6 100644 --- a/app/policies/custom_emoji_policy.rb +++ b/app/policies/custom_emoji_policy.rb @@ -2,30 +2,52 @@ class CustomEmojiPolicy < ApplicationPolicy def index? - staff? + user_signed_in? end def create? - admin? + user_signed_in? end def update? - admin? + user_signed_in? && owned? end def copy? - admin? + staff? || (user_signed_in? && new_or_owned?) end def enable? - staff? + user_signed_in? && owned? end def disable? - staff? + user_signed_in? && owned? end def destroy? - admin? + user_signed_in? && owned? + end + + def claim? + staff? || claimable? + end + + def unclaim? + user_signed_in? && owned? + end + + private + + def owned? + staff? || (current_account.present? && record.account_id == current_account.id) + end + + def new_or_owned? + !CustomEmoji.where(domain: nil, shortcode: record.shortcode).where('account_id IS NULL OR account_id != ?', current_account.id).exists? + end + + def claimable? + record.local? ? record.account_id.blank? || record.account_id == current_account.id : new_or_owned? end end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index d0359580d..56c217cec 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -13,7 +13,8 @@ class StatusPolicy < ApplicationPolicy def show? return false if author.suspended? - 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? @@ -25,7 +26,7 @@ class StatusPolicy < ApplicationPolicy end def reblog? - !requires_mention? && (!private? || owned?) && show? && !blocking_author? + published? && !requires_mention? && (!private? || owned?) && show? && !blocking_author? end def favourite? @@ -45,7 +46,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? @@ -53,7 +54,7 @@ class StatusPolicy < ApplicationPolicy end def private? - record.private_visibility? + visibility_for_remote_domain == 'private' end def mention_exists? @@ -79,7 +80,7 @@ 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 @@ -97,4 +98,12 @@ class StatusPolicy < ApplicationPolicy def local_only? record.local_only? end + + def published? + record.published? + 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..d898a9188 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_statuses_map + def initialize(statuses, current_account_id = nil, **options) if current_account_id.nil? @reblogs_map = {} @@ -11,6 +13,8 @@ class StatusRelationshipsPresenter @bookmarks_map = {} @mutes_map = {} @pins_map = {} + + @hidden_statuses_map = {} else statuses = statuses.compact status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact @@ -22,6 +26,8 @@ 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_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 759ef30f9..0c6205d7f 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -25,6 +25,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer attribute :also_known_as, if: :also_known_as? attribute :suspended, if: :suspended? + 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 @@ -146,6 +150,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer object.suspended? ? [] : (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 4ac699ddf..0b2ab0cfd 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 sensitive @@ -178,6 +205,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/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 470cec8a1..654f2f3dd 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -57,6 +57,7 @@ class InitialStateSerializer < ActiveModel::Serializer store[:default_content_type] = object.current_account.user.setting_default_content_type store[:system_emoji_font] = object.current_account.user.setting_system_emoji_font store[:crop_images] = object.current_account.user.setting_crop_images + store[:web_push] = object.current_account.user.setting_web_push else store[:auto_play_gif] = Setting.auto_play_gif store[:display_media] = Setting.display_media 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 5cc42c7cf..133f66201 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/list_serializer.rb b/app/serializers/rest/list_serializer.rb index 3e87f7119..45c8dca67 100644 --- a/app/serializers/rest/list_serializer.rb +++ b/app/serializers/rest/list_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class REST::ListSerializer < ActiveModel::Serializer - attributes :id, :title, :replies_policy + attributes :id, :title, :replies_policy, :reblogs def id object.id.to_s 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 b5dcf6208..4433713a7 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,32 @@ 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 :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 +58,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 @@ -72,14 +99,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 @@ -104,6 +147,14 @@ class REST::StatusSerializer < ActiveModel::Serializer 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 @@ -135,6 +186,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..e36ca9f39 100644 --- a/app/services/activitypub/fetch_replies_service.rb +++ b/app/services/activitypub/fetch_replies_service.rb @@ -1,49 +1,27 @@ # 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? - - FetchReplyWorker.push_bulk(filtered_replies) - - @items + fetch_collection_items(collection, **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 4cb8e09db..7e50dc776 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -36,13 +36,14 @@ 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? after_suspension_change! if suspension_changed? + return after_key_change! if key_changed? && !@options[:signed_with_known_key] unless @options[:only_key] || @account.suspended? check_featured_collection! if @account.featured_collection_url.present? check_links! unless @account.fields.empty? + process_sync end @account @@ -91,6 +92,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! @@ -121,7 +127,8 @@ class ActivityPub::ProcessAccountService < BaseService end def after_key_change! - RefollowWorker.perform_async(@account.id) + ResetAccountWorker.perform_async(@account.id) + nil end def after_suspension_change! @@ -317,4 +324,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_domain_from_account_service.rb b/app/services/after_block_domain_from_account_service.rb index 89d007c1c..58bd69bdd 100644 --- a/app/services/after_block_domain_from_account_service.rb +++ b/app/services/after_block_domain_from_account_service.rb @@ -11,6 +11,7 @@ class AfterBlockDomainFromAccountService < BaseService @domain = domain clear_notifications! + defederate_from_domain! remove_follows! reject_existing_followers! reject_pending_follow_requests! @@ -18,6 +19,10 @@ class AfterBlockDomainFromAccountService < BaseService private + def defederate_from_domain! + DefederateAccountService.new.call(@account, @domain) + end + def remove_follows! @account.active_relationships.where(target_account: Account.where(domain: @domain)).includes(:target_account).reorder(nil).find_each do |follow| UnfollowService.new.call(@account, follow.target_account) diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb index 314919df8..36f891988 100644 --- a/app/services/after_block_service.rb +++ b/app/services/after_block_service.rb @@ -1,13 +1,18 @@ # frozen_string_literal: true class AfterBlockService < BaseService - def call(account, target_account) + def call(account, target_account, defederate: true) @account = account @target_account = target_account clear_home_feed! clear_notifications! clear_conversations! + + return unless defederate + + defederate_interactions! + unlink_interactions! end private @@ -23,4 +28,27 @@ class AfterBlockService < BaseService def clear_notifications! Notification.where(account: @account).where(from_account: @target_account).in_batches.delete_all end + + def unlink_interactions! + @target_account.statuses.where(in_reply_to_account_id: @account.id).in_batches.update_all(in_reply_to_account_id: nil) + @target_account.mentions.where(account_id: @account.id).in_batches.destroy_all + end + + def defederate_interactions! + defederate_statuses!(@account.statuses.where(in_reply_to_account_id: @target_account.id)) + defederate_statuses!(@account.statuses.joins(:mentions).where(mentions: { account_id: @target_account.id })) + defederate_statuses!(@account.statuses.joins(:reblog).where(reblogs_statuses: { account_id: @target_account.id })) + defederate_favourites! + end + + def defederate_statuses!(statuses) + statuses.find_each { |status| RemovalWorker.perform_async(status.id, unpublish: true, blocking: @target_account.id) } + end + + def defederate_favourites! + favourites = @account.favourites.joins(:status).where(statuses: { account_id: @target_account.id }) + favourites.pluck(:status_id).each do |status_id| + UnfavouriteWorker.perform_async(@account.id, status_id) + end + end end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 707672ee0..ef68dc5bc 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -73,18 +73,12 @@ class BatchedRemoveStatusService < BaseService redis.pipelined do redis.publish('timeline:public', payload) - if status.local? - redis.publish('timeline:public:local', payload) - else - redis.publish('timeline:public:remote', payload) - end + redis.publish('timeline:public:local', payload) if status.local? + redis.publish('timeline:public:remote', payload) if status.media_attachments.any? 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 + redis.publish('timeline:public:local:media', payload) if status.local? + redis.publish('timeline:public:remote:media', payload) end @tags[status.id].each do |hashtag| diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index 76cc36ff6..6bfc4d10e 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -23,6 +23,7 @@ class BlockDomainService < BaseService if domain_block.silence? silence_accounts! elsif domain_block.suspend? + DefederateDomainService.new.call(domain_block.domain) suspend_accounts! 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/defederate_account_serivice.rb b/app/services/defederate_account_serivice.rb new file mode 100644 index 000000000..5d9cc1597 --- /dev/null +++ b/app/services/defederate_account_serivice.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class DefederateAccountService < BaseService + include Payloadable + + def call(account, domains) + @account = account + @domains = domains + + return if account.blank? || !account.local? || domains.blank? + + distribute_delete_actor! + end + + private + + def distribute_delete_actor! + ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url| + [delete_actor_json, @account.id, inbox_url] + end + + ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url| + [delete_actor_json, @account.id, inbox_url] + end + end + + def delete_actor_json + @delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account)) + end + + def delivery_inboxes + @delivery_inboxes ||= @account.followers.where(domain: @domains).inboxes + end + + def low_priority_delivery_inboxes + Account.where(domain: @domains).inboxes - delivery_inboxes + end +end diff --git a/app/services/defederate_domain_service.rb b/app/services/defederate_domain_service.rb new file mode 100644 index 000000000..d40f88e3f --- /dev/null +++ b/app/services/defederate_domain_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class DefederateDomainService < BaseService + def call(domains) + return if domains.blank? + + Account.local.find_each do |account| + DefederateAccountService.new.call(account, domains) + end + end +end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 6fa98ce12..1fa8b2520 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,18 +15,21 @@ 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.account.silenced? return if status.reblog? && !Setting.show_reblogs_in_public_timelines - render_anonymous_payload(status) - - deliver_to_hashtags(status) + if status.distributable? + render_anonymous_payload(status) + deliver_to_hashtags(status) + end + return unless status.public_visibility? return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines deliver_to_public(status) @@ -87,29 +91,23 @@ class FanOutOnWriteService < BaseService def deliver_to_public(status) Rails.logger.debug "Delivering status #{status.id} to public timeline" - Redis.current.publish('timeline:public', @payload) - if status.local? - Redis.current.publish('timeline:public:local', @payload) - else - Redis.current.publish('timeline:public:remote', @payload) - end + Redis.current.publish('timeline:public', @payload) if status.curated? + Redis.current.publish('timeline:public:local', @payload) if status.local? + Redis.current.publish('timeline:public:remote', @payload) end def deliver_to_media(status) Rails.logger.debug "Delivering status #{status.id} to media timeline" - Redis.current.publish('timeline:public:media', @payload) - if status.local? - Redis.current.publish('timeline:public:local:media', @payload) - else - Redis.current.publish('timeline:public:remote:media', @payload) - end + Redis.current.publish('timeline:public:media', @payload) if status.curated? + Redis.current.publish('timeline:public:local:media', @payload) if status.local? + Redis.current.publish('timeline:public:remote:media', @payload) end 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..a12bf9533 --- /dev/null +++ b/app/services/mute_conversation_service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class MuteConversationService < BaseService + def call(account, conversation) + return if account.blank? || conversation.blank? + + account.mute_conversation!(conversation) + MuteConversationWorker.perform_async(account.id, conversation.id) + end +end diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 9ae9afd62..5e7e1d292 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true class MuteService < BaseService - def call(account, target_account, notifications: nil, duration: 0) + def call(account, target_account, notifications: nil, timelines_only: nil, duration: 0) return if account.id == target_account.id - mute = account.mute!(target_account, notifications: notifications, duration: duration) + mute = account.mute!(target_account, notifications: notifications, timelines_only: timelines_only, duration: duration) if mute.hide_notifications? - BlockWorker.perform_async(account.id, target_account.id) + BlockWorker.perform_async(account.id, target_account.id, defederate: false) else MuteWorker.perform_async(account.id, target_account.id) end 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 fc187db40..c241c3ca0 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -51,6 +51,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 @@ -83,7 +88,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..216dedeac 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 @@ -12,7 +13,10 @@ class PostStatusService < BaseService # @option [Status] :thread Optional status to reply to # @option [Boolean] :sensitive # @option [String] :visibility + # @option [Boolean] :local_only # @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 +24,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 +57,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 +74,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 +100,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 +127,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 +163,10 @@ class PostStatusService < BaseService ProcessHashtagsService.new end + def process_command_tags_service + ProcessCommandTagsService.new + end + def scheduled? @scheduled_at.present? end @@ -156,24 +197,33 @@ 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, + local_only: @options[:local_only], 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 +248,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 b4fa70710..56d9d2f68 100644 --- a/app/services/precompute_feed_service.rb +++ b/app/services/precompute_feed_service.rb @@ -4,6 +4,7 @@ class PrecomputeFeedService < BaseService def call(account) FeedManager.instance.populate_home(account) FeedManager.instance.populate_direct_feed(account) + FeedManager.instance.populate_lists(account) ensure Redis.current.del("account:#{account.id}:regeneration") end 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 570cd8272..56cfbdccd 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 Webfinger::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, :mention) + LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention) 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, { synchronize_followers: !mention.status.distributable? }) + ActivityPub::DeliveryWorker.perform_async(activitypub_json(mentioned_account.domain), mention.status.account_id, mentioned_account.inbox_url, { synchronize_followers: !mention.status.distributable? }) 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 6f018e24b..3188bbb69 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -28,7 +28,8 @@ 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) + curate!(reblogged_status) unless reblogged_status.curated? || !reblogged_status.published? DistributionWorker.perform_async(reblog.id) ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only? @@ -60,6 +61,11 @@ 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 + + def curate!(status) + status.curate! + DistributionWorker.perform_async(status.id) 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..8025f235b 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -15,13 +15,13 @@ 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 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 +30,10 @@ 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 + remove_from_spam_check unless @options[:unpublish] + remove_media unless @options[:unpublish] - @status.destroy! if @options[:immediate] || !@status.reported? + @status.destroy! if @options[:immediate] || !((@options[:unpublish] && @status.local?) || @status.reported?) else raise Mastodon::RaceConditionError end @@ -44,10 +44,17 @@ class RemoveStatusService < BaseService # original object being removed implicitly removes reblogs # of it. The Delete activity of the original is forwarded # separately. - return if !@account.local? || @options[:original_removed] + return if !@account.local? || @options[:original_removed] || !(status.published? || @options[:unpublished]) remove_from_remote_followers remove_from_remote_affected + + @status.mentions.where(account_id: @options[:blocking]).destroy_all if @options[:blocking] + + return unless @options[:unpublish] + + @status.update(published: false, expires_at: nil, local_only: @status.local?) + DistributionWorker.perform_async(@status.id) if @status.local? end private @@ -98,6 +105,10 @@ class RemoveStatusService < BaseService [signed_activity_json, @account.id, inbox_url] end + ActivityPub::DeliveryWorker.push_bulk(@account.following.inboxes) do |inbox_url| + [signed_activity_json, @account.id, inbox_url] + end + relay! if relayable? end @@ -107,12 +118,12 @@ class RemoveStatusService < BaseService def relay! ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| - [signed_activity_json, @account.id, inbox_url] + [signed_activity_json(Addressable::URI.parse(inbox_url).host), @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 +141,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,25 +150,19 @@ 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? - redis.publish('timeline:public:local', @payload) - else - redis.publish('timeline:public:remote', @payload) - end + redis.publish('timeline:public:local', @payload) if @status.local? + redis.publish('timeline:public:remote', @payload) end def remove_from_media - return unless @status.public_visibility? + return unless @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 + redis.publish('timeline:public:local:media', @payload) if @status.local? + redis.publish('timeline:public:remote:media', @payload) end def remove_from_direct diff --git a/app/services/resolve_mentions_service.rb b/app/services/resolve_mentions_service.rb new file mode 100644 index 000000000..6478dc902 --- /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 Webfinger::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 2b10ac1e0..8e7ae82de 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 @@ -52,7 +52,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..f4762631c --- /dev/null +++ b/app/services/revoke_status_service.rb @@ -0,0 +1,98 @@ +# 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) + redis.publish('timeline:public:local', @payload) if @status.local? + redis.publish('timeline:public:remote', @payload) + end + + def remove_from_media + return if @status.distributable? + + redis.publish('timeline:public:media', @payload) + redis.publish('timeline:public:local:media', @payload) if @status.local? + redis.publish('timeline:public:remote:media', @payload) + 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/suspend_account_service.rb b/app/services/suspend_account_service.rb index 7c70a6021..9bf94bb2b 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -66,9 +66,11 @@ class SuspendAccountService < BaseService styles = [:original] | attachment.styles.keys styles.each do |style| + next if attachment.path(style).blank? + case Paperclip::Attachment.default_options[:storage] when :s3 - attachment.s3_object(style).acl.put(acl: 'private') + attachment.s3_object(style).acl.put({ acl: 'private' }) when :fog # Not supported when :filesystem diff --git a/app/services/unallow_domain_service.rb b/app/services/unallow_domain_service.rb index fc5260761..0d2d8f254 100644 --- a/app/services/unallow_domain_service.rb +++ b/app/services/unallow_domain_service.rb @@ -4,7 +4,7 @@ class UnallowDomainService < BaseService include DomainControlHelper def call(domain_allow) - suspend_accounts!(domain_allow.domain) if whitelist_mode? + suspend_accounts!(domain_allow.domain) domain_allow.destroy end @@ -12,6 +12,7 @@ class UnallowDomainService < BaseService private def suspend_accounts!(domain) + DomainDefederationWorker.perform_async(domain) Account.where(domain: domain).in_batches.update_all(suspended_at: Time.now.utc) AfterUnallowDomainWorker.perform_async(domain) 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..cedd534ea --- /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[: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? + + RemoveStatusService.new.call(@status, unpublish: true) if @status.published? && !@status.local_only? && @params[:local_only] + 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..4cacb6f3c 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 1a81b96f6..d38d57894 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..ab2d33d69 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/admin/custom_emojis/_custom_emoji.html.haml b/app/views/custom_emojis/_custom_emoji.html.haml index 526c844e9..e124373c6 100644 --- a/app/views/admin/custom_emojis/_custom_emoji.html.haml +++ b/app/views/custom_emojis/_custom_emoji.html.haml @@ -7,6 +7,7 @@ .batch-table__row__content__text %samp= ":#{custom_emoji.shortcode}:" + %p.hint.muted-hint{ title: t('admin.custom_emojis.owner') }= custom_emoji.account_id.present? ? "@#{custom_emoji.account.username}" : t('admin.custom_emojis.unclaimed') if custom_emoji.local? - if custom_emoji.local? %span.account-role.bot= custom_emoji.category&.name || t('admin.custom_emojis.uncategorized') diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/custom_emojis/index.html.haml index b6cf7ba64..f81d91d53 100644 --- a/app/views/admin/custom_emojis/index.html.haml +++ b/app/views/custom_emojis/index.html.haml @@ -3,7 +3,9 @@ - if can?(:create, :custom_emoji) - content_for :heading_actions do - = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button' + = link_to t('admin.custom_emojis.upload'), new_custom_emoji_path, class: 'button' + +%p= t('admin.custom_emojis.ownership_warning') .filters .filter-subset @@ -11,17 +13,27 @@ %ul %li= filter_link_to t('admin.accounts.location.all'), local: nil, remote: nil %li - - if selected? local: '1', remote: nil - = filter_link_to t('admin.accounts.location.local'), {local: nil, remote: nil}, {local: '1', remote: nil} + - if selected? local: '1', remote: nil, claimed: nil, unclaimed: nil + = filter_link_to t('admin.accounts.location.local'), {local: nil, remote: nil, claimed: nil, unclaimed: nil}, {local: '1', remote: nil, claimed: nil, unclaimed: nil} + - else + = filter_link_to t('admin.accounts.location.local'), local: '1', remote: nil, claimed: nil, unclaimed: nil + %li + - if selected? remote: '1', local: nil, claimed: nil, unclaimed: nil + = filter_link_to t('admin.accounts.location.remote'), {remote: nil, local: nil, claimed: nil, unclaimed: nil}, {remote: '1', local: nil, claimed: nil, unclaimed: nil} - else - = filter_link_to t('admin.accounts.location.local'), local: '1', remote: nil + = filter_link_to t('admin.accounts.location.remote'), remote: '1', local: nil, claimed: nil, unclained: nil %li - - if selected? remote: '1', local: nil - = filter_link_to t('admin.accounts.location.remote'), {remote: nil, local: nil}, {remote: '1', local: nil} + - if selected? local: '1', remote: nil, claimed: '1', unclaimed: nil + = filter_link_to t('admin.accounts.location.claimed'), {local: '1', remote: nil, claimed: nil, unclaimed: nil}, {local: '1', remote: nil, claimed: '1', unclaimed: nil} - else - = filter_link_to t('admin.accounts.location.remote'), remote: '1', local: nil + = filter_link_to t('admin.accounts.location.claimed'), local: '1', remote: nil, claimed: '1', unclaimed: nil + %li + - if selected? local: '1', remote: nil, claimed: nil, unclaimed: '1' + = filter_link_to t('admin.accounts.location.unclaimed'), {local: '1', remote: nil, claimed: nil, unclaimed: nil}, {local: '1', remote: nil, claimed: nil, unclaimed: '1'} + - else + = filter_link_to t('admin.accounts.location.unclaimed'), local: '1', remote: nil, claimed: nil, unclaimed: '1' -= form_tag admin_custom_emojis_url, method: 'GET', class: 'simple_form' do += form_tag custom_emojis_url, method: 'GET', class: 'simple_form' do .fields-group - CustomEmojiFilter::KEYS.each do |key| = hidden_field_tag key, params[key] if params[key].present? @@ -32,9 +44,9 @@ .actions %button.button= t('admin.accounts.search') - = link_to t('admin.accounts.reset'), admin_custom_emojis_path, class: 'button negative' + = link_to t('admin.accounts.reset'), custom_emojis_path, class: 'button negative' -= form_for(@form, url: batch_admin_custom_emojis_path) do |f| += form_for(@form, url: batch_custom_emojis_path) do |f| = hidden_field_tag :page, params[:page] || 1 - CustomEmojiFilter::KEYS.each do |key| @@ -45,6 +57,10 @@ %label.batch-table__toolbar__select.batch-checkbox-all = check_box_tag :batch_checkbox_all, nil, false .batch-table__toolbar__actions + = f.button safe_join([fa_icon('lock'), t('admin.custom_emojis.claim')]), name: :claim, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + = f.button safe_join([fa_icon('unlock'), t('admin.custom_emojis.unclaim')]), name: :unclaim, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + - if params[:local] == '1' = f.button safe_join([fa_icon('save'), t('generic.save_changes')]), name: :update, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } @@ -56,10 +72,9 @@ = f.button safe_join([fa_icon('power-off'), t('admin.custom_emojis.disable')]), name: :disable, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - - if can?(:destroy, :custom_emoji) - = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - - if can?(:copy, :custom_emoji) && params[:local] != '1' + - if params[:local] != '1' = f.button safe_join([fa_icon('copy'), t('admin.custom_emojis.copy')]), name: :copy, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - if params[:local] == '1' diff --git a/app/views/admin/custom_emojis/new.html.haml b/app/views/custom_emojis/new.html.haml index e15a07cb8..fe9d8fc64 100644 --- a/app/views/admin/custom_emojis/new.html.haml +++ b/app/views/custom_emojis/new.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('.title') -= simple_form_for @custom_emoji, url: admin_custom_emojis_path do |f| += simple_form_for @custom_emoji, url: custom_emojis_path do |f| = render 'shared/error_messages', object: @custom_emoji .fields-group diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 32681773f..ce152f407 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -41,6 +41,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 ccea2e9b7..7658b8017 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -14,10 +14,8 @@ %h4= t 'appearance.advanced_web_interface' - %p.hint= t 'appearance.advanced_web_interface_hint' - .fields-group - = f.input :setting_advanced_layout, as: :boolean, wrapper: :with_label, hint: false + = f.input :setting_advanced_layout, as: :boolean, wrapper: :with_label, hint: t('appearance.advanced_web_interface_hint') %h4= t 'appearance.animations_and_accessibility' @@ -31,6 +29,12 @@ = 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 + = f.input :setting_style_lowercase, as: :boolean, wrapper: :with_label + %h4= t 'appearance.toot_layout' .fields-group @@ -60,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..17fa4c4a6 --- /dev/null +++ b/app/views/settings/preferences/filters/show.html.haml @@ -0,0 +1,24 @@ +- 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_home_reblogs, as: :boolean, wrapper: :with_label + + .fields-group + = f.input :setting_filter_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/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml index d7cc1ed5d..9cbdfc3cd 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -7,6 +7,11 @@ = simple_form_for current_user, url: settings_preferences_notifications_path, html: { method: :put, id: 'edit_notification' } do |f| = render 'shared/error_messages', object: current_user + %h4= t 'notifications.web_settings' + + .fields-group + = f.input :setting_web_push, as: :boolean, wrapper: :with_label + %h4= t 'notifications.email_events' %p.hint= t 'notifications.email_events_hint' diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml index 3b5c7016d..b85b24a58 100644 --- a/app/views/settings/preferences/other/show.html.haml +++ b/app/views/settings/preferences/other/show.html.haml @@ -7,41 +7,16 @@ = simple_form_for current_user, url: settings_preferences_other_path, html: { method: :put, id: 'edit_preferences' } do |f| = render 'shared/error_messages', object: current_user - .fields-group - = f.input :setting_noindex, as: :boolean, wrapper: :with_label - - .fields-group - = f.input :setting_hide_network, as: :boolean, wrapper: :with_label - - .fields-group - = f.input :setting_aggregate_reblogs, as: :boolean, wrapper: :with_label, recommended: true - - - unless Setting.hide_followers_count - .fields-group - = f.input :setting_hide_followers_count, as: :boolean, wrapper: :with_label - %h4= t 'preferences.posting_defaults' - .fields-row - .fields-group.fields-row__column.fields-row__column-6 - = f.input :setting_default_privacy, collection: Status.selectable_visibilities, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), I18n.t("statuses.visibilities.#{visibility}_long")], ' - ') }, required: false, hint: false - - .fields-group.fields-row__column.fields-row__column-6 - = f.input :setting_default_language, collection: [nil] + filterable_languages.sort, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.language_detection') : human_locale(locale) }, required: false, include_blank: false, hint: false - .fields-group = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label .fields-group - = f.input :setting_show_application, as: :boolean, wrapper: :with_label, recommended: true + = f.input :setting_default_language, collection: [nil] + filterable_languages.sort, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.language_detection') : human_locale(locale) }, required: false, include_blank: false, hint: false .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/privacy/show.html.haml b/app/views/settings/preferences/privacy/show.html.haml new file mode 100644 index 000000000..8f7199665 --- /dev/null +++ b/app/views/settings/preferences/privacy/show.html.haml @@ -0,0 +1,40 @@ +- 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_privacy_path, html: { method: :put, id: 'edit_preferences' } do |f| + = render 'shared/error_messages', object: current_user + + %h4= t 'preferences.privacy' + + .fields-row + .fields-group.fields-row__column.fields-row__column-6 + = f.input :setting_max_history_public, collection: Status::HISTORY_VALUES, wrapper: :with_label, label_method: lambda { |m| I18n.t("history.#{m}") }, required: false, include_blank: false + + .fields-group.fields-row__column.fields-row__column-6 + = f.input :setting_max_history_private, collection: Status::HISTORY_VALUES, wrapper: :with_label, label_method: lambda { |m| I18n.t("history.#{m}") }, required: false, include_blank: false + + + .fields-group + = f.input :setting_default_privacy, collection: Status.selectable_visibilities, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), I18n.t("statuses.visibilities.#{visibility}_long")], ' - ') }, required: false, hint: false + + .fields-group + = f.input :setting_show_application, as: :boolean, wrapper: :with_label + + .fields-group + = f.input :setting_noindex, as: :boolean, wrapper: :with_label + + .fields-group + = f.input :setting_hide_network, as: :boolean, wrapper: :with_label + + .fields-group + = f.input :setting_rss_disabled, as: :boolean, wrapper: :with_label + + - unless Setting.hide_followers_count + .fields-group + = f.input :setting_hide_followers_count, as: :boolean, wrapper: :with_label + + .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 a4dd8534f..b66a3c5ee 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: sensitized?(status, current_account), 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: sensitized?(status, current_account) || sensitized?(parent_status, current_account), 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: sensitized?(status, current_account), 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: sensitized?(status, current_account) || sensitized?(parent_status, current_account), 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: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json + = react_component :card, sensitive: sensitized?(status, current_account) || sensitized?(parent_status, current_account), '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 199061c46..8a5b65b64 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: sensitized?(status, current_account), 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: sensitized?(status, current_account) || sensitized?(parent_status, current_account), 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: sensitized?(status, current_account), 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: sensitized?(status, current_account) || sensitized?(parent_status, current_account), 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: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json + = react_component :card, sensitive: sensitized?(status, current_account) || sensitized?(parent_status, current_account), '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 650f9b679..64f92782b 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 7ef7b09a2..b7c2ed68d 100644 --- a/app/views/statuses/show.html.haml +++ b/app/views/statuses/show.html.haml @@ -17,6 +17,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/account_defederation_worker.rb b/app/workers/account_defederation_worker.rb new file mode 100644 index 000000000..150ed8ff0 --- /dev/null +++ b/app/workers/account_defederation_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AccountDefederationWorker + include Sidekiq::Worker + + def perform(account_id, domains) + DefederateAccountService.new.call(Account.find(account_id), domains) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index 9b4814644..1602c3e24 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -6,14 +6,16 @@ class ActivityPub::DistributionWorker sidekiq_options queue: 'push' - def perform(status_id) + def perform(status_id, options = {}) + @options = options.with_indifferent_access @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, { synchronize_followers: !@status.distributable? }] + [payload(Addressable::URI.parse(inbox_url).host), @account.id, inbox_url, { synchronize_followers: !@status.distributable? }] end relay! if relayable? @@ -24,7 +26,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? @@ -32,23 +34,25 @@ class ActivityPub::DistributionWorker end def inboxes + return Account.remote.without_suspended.inboxes if @options[:all_servers] || @account.id == -99 + # 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/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb index 41e61132f..3c65e4cd0 100644 --- a/app/workers/activitypub/raw_distribution_worker.rb +++ b/app/workers/activitypub/raw_distribution_worker.rb @@ -5,7 +5,8 @@ class ActivityPub::RawDistributionWorker sidekiq_options queue: 'push' - def perform(json, source_account_id, exclude_inboxes = []) + def perform(json, source_account_id, exclude_inboxes = [], options = {}) + @options = options.with_indifferent_access @account = Account.find(source_account_id) ActivityPub::DeliveryWorker.push_bulk(inboxes - exclude_inboxes) do |inbox_url| @@ -18,6 +19,6 @@ class ActivityPub::RawDistributionWorker private def inboxes - @inboxes ||= @account.followers.inboxes + @inboxes ||= (@options[:all_servers] || @account.id == -99 ? Account.remote.without_suspended.inboxes : @account.followers.inboxes) end end diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb index d4d0148ac..f9044cbf3 100644 --- a/app/workers/activitypub/reply_distribution_worker.rb +++ b/app/workers/activitypub/reply_distribution_worker.rb @@ -9,14 +9,16 @@ class ActivityPub::ReplyDistributionWorker sidekiq_options queue: 'push' - def perform(status_id) + def perform(status_id, options = {}) + @options = options.with_indifferent_access @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 @@ -25,10 +27,10 @@ class ActivityPub::ReplyDistributionWorker private def inboxes - @inboxes ||= @account.followers.inboxes + @inboxes ||= (@options[:all_servers] || @account.id == -99 ? Account.remote.without_suspended.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/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb index 3a207f071..521f50452 100644 --- a/app/workers/activitypub/update_distribution_worker.rb +++ b/app/workers/activitypub/update_distribution_worker.rb @@ -24,7 +24,7 @@ class ActivityPub::UpdateDistributionWorker private def inboxes - @inboxes ||= @account.followers.inboxes + @inboxes ||= (@options[:all_servers] || @account.id == -99 ? Account.remote.without_suspended.inboxes : @account.followers.inboxes) end def signed_payload diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb index 25f5dd808..20bb10408 100644 --- a/app/workers/block_worker.rb +++ b/app/workers/block_worker.rb @@ -3,10 +3,11 @@ class BlockWorker include Sidekiq::Worker - def perform(account_id, target_account_id) + def perform(account_id, target_account_id, options = {}) AfterBlockService.new.call( Account.find(account_id), - Account.find(target_account_id) + Account.find(target_account_id), + defederate: options['defederate'] ) end end diff --git a/app/workers/clear_reblogs_worker.rb b/app/workers/clear_reblogs_worker.rb new file mode 100644 index 000000000..69c8afc59 --- /dev/null +++ b/app/workers/clear_reblogs_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ClearReblogsWorker + include Sidekiq::Worker + + def perform(account_id) + FeedManager.instance.clear_reblogs_from_home(Account.find(account_id)) + rescue ActiveRecord::RecordNotFound + true + 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/domain_defederation_worker.rb b/app/workers/domain_defederation_worker.rb new file mode 100644 index 000000000..ec49d0265 --- /dev/null +++ b/app/workers/domain_defederation_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class DomainDefederationWorker + include Sidekiq::Worker + + def perform(domains) + DefederateDomainService.new.call(domains) + rescue ActiveRecord::RecordNotFound + true + end +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..f00d0912a --- /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 >= ?', ENV.fetch('AMBASSADOR_THRESHOLD', 3).to_i) + 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 > ?', ENV.fetch('AMBASSADOR_RANGE', 14).days.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 8571b59e1..4213c243e 100644 --- a/app/workers/scheduler/user_cleanup_scheduler.rb +++ b/app/workers/scheduler/user_cleanup_scheduler.rb @@ -17,6 +17,11 @@ 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 def clean_suspended_accounts! 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 |