diff options
Diffstat (limited to 'app')
299 files changed, 2266 insertions, 3762 deletions
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 5850bd56d..a6e33a5d9 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -4,13 +4,17 @@ class AboutController < ApplicationController before_action :set_pack layout 'public' - before_action :set_instance_presenter, only: [:show, :more, :terms] + before_action :set_body_classes, only: :show + before_action :set_instance_presenter + before_action :set_expires_in - def show - @hide_navbar = true - end + skip_before_action :check_user_permissions, only: [:more, :terms] - def more; end + def show; end + + def more + flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor] + end def terms; end @@ -32,4 +36,12 @@ class AboutController < ApplicationController def set_instance_presenter @instance_presenter = InstancePresenter.new end + + def set_body_classes + @hide_navbar = true + end + + def set_expires_in + expires_in 0, public: true + end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 051b6ecbd..ff684e31e 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -4,16 +4,17 @@ class AccountsController < ApplicationController PAGE_SIZE = 20 include AccountControllerConcern + include SignatureAuthentication before_action :set_cache_headers + before_action :set_body_classes def show respond_to do |format| format.html do use_pack 'public' - mark_cacheable! unless user_signed_in? + expires_in 0, public: true unless user_signed_in? - @body_classes = 'with-modals' @pinned_statuses = [] @endorsed_accounts = @account.endorsed_accounts.to_a.sample(4) @@ -32,30 +33,26 @@ class AccountsController < ApplicationController end end - format.atom do - mark_cacheable! - - @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]) - render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? || entry.status.local_only? })) - end - format.rss do - mark_cacheable! + expires_in 0, public: true @statuses = cache_collection(default_statuses.without_reblogs.without_replies.limit(PAGE_SIZE), Status) render xml: RSS::AccountSerializer.render(@account, @statuses) end format.json do - render_cached_json(['activitypub', 'actor', @account], content_type: 'application/activity+json') do - ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter) - end + expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?) + render json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to end end end private + def set_body_classes + @body_classes = 'with-modals' + end + def show_pinned_statuses? [replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none? end @@ -137,4 +134,12 @@ class AccountsController < ApplicationController filtered_statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]).to_a end end + + def restrict_fields_to + if signed_request_account.present? || public_fetch_mode? + # Return all fields + else + %i(id type preferred_username inbox public_key endpoints) + end + end end diff --git a/app/controllers/activitypub/base_controller.rb b/app/controllers/activitypub/base_controller.rb new file mode 100644 index 000000000..a3b5c4dfa --- /dev/null +++ b/app/controllers/activitypub/base_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ActivityPub::BaseController < Api::BaseController + private + + def set_cache_headers + response.headers['Vary'] = 'Signature' if authorized_fetch_mode? + end +end diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index 012c3c538..fa925b204 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -1,30 +1,21 @@ # frozen_string_literal: true -class ActivityPub::CollectionsController < Api::BaseController +class ActivityPub::CollectionsController < ActivityPub::BaseController include SignatureVerification + include AccountOwnedConcern - before_action :set_account + before_action :require_signature!, if: :authorized_fetch_mode? before_action :set_size before_action :set_statuses before_action :set_cache_headers def show - render_cached_json(['activitypub', 'collection', @account, params[:id]], content_type: 'application/activity+json') do - ActiveModelSerializers::SerializableResource.new( - collection_presenter, - serializer: ActivityPub::CollectionSerializer, - adapter: ActivityPub::Adapter, - skip_activities: true - ) - end + expires_in 3.minutes, public: public_fetch_mode? + render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, skip_activities: true end private - def set_account - @account = Account.find_local!(params[:account_username]) - end - def set_statuses @statuses = scope_for_collection @statuses = cache_collection(@statuses, Status) diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index a0b7532c2..7cfd9a25e 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -3,38 +3,42 @@ class ActivityPub::InboxesController < Api::BaseController include SignatureVerification include JsonLdHelper + include AccountOwnedConcern - before_action :set_account + before_action :skip_unknown_actor_delete + before_action :require_signature! def create - if unknown_deleted_account? - head 202 - elsif signed_request_account - upgrade_account - process_payload - head 202 - else - render plain: signature_verification_failure_reason, status: 401 - end + upgrade_account + process_payload + head 202 end private + def skip_unknown_actor_delete + head 202 if unknown_deleted_account? + end + def unknown_deleted_account? json = Oj.load(body, mode: :strict) - json['type'] == 'Delete' && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists? + json.is_a?(Hash) && json['type'] == 'Delete' && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists? rescue Oj::ParseError false end - def set_account - @account = Account.find_local!(params[:account_username]) if params[:account_username] + def account_required? + params[:account_username].present? end def body return @body if defined?(@body) - @body = request.body.read.force_encoding('UTF-8') + + @body = request.body.read + @body.force_encoding('UTF-8') if @body.present? + request.body.rewind if request.body.respond_to?(:rewind) + @body end @@ -44,7 +48,6 @@ class ActivityPub::InboxesController < Api::BaseController ResolveAccountWorker.perform_async(signed_request_account.acct) end - Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? DeliveryFailureTracker.track_inverse_success!(signed_request_account) end diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 5147afbf7..891756b7e 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -1,26 +1,22 @@ # frozen_string_literal: true -class ActivityPub::OutboxesController < Api::BaseController +class ActivityPub::OutboxesController < ActivityPub::BaseController LIMIT = 20 include SignatureVerification + include AccountOwnedConcern - before_action :set_account + before_action :require_signature!, if: :authorized_fetch_mode? before_action :set_statuses before_action :set_cache_headers def show - expires_in 1.minute, public: true unless page_requested? - + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end private - def set_account - @account = Account.find_local!(params[:account_username]) - end - def outbox_presenter if page_requested? ActivityPub::CollectionPresenter.new( diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb new file mode 100644 index 000000000..ab755ed4e --- /dev/null +++ b/app/controllers/activitypub/replies_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class ActivityPub::RepliesController < ActivityPub::BaseController + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + DESCENDANTS_LIMIT = 60 + + before_action :require_signature!, if: :authorized_fetch_mode? + before_action :set_status + before_action :set_cache_headers + before_action :set_replies + + 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 + end + + private + + def set_status + @status = @account.statuses.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + raise ActiveRecord::RecordNotFound + end + + def set_replies + @replies = page_params[:other_accounts] ? Status.where.not(account_id: @account.id) : @account.statuses + @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted]) + @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) + end + + def replies_collection_presenter + page = ActivityPub::CollectionPresenter.new( + id: account_status_replies_url(@account, @status, page_params), + type: :unordered, + part_of: account_status_replies_url(@account, @status), + next: next_page, + items: @replies.map { |status| status.local ? status : status.id } + ) + + return page if page_requested? + + ActivityPub::CollectionPresenter.new( + id: account_status_replies_url(@account, @status), + type: :unordered, + first: page + ) + end + + def page_requested? + params[:page] == 'true' + end + + def next_page + account_status_replies_url( + @account, + @status, + page: true, + min_id: @replies&.last&.id, + other_accounts: !(@replies&.last&.account_id == @account.id && @replies.size == DESCENDANTS_LIMIT) + ) + end + + def page_params + params_slice(:other_accounts, :min_id).merge(page: true) + end +end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 0c7760d77..2fa1dfe5f 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -2,8 +2,8 @@ module Admin class AccountsController < BaseController - before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject] - before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload] + before_action :set_account, only: [:show, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject] + before_action :require_remote_account!, only: [:redownload] before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] def index @@ -19,18 +19,6 @@ module Admin @warnings = @account.targeted_account_warnings.latest.custom end - def subscribe - authorize @account, :subscribe? - Pubsubhubbub::SubscribeWorker.perform_async(@account.id) - redirect_to admin_account_path(@account.id) - end - - def unsubscribe - authorize @account, :unsubscribe? - Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id) - redirect_to admin_account_path(@account.id) - end - def memorialize authorize @account, :memorialize? @account.memorialize! diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index aedfeb70e..faa2df1b5 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -31,6 +31,7 @@ module Admin @profile_directory = Setting.profile_directory @timeline_preview = Setting.timeline_preview @keybase_integration = Setting.enable_keybase + @spam_check_enabled = Setting.spam_check_enabled end private diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 377cac8ad..7129656da 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -17,7 +17,7 @@ module Admin if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block) @domain_block.save - flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety + flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety @domain_block.errors[:domain].clear render :new else diff --git a/app/controllers/api/proofs_controller.rb b/app/controllers/api/proofs_controller.rb index a84ad2014..a98599eee 100644 --- a/app/controllers/api/proofs_controller.rb +++ b/app/controllers/api/proofs_controller.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true class Api::ProofsController < Api::BaseController - before_action :set_account + include AccountOwnedConcern + before_action :set_provider - before_action :check_account_approval - before_action :check_account_suspension def index render json: @account, serializer: @provider.serializer_class @@ -16,15 +15,7 @@ class Api::ProofsController < Api::BaseController @provider = ProofProvider.find(params[:provider]) || raise(ActiveRecord::RecordNotFound) end - def set_account - @account = Account.find_local!(params[:username]) - end - - def check_account_approval - not_found if @account.user_pending? - end - - def check_account_suspension - gone if @account.suspended? + def username_param + params[:username] end end diff --git a/app/controllers/api/push_controller.rb b/app/controllers/api/push_controller.rb deleted file mode 100644 index e04d19125..000000000 --- a/app/controllers/api/push_controller.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -class Api::PushController < Api::BaseController - include SignatureVerification - - def update - response, status = process_push_request - render plain: response, status: status - end - - private - - def process_push_request - case hub_mode - when 'subscribe' - Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds, verified_domain) - when 'unsubscribe' - Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback) - else - ["Unknown mode: #{hub_mode}", 422] - end - end - - def hub_mode - params['hub.mode'] - end - - def hub_topic - params['hub.topic'] - end - - def hub_callback - params['hub.callback'] - end - - def hub_lease_seconds - params['hub.lease_seconds'] - end - - def hub_secret - params['hub.secret'] - end - - def account_from_topic - if hub_topic.present? && local_domain? && account_feed_path? - Account.find_local(hub_topic_params[:username]) - end - end - - def hub_topic_params - @_hub_topic_params ||= Rails.application.routes.recognize_path(hub_topic_uri.path) - end - - def hub_topic_uri - @_hub_topic_uri ||= Addressable::URI.parse(hub_topic).normalize - end - - def local_domain? - TagManager.instance.web_domain?(hub_topic_domain) - end - - def verified_domain - return signed_request_account.domain if signed_request_account - end - - def hub_topic_domain - hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '') - end - - def account_feed_path? - hub_topic_params[:controller] == 'accounts' && hub_topic_params[:action] == 'show' && hub_topic_params[:format] == 'atom' - end -end diff --git a/app/controllers/api/salmon_controller.rb b/app/controllers/api/salmon_controller.rb deleted file mode 100644 index ac5f3268d..000000000 --- a/app/controllers/api/salmon_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -class Api::SalmonController < Api::BaseController - include SignatureVerification - - before_action :set_account - respond_to :txt - - def update - if verify_payload? - process_salmon - head 202 - elsif payload.present? - render plain: signature_verification_failure_reason, status: 401 - else - head 400 - end - end - - private - - def set_account - @account = Account.find(params[:id]) - end - - def payload - @_payload ||= request.body.read - end - - def verify_payload? - payload.present? && VerifySalmonService.new.call(payload) - end - - def process_salmon - SalmonWorker.perform_async(@account.id, payload.force_encoding('UTF-8')) - end -end diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb deleted file mode 100644 index 89007f3d6..000000000 --- a/app/controllers/api/subscriptions_controller.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -class Api::SubscriptionsController < Api::BaseController - before_action :set_account - respond_to :txt - - def show - if subscription.valid?(params['hub.topic']) - @account.update(subscription_expires_at: future_expires) - render plain: encoded_challenge, status: 200 - else - head 404 - end - end - - def update - if subscription.verify(body, request.headers['HTTP_X_HUB_SIGNATURE']) - ProcessingWorker.perform_async(@account.id, body.force_encoding('UTF-8')) - end - - head 200 - end - - private - - def subscription - @_subscription ||= @account.subscription( - api_subscription_url(@account.id) - ) - end - - def body - @_body ||= request.body.read - end - - def encoded_challenge - HTMLEntities.new.encode(params['hub.challenge']) - end - - def future_expires - Time.now.utc + lease_seconds_or_default - end - - def lease_seconds_or_default - (params['hub.lease_seconds'] || 1.day).to_i.seconds - end - - def set_account - @account = Account.find(params[:id]) - end -end diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb deleted file mode 100644 index 5420c0533..000000000 --- a/app/controllers/api/v1/follows_controller.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::FollowsController < Api::BaseController - before_action -> { doorkeeper_authorize! :follow, :'write:follows' } - before_action :require_user! - - respond_to :json - - def create - raise ActiveRecord::RecordNotFound if follow_params[:uri].blank? - - @account = FollowService.new.call(current_user.account, target_uri).try(:target_account) - - if @account.nil? - username, domain = target_uri.split('@') - @account = Account.find_remote!(username, domain) - end - - render json: @account, serializer: REST::AccountSerializer - end - - private - - def target_uri - follow_params[:uri].strip.gsub(/\A@/, '') - end - - def follow_params - params.permit(:uri) - end -end diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb index 6131cbbb6..4fb869bb9 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -3,7 +3,7 @@ class Api::V1::SearchController < Api::BaseController include Authorization - RESULTS_LIMIT = 20 + RESULTS_LIMIT = (ENV['MAX_SEARCH_RESULTS'] || 20).to_i before_action -> { doorkeeper_authorize! :read, :'read:search' } before_action :require_user! diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index cef412554..95e0d624f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -37,6 +37,14 @@ class ApplicationController < ActionController::Base Rails.env.production? end + def authorized_fetch_mode? + ENV['AUTHORIZED_FETCH'] == 'true' + end + + def public_fetch_mode? + !authorized_fetch_mode? + end + def store_current_location store_location_for(:user, request.url) unless request.format == :json end @@ -153,7 +161,7 @@ class ApplicationController < ActionController::Base end def single_user_mode? - @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists? + @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? end def use_seamless_external_login? @@ -228,10 +236,6 @@ class ApplicationController < ActionController::Base end def set_cache_headers - response.headers['Vary'] = 'Accept' - end - - def mark_cacheable! - expires_in 0, public: true + response.headers['Vary'] = public_fetch_mode? ? 'Accept' : 'Accept, Signature' end end diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index 1c422096c..11eac0eb6 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -3,24 +3,19 @@ module AccountControllerConcern extend ActiveSupport::Concern + include AccountOwnedConcern + FOLLOW_PER_PAGE = 12 included do layout 'public' - before_action :set_account - before_action :check_account_approval - before_action :check_account_suspension before_action :set_instance_presenter - before_action :set_link_headers + before_action :set_link_headers, if: -> { request.format.nil? || request.format == :html } end private - def set_account - @account = Account.find_local!(username_param) - end - def set_instance_presenter @instance_presenter = InstancePresenter.new end @@ -29,27 +24,15 @@ module AccountControllerConcern response.headers['Link'] = LinkHeader.new( [ webfinger_account_link, - atom_account_url_link, actor_url_link, ] ) end - def username_param - params[:account_username] - end - def webfinger_account_link [ webfinger_account_url, - [%w(rel lrdd), %w(type application/xrd+xml)], - ] - end - - def atom_account_url_link - [ - account_url(@account, format: 'atom'), - [%w(rel alternate), %w(type application/atom+xml)], + [%w(rel lrdd), %w(type application/jrd+json)], ] end @@ -63,15 +46,4 @@ module AccountControllerConcern def webfinger_account_url webfinger_url(resource: @account.to_webfinger_s) end - - def check_account_approval - not_found if @account.user_pending? - end - - def check_account_suspension - if @account.suspended? - expires_in(3.minutes, public: true) - gone - end - end end diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb new file mode 100644 index 000000000..99c240fe9 --- /dev/null +++ b/app/controllers/concerns/account_owned_concern.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module AccountOwnedConcern + extend ActiveSupport::Concern + + included do + before_action :set_account, if: :account_required? + before_action :check_account_approval, if: :account_required? + before_action :check_account_suspension, if: :account_required? + end + + private + + def account_required? + true + end + + def set_account + @account = Account.find_local!(username_param) + end + + def username_param + params[:account_username] + end + + def check_account_approval + not_found if @account.local? && @account.user_pending? + end + + def check_account_suspension + expires_in(3.minutes, public: true) && gone if @account.suspended? + end +end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 90a57197c..7b251cf80 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -5,12 +5,22 @@ module SignatureVerification extend ActiveSupport::Concern + include DomainControlHelper + + def require_signature! + render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account + end + def signed_request? request.headers['Signature'].present? end def signature_verification_failure_reason - return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason) + @signature_verification_failure_reason + end + + def signature_verification_failure_code + @signature_verification_failure_code || 401 end def signed_request_account @@ -123,6 +133,13 @@ module SignatureVerification end def account_from_key_id(key_id) + domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id + + if domain_not_allowed?(domain) + @signature_verification_failure_code = 403 + return + end + if key_id.start_with?('acct:') stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, '')) } elsif !ActivityPub::TagManager.instance.local_uri?(key_id) diff --git a/app/controllers/concerns/status_controller_concern.rb b/app/controllers/concerns/status_controller_concern.rb new file mode 100644 index 000000000..62a7cf508 --- /dev/null +++ b/app/controllers/concerns/status_controller_concern.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module StatusControllerConcern + extend ActiveSupport::Concern + + ANCESTORS_LIMIT = 40 + DESCENDANTS_LIMIT = 60 + DESCENDANTS_DEPTH_LIMIT = 20 + + def create_descendant_thread(starting_depth, statuses) + depth = starting_depth + statuses.size + + if depth < DESCENDANTS_DEPTH_LIMIT + { + statuses: statuses, + starting_depth: starting_depth, + } + else + next_status = statuses.pop + + { + statuses: statuses, + starting_depth: starting_depth, + next_status: next_status, + } + end + end + + def set_ancestors + @ancestors = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : [] + @next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift + end + + def set_descendants + @max_descendant_thread_id = params[:max_descendant_thread_id]&.to_i + @since_descendant_thread_id = params[:since_descendant_thread_id]&.to_i + + descendants = cache_collection( + @status.descendants( + DESCENDANTS_LIMIT, + current_account, + @max_descendant_thread_id, + @since_descendant_thread_id, + DESCENDANTS_DEPTH_LIMIT + ), + Status + ) + + @descendant_threads = [] + + if descendants.present? + statuses = [descendants.first] + starting_depth = 0 + + descendants.drop(1).each_with_index do |descendant, index| + if descendants[index].id == descendant.in_reply_to_id + statuses << descendant + else + @descendant_threads << create_descendant_thread(starting_depth, statuses) + + # The thread is broken, assume it's a reply to the root status + starting_depth = 0 + + # ... unless we can find its ancestor in one of the already-processed threads + @descendant_threads.reverse_each do |descendant_thread| + statuses = descendant_thread[:statuses] + + index = statuses.find_index do |thread_status| + thread_status.id == descendant.in_reply_to_id + end + + if index.present? + starting_depth = descendant_thread[:starting_depth] + index + 1 + break + end + end + + statuses = [descendant] + end + end + + @descendant_threads << create_descendant_thread(starting_depth, statuses) + end + + @max_descendant_thread_id = @descendant_threads.pop[:statuses].first.id if descendants.size >= DESCENDANTS_LIMIT + end +end diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb index 6e80feaf8..e3f67bd14 100644 --- a/app/controllers/custom_css_controller.rb +++ b/app/controllers/custom_css_controller.rb @@ -6,6 +6,7 @@ class CustomCssController < ApplicationController before_action :set_cache_headers def show + expires_in 3.minutes, public: true render plain: Setting.custom_css || '', content_type: 'text/css' end end diff --git a/app/controllers/emojis_controller.rb b/app/controllers/emojis_controller.rb index 3feb08132..fe4c19cad 100644 --- a/app/controllers/emojis_controller.rb +++ b/app/controllers/emojis_controller.rb @@ -7,9 +7,8 @@ class EmojisController < ApplicationController def show respond_to do |format| format.json do - render_cached_json(['activitypub', 'emoji', @emoji], content_type: 'application/activity+json') do - ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter) - end + expires_in 3.minutes, public: true + render json: @emoji, content_type: 'application/activity+json', serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter end end end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index fab9c8462..e2ba9bf00 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -2,14 +2,16 @@ class FollowerAccountsController < ApplicationController include AccountControllerConcern + include SignatureVerification + before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_cache_headers def index respond_to do |format| format.html do use_pack 'public' - mark_cacheable! unless user_signed_in? + expires_in 0, public: true unless user_signed_in? next if @account.user_hides_network? @@ -18,9 +20,9 @@ class FollowerAccountsController < ApplicationController end format.json do - raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? + raise Mastodon::NotPermittedError if page_requested? && @account.user_hides_network? - expires_in 3.minutes, public: true if params[:page].blank? + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, @@ -36,6 +38,10 @@ class FollowerAccountsController < ApplicationController @follows ||= Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) end + def page_requested? + params[:page].present? + end + def page_url(page) account_followers_url(@account, page: page) unless page.nil? end @@ -43,7 +49,7 @@ class FollowerAccountsController < ApplicationController def collection_presenter options = { type: :ordered } options[:size] = @account.followers_count unless Setting.hide_followers_count || @account.user&.setting_hide_followers_count - if params[:page].present? + if page_requested? ActivityPub::CollectionPresenter.new( id: account_followers_url(@account, page: params.fetch(:page, 1)), items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 272116040..49f1f3218 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -2,14 +2,16 @@ class FollowingAccountsController < ApplicationController include AccountControllerConcern + include SignatureVerification + before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_cache_headers def index respond_to do |format| format.html do use_pack 'public' - mark_cacheable! unless user_signed_in? + expires_in 0, public: true unless user_signed_in? next if @account.user_hides_network? @@ -18,9 +20,9 @@ class FollowingAccountsController < ApplicationController end format.json do - raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? + raise Mastodon::NotPermittedError if page_requested? && @account.user_hides_network? - expires_in 3.minutes, public: true if params[:page].blank? + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, @@ -36,12 +38,16 @@ class FollowingAccountsController < ApplicationController @follows ||= Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) end + def page_requested? + params[:page].present? + end + def page_url(page) account_following_index_url(@account, page: page) unless page.nil? end def collection_presenter - if params[:page].present? + if page_requested? ActivityPub::CollectionPresenter.new( id: account_following_index_url(@account, page: params.fetch(:page, 1)), type: :ordered, diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 17cf9e07b..3f9554ca0 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -23,7 +23,7 @@ class HomeController < ApplicationController when 'statuses' status = Status.find_by(id: matches[2]) - if status && (status.public_visibility? || status.unlisted_visibility?) + if status&.distributable? redirect_to(ActivityPub::TagManager.instance.url_for(status)) return end @@ -64,7 +64,7 @@ class HomeController < ApplicationController if request.path.start_with?('/web') new_user_session_path elsif single_user_mode? - short_account_path(Account.local.without_suspended.first) + short_account_path(Account.local.without_suspended.where('id > 0').first) else about_path end diff --git a/app/controllers/instance_actors_controller.rb b/app/controllers/instance_actors_controller.rb new file mode 100644 index 000000000..41f33602e --- /dev/null +++ b/app/controllers/instance_actors_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class InstanceActorsController < ApplicationController + include AccountControllerConcern + + def show + expires_in 10.minutes, public: true + render json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to + end + + private + + def set_account + @account = Account.find(-99) + end + + def restrict_fields_to + %i(id type preferred_username inbox public_key endpoints url manually_approves_followers) + end +end diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb index 9f41cf48a..ca89fc7fe 100644 --- a/app/controllers/intents_controller.rb +++ b/app/controllers/intents_controller.rb @@ -2,6 +2,7 @@ class IntentsController < ApplicationController before_action :check_uri + rescue_from Addressable::URI::InvalidURIError, with: :handle_invalid_uri def show diff --git a/app/controllers/manifests_controller.rb b/app/controllers/manifests_controller.rb index 332d845d8..1e5db4393 100644 --- a/app/controllers/manifests_controller.rb +++ b/app/controllers/manifests_controller.rb @@ -4,6 +4,7 @@ class ManifestsController < ApplicationController skip_before_action :store_current_location def show + expires_in 3.minutes, public: true render json: InstancePresenter.new, serializer: ManifestSerializer end end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index d44b52d26..b3b7519a1 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -31,7 +31,6 @@ class MediaController < ApplicationController def verify_permitted_status! authorize @media_attachment.status, :show? rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 instead of a 403 error code raise ActiveRecord::RecordNotFound end diff --git a/app/controllers/public_timelines_controller.rb b/app/controllers/public_timelines_controller.rb index c5fe789f4..a5c981c7f 100644 --- a/app/controllers/public_timelines_controller.rb +++ b/app/controllers/public_timelines_controller.rb @@ -9,20 +9,16 @@ class PublicTimelinesController < ApplicationController before_action :set_instance_presenter def show - respond_to do |format| - format.html do - @initial_state_json = ActiveModelSerializers::SerializableResource.new( - InitialStatePresenter.new(settings: { known_fediverse: Setting.show_known_fediverse_at_about_page }, token: current_session&.token), - serializer: InitialStateSerializer - ).to_json - end - end + @initial_state_json = ActiveModelSerializers::SerializableResource.new( + InitialStatePresenter.new(settings: { known_fediverse: Setting.show_known_fediverse_at_about_page }, token: current_session&.token), + serializer: InitialStateSerializer + ).to_json end private def check_enabled - raise ActiveRecord::RecordNotFound unless Setting.timeline_preview + not_found unless Setting.timeline_preview end def set_body_classes diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb index 17bc1940a..46dd444a4 100644 --- a/app/controllers/remote_follow_controller.rb +++ b/app/controllers/remote_follow_controller.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true class RemoteFollowController < ApplicationController + include AccountOwnedConcern + layout 'modal' - before_action :set_account before_action :set_pack - before_action :gone, if: :suspended_account? before_action :set_body_classes def new @@ -37,14 +37,6 @@ class RemoteFollowController < ApplicationController use_pack 'modal' end - def set_account - @account = Account.find_local!(params[:account_username]) - end - - def suspended_account? - @account.suspended? - end - def set_body_classes @body_classes = 'modal-layout' @hide_header = true diff --git a/app/controllers/remote_unfollows_controller.rb b/app/controllers/remote_unfollows_controller.rb deleted file mode 100644 index af5943363..000000000 --- a/app/controllers/remote_unfollows_controller.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -class RemoteUnfollowsController < ApplicationController - layout 'modal' - - before_action :authenticate_user! - before_action :set_body_classes - - def create - @account = unfollow_attempt.try(:target_account) - - if @account.nil? - render :error - else - render :success - end - rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError - render :error - end - - private - - def unfollow_attempt - username, domain = acct_without_prefix.split('@') - UnfollowService.new.call(current_account, Account.find_remote!(username, domain)) - end - - def acct_without_prefix - acct_params.gsub(/\Aacct:/, '') - end - - def acct_params - params.fetch(:acct, '') - end - - def set_body_classes - @body_classes = 'modal-layout' - end -end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 451742d41..372f253cb 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -53,6 +53,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_advanced_layout, :setting_default_content_type, :setting_use_blurhash, + :setting_use_pending_items, notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), interactions: %i(must_be_follower must_be_following must_be_following_dm) ) diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index 8518c61ee..363b32e17 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -11,7 +11,7 @@ module Settings def create if current_user.validate_and_consume_otp!(confirmation_params[:code]) - flash[:notice] = I18n.t('two_factor_authentication.enabled_success') + flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success') current_user.otp_required_for_login = true @recovery_codes = current_user.generate_otp_backup_codes! diff --git a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb index 94d1567f3..0555d61db 100644 --- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb +++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb @@ -6,7 +6,7 @@ module Settings def create @recovery_codes = current_user.generate_otp_backup_codes! current_user.save! - flash[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated') + flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated') render :index end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 66ba260aa..0190a3c54 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -1,24 +1,22 @@ # frozen_string_literal: true class StatusesController < ApplicationController + include StatusControllerConcern include SignatureAuthentication include Authorization - - ANCESTORS_LIMIT = 40 - DESCENDANTS_LIMIT = 60 - DESCENDANTS_DEPTH_LIMIT = 20 + include AccountOwnedConcern layout 'public' - before_action :set_account + before_action :require_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_status before_action :set_instance_presenter before_action :set_link_headers - before_action :check_account_suspension - before_action :redirect_to_original, only: [:show] - before_action :set_referrer_policy_header, only: [:show] + before_action :redirect_to_original, only: :show + before_action :set_referrer_policy_header, only: :show before_action :set_cache_headers - before_action :set_replies, only: [:replies] + before_action :set_body_classes + before_action :set_autoplay, only: :embed content_security_policy only: :embed do |p| p.frame_ancestors(false) @@ -30,27 +28,20 @@ class StatusesController < ApplicationController use_pack 'public' expires_in 10.seconds, public: true if current_account.nil? - - @body_classes = 'with-modals' - set_ancestors set_descendants - - render 'stream_entries/show' end format.json do - render_cached_json(['activitypub', 'note', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do - ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter) - end + expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? + render json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter end end end def activity - render_cached_json(['activitypub', 'activity', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do - ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter) - end + expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? + render json: @status, content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter end def embed @@ -59,130 +50,24 @@ class StatusesController < ApplicationController expires_in 180, public: true response.headers['X-Frame-Options'] = 'ALLOWALL' - @autoplay = ActiveModel::Type::Boolean.new.cast(params[:autoplay]) - - render 'stream_entries/embed', layout: 'embedded' - end - def replies - render json: replies_collection_presenter, - serializer: ActivityPub::CollectionSerializer, - adapter: ActivityPub::Adapter, - content_type: 'application/activity+json', - skip_activities: true + render layout: 'embedded' end private - def replies_collection_presenter - page = ActivityPub::CollectionPresenter.new( - id: replies_account_status_url(@account, @status, page_params), - type: :unordered, - part_of: replies_account_status_url(@account, @status), - next: next_page, - items: @replies.map { |status| status.local ? status : status.id } - ) - if page_requested? - page - else - ActivityPub::CollectionPresenter.new( - id: replies_account_status_url(@account, @status), - type: :unordered, - first: page - ) - end - end - - def create_descendant_thread(starting_depth, statuses) - depth = starting_depth + statuses.size - if depth < DESCENDANTS_DEPTH_LIMIT - { statuses: statuses, starting_depth: starting_depth } - else - next_status = statuses.pop - { statuses: statuses, starting_depth: starting_depth, next_status: next_status } - end - end - - def set_account - @account = Account.find_local!(params[:account_username]) - end - - def set_ancestors - @ancestors = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : [] - @next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift - end - - def set_descendants - @max_descendant_thread_id = params[:max_descendant_thread_id]&.to_i - @since_descendant_thread_id = params[:since_descendant_thread_id]&.to_i - - descendants = cache_collection( - @status.descendants( - DESCENDANTS_LIMIT, - current_account, - @max_descendant_thread_id, - @since_descendant_thread_id, - DESCENDANTS_DEPTH_LIMIT - ), - Status - ) - - @descendant_threads = [] - - if descendants.present? - statuses = [descendants.first] - starting_depth = 0 - - descendants.drop(1).each_with_index do |descendant, index| - if descendants[index].id == descendant.in_reply_to_id - statuses << descendant - else - @descendant_threads << create_descendant_thread(starting_depth, statuses) - - # The thread is broken, assume it's a reply to the root status - starting_depth = 0 - - # ... unless we can find its ancestor in one of the already-processed threads - @descendant_threads.reverse_each do |descendant_thread| - statuses = descendant_thread[:statuses] - - index = statuses.find_index do |thread_status| - thread_status.id == descendant.in_reply_to_id - end - - if index.present? - starting_depth = descendant_thread[:starting_depth] + index + 1 - break - end - end - - statuses = [descendant] - end - end - - @descendant_threads << create_descendant_thread(starting_depth, statuses) - end - - @max_descendant_thread_id = @descendant_threads.pop[:statuses].first.id if descendants.size >= DESCENDANTS_LIMIT + def set_body_classes + @body_classes = 'with-modals' end def set_link_headers - response.headers['Link'] = LinkHeader.new( - [ - [account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], - [ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]], - ] - ) + response.headers['Link'] = LinkHeader.new([[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]) end def set_status - @status = @account.statuses.find(params[:id]) - @stream_entry = @status.stream_entry - @type = @stream_entry.activity_type.downcase - + @status = @account.statuses.find(params[:id]) authorize @status, :show? rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 raise ActiveRecord::RecordNotFound end @@ -190,39 +75,15 @@ class StatusesController < ApplicationController @instance_presenter = InstancePresenter.new end - def check_account_suspension - gone if @account.suspended? - end - def redirect_to_original - redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog? + redirect_to ActivityPub::TagManager.instance.url_for(@status.reblog) if @status.reblog? end def set_referrer_policy_header - return if @status.public_visibility? || @status.unlisted_visibility? - response.headers['Referrer-Policy'] = 'origin' - end - - def page_requested? - params[:page] == 'true' - end - - def set_replies - @replies = page_params[:other_accounts] ? Status.where.not(account_id: @account.id) : @account.statuses - @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted]) - @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) - end - - def next_page - last_reply = @replies.last - return if last_reply.nil? - same_account = last_reply.account_id == @account.id - return unless same_account || @replies.size == DESCENDANTS_LIMIT - same_account = false unless @replies.size == DESCENDANTS_LIMIT - replies_account_status_url(@account, @status, page: true, min_id: last_reply.id, other_accounts: !same_account) + response.headers['Referrer-Policy'] = 'origin' unless @status.distributable? end - def page_params - { page: true, other_accounts: params[:other_accounts], min_id: params[:min_id] }.compact + def set_autoplay + @autoplay = truthy_param?(:autoplay) end end diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb deleted file mode 100644 index 1ee85592c..000000000 --- a/app/controllers/stream_entries_controller.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -class StreamEntriesController < ApplicationController - include Authorization - include SignatureVerification - - layout 'public' - - before_action :set_account - before_action :set_stream_entry - before_action :set_link_headers - before_action :check_account_suspension - before_action :set_cache_headers - - def show - respond_to do |format| - format.html do - use_pack 'public' - - expires_in 5.minutes, public: true unless @stream_entry.hidden? - - redirect_to short_account_status_url(params[:account_username], @stream_entry.activity) - end - - format.atom do - expires_in 3.minutes, public: true unless @stream_entry.hidden? - - render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true)) - end - end - end - - def embed - redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301 - end - - private - - def set_account - @account = Account.find_local!(params[:account_username]) - end - - def set_link_headers - response.headers['Link'] = LinkHeader.new( - [ - [account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], - [ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]], - ] - ) - end - - def set_stream_entry - @stream_entry = @account.stream_entries.where(activity_type: 'Status').find(params[:id]) - @type = 'status' - - raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? - authorize @stream_entry.activity, :show? if @stream_entry.hidden? || @stream_entry.local_only? - rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 - raise ActiveRecord::RecordNotFound - end - - def check_account_suspension - gone if @account.suspended? - end -end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 5cb048c1a..b4e6dbc92 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -1,19 +1,23 @@ # frozen_string_literal: true class TagsController < ApplicationController + include SignatureVerification + PAGE_SIZE = 20 layout 'public' + before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } + before_action :set_tag before_action :set_body_classes before_action :set_instance_presenter def show - @tag = Tag.find_normalized!(params[:id]) - respond_to do |format| format.html do use_pack 'about' + expires_in 0, public: true + @initial_state_json = ActiveModelSerializers::SerializableResource.new( InitialStatePresenter.new(settings: {}, token: current_session&.token), serializer: InitialStateSerializer @@ -21,6 +25,8 @@ class TagsController < ApplicationController end format.rss do + expires_in 0, public: true + @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none)).limit(PAGE_SIZE) @statuses = cache_collection(@statuses, Status) @@ -28,19 +34,22 @@ class TagsController < ApplicationController end format.json do + expires_in 3.minutes, public: public_fetch_mode? + @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id]) @statuses = cache_collection(@statuses, Status) - render json: collection_presenter, - serializer: ActivityPub::CollectionSerializer, - adapter: ActivityPub::Adapter, - content_type: 'application/activity+json' + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end private + def set_tag + @tag = Tag.find_normalized!(params[:id]) + end + def set_body_classes @body_classes = 'with-modals' end diff --git a/app/controllers/well_known/host_meta_controller.rb b/app/controllers/well_known/host_meta_controller.rb index 5fb70288a..2e9298c4a 100644 --- a/app/controllers/well_known/host_meta_controller.rb +++ b/app/controllers/well_known/host_meta_controller.rb @@ -13,7 +13,7 @@ module WellKnown format.xml { render content_type: 'application/xrd+xml' } end - expires_in(3.days, public: true) + expires_in 3.days, public: true end end end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 28654b61d..53f7f1e27 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -19,7 +19,7 @@ module WellKnown end end - expires_in(3.days, public: true) + expires_in 3.days, public: true rescue ActiveRecord::RecordNotFound head 404 end @@ -27,12 +27,9 @@ module WellKnown private def username_from_resource - resource_user = resource_param - + resource_user = resource_param username, domain = resource_user.split('@') - if Rails.configuration.x.alternate_domains.include?(domain) - resource_user = "#{username}@#{Rails.configuration.x.local_domain}" - end + resource_user = "#{username}@#{Rails.configuration.x.local_domain}" if Rails.configuration.x.alternate_domains.include?(domain) WebfingerResource.new(resource_user).username end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index e5fbb1500..1daa60774 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -89,7 +89,7 @@ module Admin::ActionLogsHelper when 'DomainBlock', 'EmailDomainBlock' link_to record.domain, "https://#{record.domain}" when 'Status' - link_to record.account.acct, TagManager.instance.url_for(record) + link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record) when 'AccountWarning' link_to record.target_account.acct, admin_account_path(record.target_account_id) end diff --git a/app/helpers/domain_control_helper.rb b/app/helpers/domain_control_helper.rb new file mode 100644 index 000000000..efd328f81 --- /dev/null +++ b/app/helpers/domain_control_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module DomainControlHelper + def domain_not_allowed?(uri_or_domain) + return if uri_or_domain.blank? + + domain = begin + if uri_or_domain.include?('://') + Addressable::URI.parse(uri_or_domain).domain + else + uri_or_domain + end + end + + DomainBlock.blocked?(domain) + end +end diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index df60b7dd7..b66e827fe 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -21,7 +21,7 @@ module HomeHelper end end else - link_to(path || TagManager.instance.url_for(account), class: 'account__display-name') do + link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do content_tag(:div, class: 'account__avatar-wrapper') do content_tag(:div, '', class: 'account__avatar', style: "width: #{size}px; height: #{size}px; background-size: #{size}px #{size}px; background-image: url(#{full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url)})") end + diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 5b4011275..83a5b2462 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -16,13 +16,15 @@ module JsonLdHelper # The url attribute can be a string, an array of strings, or an array of objects. # The objects could include a mimeType. Not-included mimeType means it's text/html. def url_to_href(value, preferred_type = nil) - single_value = if value.is_a?(Array) && !value.first.is_a?(String) - value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) } - elsif value.is_a?(Array) - value.first - else - value - end + single_value = begin + if value.is_a?(Array) && !value.first.is_a?(String) + value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) } + elsif value.is_a?(Array) + value.first + else + value + end + end if single_value.nil? || single_value.is_a?(String) single_value @@ -64,7 +66,9 @@ module JsonLdHelper def fetch_resource(uri, id, on_behalf_of = nil) unless id json = fetch_resource_without_id_validation(uri, on_behalf_of) + return unless json + uri = json['id'] end @@ -73,25 +77,20 @@ module JsonLdHelper end def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false) + on_behalf_of ||= Account.representative + build_request(uri, on_behalf_of).perform do |response| - unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error - raise Mastodon::UnexpectedResponseError, response - end - return body_to_json(response.body_with_limit) if response.code == 200 - end - # If request failed, retry without doing it on behalf of a user - return if on_behalf_of.nil? - build_request(uri).perform do |response| - unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error - raise Mastodon::UnexpectedResponseError, response - end - response.code == 200 ? body_to_json(response.body_with_limit) : nil + raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error + + body_to_json(response.body_with_limit) if response.code == 200 end end def body_to_json(body, compare_id: nil) json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body + return if compare_id.present? && json['id'] != compare_id + json rescue Oj::ParseError nil @@ -105,35 +104,34 @@ module JsonLdHelper end end - private - def response_successful?(response) (200...300).cover?(response.code) end def response_error_unsalvageable?(response) - (400...500).cover?(response.code) && response.code != 429 + response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)) end def build_request(uri, on_behalf_of = nil) - request = Request.new(:get, uri) - request.on_behalf_of(on_behalf_of) if on_behalf_of - request.add_headers('Accept' => 'application/activity+json, application/ld+json') - request + Request.new(:get, uri).tap do |request| + request.on_behalf_of(on_behalf_of) if on_behalf_of + request.add_headers('Accept' => 'application/activity+json, application/ld+json') + end end def load_jsonld_context(url, _options = {}, &_block) json = Rails.cache.fetch("jsonld:context:#{url}", expires_in: 30.days, raw: true) do request = Request.new(:get, url) request.add_headers('Accept' => 'application/ld+json') - request.perform do |res| raise JSON::LD::JsonLdError::LoadingDocumentFailed unless res.code == 200 && res.mime_type == 'application/ld+json' + res.body_with_limit end end doc = JSON::LD::API::RemoteDocument.new(url, json) + block_given? ? yield(doc) : doc end end diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/statuses_helper.rb index 6a71f1c02..2996631a3 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module StreamEntriesHelper +module StatusesHelper EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' @@ -115,11 +115,13 @@ module StreamEntriesHelper def status_text_summary(status) return if status.spoiler_text.blank? + I18n.t('statuses.content_warning', warning: status.spoiler_text) end def poll_summary(status) return unless status.preloadable_poll + status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n") end diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js index 33b7a207d..0f4222139 100644 --- a/app/javascript/core/public.js +++ b/app/javascript/core/public.js @@ -47,7 +47,7 @@ const getProfileAvatarAnimationHandler = (swapTo) => { return ({ target }) => { const swapSrc = target.getAttribute(swapTo); //only change the img source if autoplay is off and the image src is actually different - if(target.getAttribute('data-autoplay') === 'false' && target.src !== swapSrc) { + if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) { target.src = swapSrc; } }; diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index c057a5298..0c2331374 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -12,6 +12,8 @@ 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 compareId from 'flavours/glitch/util/compare_id'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; @@ -32,8 +34,9 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; -export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; -export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; @@ -52,6 +55,10 @@ const fetchRelatedRelationships = (dispatch, notifications) => { } }; +export const loadPending = () => ({ + type: NOTIFICATIONS_LOAD_PENDING, +}); + export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true); @@ -83,6 +90,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch({ type: NOTIFICATIONS_UPDATE, notification, + usePendingItems: preferPendingItems, meta: (playSound && !filtered) ? { sound: 'boop' } : undefined, }); @@ -136,10 +144,19 @@ export function expandNotifications({ maxId } = {}, done = noOp) { : excludeTypesFromFilter(activeFilter), }; - if (!maxId && notifications.get('items').size > 0) { - params.since_id = notifications.getIn(['items', 0, 'id']); + if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) { + const a = notifications.getIn(['pendingItems', 0, 'id']); + const b = notifications.getIn(['items', 0, 'id']); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } } + const isLoadingRecent = !!params.since_id; + dispatch(expandNotificationsRequest(isLoadingMore)); api(getState).get('/api/v1/notifications', { params }).then(response => { @@ -148,7 +165,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) { dispatch(importFetchedAccounts(response.data.map(item => item.account))); dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); - dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore)); + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent && preferPendingItems)); fetchRelatedRelationships(dispatch, response.data); done(); }).catch(error => { @@ -165,13 +182,12 @@ export function expandNotificationsRequest(isLoadingMore) { }; }; -export function expandNotificationsSuccess(notifications, next, isLoadingMore) { +export function expandNotificationsSuccess(notifications, next, isLoadingMore, usePendingItems) { return { type: NOTIFICATIONS_EXPAND_SUCCESS, notifications, - accounts: notifications.map(item => item.account), - statuses: notifications.map(item => item.status).filter(status => !!status), next, + usePendingItems, skipLoading: !isLoadingMore, }; }; diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index cca571583..f5bc0fd23 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -1,6 +1,8 @@ import { importFetchedStatus, importFetchedStatuses } from './importer'; import api, { getLinks } from 'flavours/glitch/util/api'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import compareId from 'flavours/glitch/util/compare_id'; +import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; @@ -10,10 +12,15 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; -export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; +export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; +export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING'; +export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; -export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; -export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const loadPending = timeline => ({ + type: TIMELINE_LOAD_PENDING, + timeline, +}); export function updateTimeline(timeline, status, accept) { return dispatch => { @@ -27,6 +34,7 @@ export function updateTimeline(timeline, status, accept) { type: TIMELINE_UPDATE, timeline, status, + usePendingItems: preferPendingItems, }); }; }; @@ -71,8 +79,15 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { return; } - if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) { - params.since_id = timeline.getIn(['items', 0]); + if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) { + const a = timeline.getIn(['pendingItems', 0]); + const b = timeline.getIn(['items', 0]); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } } const isLoadingRecent = !!params.since_id; @@ -82,7 +97,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); @@ -117,7 +132,7 @@ export function expandTimelineRequest(timeline, isLoadingMore) { }; }; -export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) { +export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) { return { type: TIMELINE_EXPAND_SUCCESS, timeline, @@ -125,6 +140,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi next, partial, isLoadingRecent, + usePendingItems, skipLoading: !isLoadingMore, }; }; @@ -153,9 +169,8 @@ export function connectTimeline(timeline) { }; }; -export function disconnectTimeline(timeline) { - return { - type: TIMELINE_DISCONNECT, - timeline, - }; -}; +export const disconnectTimeline = timeline => ({ + type: TIMELINE_DISCONNECT, + timeline, + usePendingItems: preferPendingItems, +}); diff --git a/app/javascript/flavours/glitch/components/load_pending.js b/app/javascript/flavours/glitch/components/load_pending.js new file mode 100644 index 000000000..7e2702403 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_pending.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadPending extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + count: PropTypes.number, + } + + render() { + const { count } = this.props; + + return ( + <button className='load-more load-gap' onClick={this.props.onClick}> + <FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} /> + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js index 462185bbc..5f42bdd8b 100644 --- a/app/javascript/flavours/glitch/components/scrollable_list.js +++ b/app/javascript/flavours/glitch/components/scrollable_list.js @@ -3,6 +3,7 @@ import { ScrollContainer } from 'react-router-scroll-4'; import PropTypes from 'prop-types'; import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container'; import LoadMore from './load_more'; +import LoadPending from './load_pending'; import IntersectionObserverWrapper from 'flavours/glitch/util/intersection_observer_wrapper'; import { throttle } from 'lodash'; import { List as ImmutableList } from 'immutable'; @@ -21,6 +22,7 @@ export default class ScrollableList extends PureComponent { static propTypes = { scrollKey: PropTypes.string.isRequired, onLoadMore: PropTypes.func, + onLoadPending: PropTypes.func, onScrollToTop: PropTypes.func, onScroll: PropTypes.func, trackScroll: PropTypes.bool, @@ -28,6 +30,7 @@ export default class ScrollableList extends PureComponent { isLoading: PropTypes.bool, showLoading: PropTypes.bool, hasMore: PropTypes.bool, + numPending: PropTypes.number, prepend: PropTypes.node, alwaysPrepend: PropTypes.bool, emptyMessage: PropTypes.node, @@ -222,12 +225,18 @@ export default class ScrollableList extends PureComponent { return !(location.state && location.state.mastodonModalOpen); } + handleLoadPending = e => { + e.preventDefault(); + this.props.onLoadPending(); + } + render () { - const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = React.Children.count(children); const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; + const loadPending = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null; let scrollableArea = null; if (showLoading) { @@ -248,6 +257,8 @@ export default class ScrollableList extends PureComponent { <div role='feed' className='item-list'> {prepend} + {loadPending} + {React.Children.map(this.props.children, (child, index) => ( <IntersectionObserverArticleContainer key={child.key} diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index e94ce6dfe..7c08ae4e8 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -476,7 +476,7 @@ class Status extends ImmutablePureComponent { featured, ...other } = this.props; - const { isExpanded, isCollapsed } = this.state; + const { isExpanded, isCollapsed, forceFilter } = this.state; let background = null; let attachments = null; let media = null; @@ -496,7 +496,8 @@ class Status extends ImmutablePureComponent { ); } - if ((status.get('filtered') || status.getIn(['reblog', 'filtered'])) && (this.state.forceFilter === true || settings.get('filtering_behavior') !== 'content_warning')) { + const filtered = (status.get('filtered') || status.getIn(['reblog', 'filtered'])) && settings.get('filtering_behavior') !== 'content_warning'; + if (forceFilter === undefined ? filtered : forceFilter) { const minHandlers = this.props.muted ? {} : { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js index 4a2c62881..3dcfade3f 100644 --- a/app/javascript/flavours/glitch/components/status_icons.js +++ b/app/javascript/flavours/glitch/components/status_icons.js @@ -12,6 +12,13 @@ import VisibilityIcon from './status_visibility_icon'; const messages = defineMessages({ collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, + inReplyTo: { id: 'status.in_reply_to', defaultMessage: 'This toot is a reply' }, + previewCard: { id: 'status.has_preview_card', defaultMessage: 'Features an attached preview card' }, + pictures: { id: 'status.has_pictures', defaultMessage: 'Features attached pictures' }, + poll: { id: 'status.is_poll', defaultMessage: 'This toot is a poll' }, + video: { id: 'status.has_video', defaultMessage: 'Features attached videos' }, + audio: { id: 'status.has_audio', defaultMessage: 'Features attached audio files' }, + localOnly: { id: 'status.local_only', defaultMessage: 'Only visible from your instance' }, }); @injectIntl @@ -36,6 +43,23 @@ export default class StatusIcons extends React.PureComponent { } } + mediaIconTitleText () { + const { intl, mediaIcon } = this.props; + + switch (mediaIcon) { + case 'link': + return intl.formatMessage(messages.previewCard); + case 'picture-o': + return intl.formatMessage(messages.pictures); + case 'tasks': + return intl.formatMessage(messages.poll); + case 'video-camera': + return intl.formatMessage(messages.video); + case 'music': + return intl.formatMessage(messages.audio); + } + } + // Rendering. render () { const { @@ -53,12 +77,20 @@ export default class StatusIcons extends React.PureComponent { <i className={`fa fa-fw fa-comment status__reply-icon`} aria-hidden='true' + title={intl.formatMessage(messages.inReplyTo)} /> ) : null} + {status.get('local_only') && + <i + className={`fa fa-fw fa-home`} + aria-hidden='true' + title={intl.formatMessage(messages.localOnly)} + />} {mediaIcon ? ( <i className={`fa fa-fw fa-${mediaIcon} status__media-icon`} aria-hidden='true' + title={this.mediaIconTitleText()} /> ) : null} {!directMessage && <VisibilityIcon visibility={status.get('visibility')} />} diff --git a/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js index 96db003ce..72828967f 100644 --- a/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js @@ -26,7 +26,7 @@ export default class ColumnSettings extends React.PureComponent { return ( <div> <div className='column-settings__row'> - <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} /> + <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} /> </div> <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js index ac2211e48..0264b6815 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js +++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js @@ -12,6 +12,7 @@ export default class SettingToggle extends React.PureComponent { label: PropTypes.node.isRequired, meta: PropTypes.node, onChange: PropTypes.func.isRequired, + defaultValue: PropTypes.bool, } onChange = ({ target }) => { @@ -19,12 +20,12 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingPath, label, meta } = this.props; + const { prefix, settings, settingPath, label, meta, defaultValue } = this.props; const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); return ( <div className='setting-toggle'> - <Toggle id={id} checked={settings.getIn(settingPath)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <label htmlFor={id} className='setting-toggle__label'>{label}</label> {meta && <span className='setting-meta__label'>{meta}</span>} </div> diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index f2a1ccc3b..bf805c69a 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -10,6 +10,7 @@ import { scrollTopNotifications, mountNotifications, unmountNotifications, + loadPending, } from 'flavours/glitch/actions/notifications'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import NotificationContainer from './containers/notification_container'; @@ -48,6 +49,7 @@ const mapStateToProps = state => ({ isLoading: state.getIn(['notifications', 'isLoading'], true), isUnread: state.getIn(['notifications', 'unread']) > 0, hasMore: state.getIn(['notifications', 'hasMore']), + numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), }); @@ -80,6 +82,7 @@ export default class Notifications extends React.PureComponent { isUnread: PropTypes.bool, multiColumn: PropTypes.bool, hasMore: PropTypes.bool, + numPending: PropTypes.number, localSettings: ImmutablePropTypes.map, notifCleaningActive: PropTypes.bool, onEnterCleaningMode: PropTypes.func, @@ -100,6 +103,10 @@ export default class Notifications extends React.PureComponent { this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); }, 300, { leading: true }); + handleLoadPending = () => { + this.props.dispatch(loadPending()); + }; + handleScrollToTop = debounce(() => { this.props.dispatch(scrollTopNotifications(true)); }, 100); @@ -170,7 +177,7 @@ export default class Notifications extends React.PureComponent { } render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, showFilterBar } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props; const pinned = !!columnId; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; @@ -212,8 +219,10 @@ export default class Notifications extends React.PureComponent { isLoading={isLoading} showLoading={isLoading && notifications.size === 0} hasMore={hasMore} + numPending={numPending} emptyMessage={emptyMessage} onLoadMore={this.handleLoadOlder} + onLoadPending={this.handleLoadPending} onScrollToTop={this.handleScrollToTop} onScroll={this.handleScroll} shouldUpdateScroll={shouldUpdateScroll} diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js index deb8b7763..4ca853563 100644 --- a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js +++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import StatusList from 'flavours/glitch/components/status_list'; -import { scrollTopTimeline } from 'flavours/glitch/actions/timelines'; +import { scrollTopTimeline, loadPending } from 'flavours/glitch/actions/timelines'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { createSelector } from 'reselect'; import { debounce } from 'lodash'; @@ -62,6 +62,7 @@ const makeMapStateToProps = () => { isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), hasMore: state.getIn(['timelines', timelineId, 'hasMore']), + numPending: state.getIn(['timelines', timelineId, 'pendingItems'], ImmutableList()).size, }); return mapStateToProps; @@ -77,6 +78,8 @@ const mapDispatchToProps = (dispatch, { timelineId }) => ({ dispatch(scrollTopTimeline(timelineId, false)); }, 100), + onLoadPending: () => dispatch(loadPending(timelineId)), + }); export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index 5bbf9c822..d057f8f83 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -9,6 +9,7 @@ import { NOTIFICATIONS_FILTER_SET, NOTIFICATIONS_CLEAR, NOTIFICATIONS_SCROLL_TOP, + NOTIFICATIONS_LOAD_PENDING, NOTIFICATIONS_DELETE_MARKED_REQUEST, NOTIFICATIONS_DELETE_MARKED_SUCCESS, NOTIFICATION_MARK_FOR_DELETE, @@ -25,6 +26,7 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import compareId from 'flavours/glitch/util/compare_id'; const initialState = ImmutableMap({ + pendingItems: ImmutableList(), items: ImmutableList(), hasMore: true, top: false, @@ -46,7 +48,11 @@ const notificationToMap = (state, notification) => ImmutableMap({ status: notification.status ? notification.status.id : null, }); -const normalizeNotification = (state, notification) => { +const normalizeNotification = (state, notification, usePendingItems) => { + if (usePendingItems) { + return state.update('pendingItems', list => list.unshift(notificationToMap(state, notification))); + } + const top = !shouldCountUnreadNotifications(state); if (top) { @@ -64,7 +70,7 @@ const normalizeNotification = (state, notification) => { }); }; -const expandNormalizedNotifications = (state, notifications, next) => { +const expandNormalizedNotifications = (state, notifications, next, usePendingItems) => { const top = !(shouldCountUnreadNotifications(state)); const lastReadId = state.get('lastReadId'); let items = ImmutableList(); @@ -75,7 +81,7 @@ const expandNormalizedNotifications = (state, notifications, next) => { return state.withMutations(mutable => { if (!items.isEmpty()) { - mutable.update('items', list => { + mutable.update(usePendingItems ? 'pendingItems' : 'items', list => { const lastIndex = 1 + list.findLastIndex( item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id')) ); @@ -105,7 +111,8 @@ const expandNormalizedNotifications = (state, notifications, next) => { }; const filterNotifications = (state, relationship) => { - return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id)); + const helper = list => list.filterNot(item => item !== null && item.get('account') === relationship.id); + return state.update('items', helper).update('pendingItems', helper); }; const clearUnread = (state) => { @@ -131,7 +138,8 @@ const deleteByStatus = (state, statusId) => { const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); state = state.update('unread', unread => unread - deletedUnread.size); } - return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId)); + const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId); + return state.update('items', helper).update('pendingItems', helper); }; const markForDelete = (state, notificationId, yes) => { @@ -192,6 +200,8 @@ export default function notifications(state = initialState, action) { return state.update('mounted', count => count - 1); case NOTIFICATIONS_SET_VISIBILITY: return updateVisibility(state, action.visibility); + case NOTIFICATIONS_LOAD_PENDING: + return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); case NOTIFICATIONS_EXPAND_REQUEST: case NOTIFICATIONS_DELETE_MARKED_REQUEST: return state.set('isLoading', true); @@ -203,20 +213,20 @@ export default function notifications(state = initialState, action) { case NOTIFICATIONS_SCROLL_TOP: return updateTop(state, action.top); case NOTIFICATIONS_UPDATE: - return normalizeNotification(state, action.notification); + return normalizeNotification(state, action.notification, action.usePendingItems); case NOTIFICATIONS_EXPAND_SUCCESS: - return expandNormalizedNotifications(state, action.notifications, action.next); + return expandNormalizedNotifications(state, action.notifications, action.next, action.usePendingItems); case ACCOUNT_BLOCK_SUCCESS: return filterNotifications(state, action.relationship); case ACCOUNT_MUTE_SUCCESS: return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state; case NOTIFICATIONS_CLEAR: - return state.set('items', ImmutableList()).set('hasMore', false); + return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false); case TIMELINE_DELETE: return deleteByStatus(state, action.id); case TIMELINE_DISCONNECT: return action.timeline === 'home' ? - state.update('items', items => items.first() ? items.unshift(null) : items) : + state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : state; case NOTIFICATION_MARK_FOR_DELETE: diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js index 440b370e6..9b016a4c6 100644 --- a/app/javascript/flavours/glitch/reducers/timelines.js +++ b/app/javascript/flavours/glitch/reducers/timelines.js @@ -8,6 +8,7 @@ import { TIMELINE_SCROLL_TOP, TIMELINE_CONNECT, TIMELINE_DISCONNECT, + TIMELINE_LOAD_PENDING, } from 'flavours/glitch/actions/timelines'; import { ACCOUNT_BLOCK_SUCCESS, @@ -25,10 +26,11 @@ const initialTimeline = ImmutableMap({ top: true, isLoading: false, hasMore: true, + pendingItems: ImmutableList(), items: ImmutableList(), }); -const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => { +const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { mMap.set('isLoading', false); mMap.set('isPartial', isPartial); @@ -38,7 +40,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is if (timeline.endsWith(':pinned')) { mMap.set('items', statuses.map(status => status.get('id'))); } else if (!statuses.isEmpty()) { - mMap.update('items', ImmutableList(), oldIds => { + mMap.update(usePendingItems ? 'pendingItems' : 'items', ImmutableList(), oldIds => { const newIds = statuses.map(status => status.get('id')); const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1; const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0); @@ -56,7 +58,15 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is })); }; -const updateTimeline = (state, timeline, status) => { +const updateTimeline = (state, timeline, status, usePendingItems) => { + if (usePendingItems) { + if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) { + return state; + } + + return state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id')))); + } + const top = state.getIn([timeline, 'top']); const ids = state.getIn([timeline, 'items'], ImmutableList()); const includesId = ids.includes(status.get('id')); @@ -77,8 +87,10 @@ const updateTimeline = (state, timeline, status) => { const deleteStatus = (state, id, accountId, references, exclude_account = null) => { state.keySeq().forEach(timeline => { - if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) - state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); + if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) { + const helper = list => list.filterNot(item => item === id); + state = state.updateIn([timeline, 'items'], helper).updateIn([timeline, 'pendingItems'], helper); + } }); // Remove reblogs of deleted status @@ -108,11 +120,10 @@ const filterTimelines = (state, relationship, statuses) => { return state; }; -const filterTimeline = (timeline, state, relationship, statuses) => - state.updateIn([timeline, 'items'], ImmutableList(), list => - list.filterNot(statusId => - statuses.getIn([statusId, 'account']) === relationship.id - )); +const filterTimeline = (timeline, state, relationship, statuses) => { + const helper = list => list.filterNot(statusId => statuses.getIn([statusId, 'account']) === relationship.id); + return state.updateIn([timeline, 'items'], ImmutableList(), helper).updateIn([timeline, 'pendingItems'], ImmutableList(), helper); +}; const updateTop = (state, timeline, top) => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { @@ -123,14 +134,17 @@ const updateTop = (state, timeline, top) => { export default function timelines(state = initialState, action) { switch(action.type) { + case TIMELINE_LOAD_PENDING: + return state.update(action.timeline, initialTimeline, map => + map.update('items', list => map.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0)); case TIMELINE_EXPAND_REQUEST: return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); case TIMELINE_EXPAND_FAIL: return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); case TIMELINE_EXPAND_SUCCESS: - return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent); + return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems); case TIMELINE_UPDATE: - return updateTimeline(state, action.timeline, fromJS(action.status)); + return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); case TIMELINE_CLEAR: @@ -148,7 +162,7 @@ export default function timelines(state = initialState, action) { return state.update( action.timeline, initialTimeline, - map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items) + map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) ); default: return state; diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index b354e7acf..7f3c21163 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -43,7 +43,7 @@ display: flex; flex-direction: column; - @media screen and (min-width: 360px) { + @media screen and (min-width: $no-gap-breakpoint) { padding: 0 10px; } } @@ -466,14 +466,14 @@ } .auto-columns.navbar-under { - @media screen and (max-width: 360px) { + @media screen and (max-width: $no-gap-breakpoint) { @include fix-margins-for-navbar-under; } } .auto-columns.navbar-under .react-swipeable-view-container .columns-area, .single-column.navbar-under .react-swipeable-view-container .columns-area { - @media screen and (max-width: 360px) { + @media screen and (max-width: $no-gap-breakpoint) { height: 100% !important; } } diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index 3eb5551c6..1044b13c1 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -605,7 +605,7 @@ & > .side_arm { display: inline-block; - margin: 0 2px 0 0; + margin: 0 2px; padding: 0; width: 36px; text-align: center; diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss index 0994a9b43..93a3f62ed 100644 --- a/app/javascript/flavours/glitch/styles/components/drawer.scss +++ b/app/javascript/flavours/glitch/styles/components/drawer.scss @@ -78,7 +78,7 @@ margin-bottom: 10px; flex: none; - @include limited-single-column('screen and (max-width: 360px)') { margin-bottom: 0 } + @include limited-single-column('screen and (max-width: #{$no-gap-breakpoint})') { margin-bottom: 0 } @include single-column('screen and (max-width: 630px)') { font-size: 16px } } diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss index e0f3d62a7..83c5d351b 100644 --- a/app/javascript/flavours/glitch/styles/components/single_column.scss +++ b/app/javascript/flavours/glitch/styles/components/single_column.scss @@ -98,7 +98,7 @@ top: 15px; } - @media screen and (min-width: 360px) { + @media screen and (min-width: $no-gap-breakpoint) { padding: 10px 0; } @@ -184,7 +184,7 @@ } } -@media screen and (min-width: 360px) { +@media screen and (min-width: $no-gap-breakpoint) { .tabs-bar { margin: 10px auto; margin-bottom: 0; diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 4ffbb2c21..ccc6da594 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -193,10 +193,8 @@ } .status__prepend-icon-wrapper { - float: left; - margin: 0 10px 0 -58px; - width: 48px; - text-align: right; + left: -26px; + position: absolute; } .notification-follow { @@ -370,9 +368,7 @@ .status__relative-time { display: inline-block; - margin-left: auto; - padding-left: 18px; - width: 120px; + flex-grow: 1; color: $dark-text-color; font-size: 14px; text-align: right; @@ -382,7 +378,6 @@ } .status__display-name { - margin: 0 auto 0 0; color: $dark-text-color; overflow: hidden; } @@ -394,6 +389,7 @@ .status__info { display: flex; + justify-content: space-between; font-size: 15px; > span { @@ -407,25 +403,23 @@ } .status__info__icons { - margin-left: auto; display: flex; align-items: center; height: 1em; color: $action-button-color; - .status__media-icon { - padding-left: 6px; - padding-right: 1px; - } - - .status__visibility-icon { - padding-left: 4px; + .status__media-icon, + .status__visibility-icon, + .status__reply-icon { + padding-left: 2px; + padding-right: 2px; } } .status__info__account { display: flex; align-items: center; + justify-content: flex-start; } .status-check-box { @@ -465,9 +459,12 @@ } .status__prepend { - margin: -10px -10px 10px; + margin-top: -10px; + margin-bottom: 10px; + margin-left: 58px; color: $dark-text-color; - padding: 8px 10px 0 68px; + padding: 8px 0; + padding-bottom: 2px; font-size: 14px; position: relative; diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss index dc60dd14b..130e1461c 100644 --- a/app/javascript/flavours/glitch/styles/containers.scss +++ b/app/javascript/flavours/glitch/styles/containers.scss @@ -147,6 +147,10 @@ min-height: 100%; } + .flash-message { + margin-bottom: 10px; + } + @media screen and (max-width: 738px) { grid-template-columns: minmax(0, 50%) minmax(0, 50%); diff --git a/app/javascript/flavours/glitch/styles/index.scss b/app/javascript/flavours/glitch/styles/index.scss index 323b2e7fe..af73feb89 100644 --- a/app/javascript/flavours/glitch/styles/index.scss +++ b/app/javascript/flavours/glitch/styles/index.scss @@ -14,7 +14,7 @@ @import 'widgets'; @import 'forms'; @import 'accounts'; -@import 'stream_entries'; +@import 'statuses'; @import 'components/index'; @import 'polls'; @import 'about'; diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss index 11fae3121..8a275d82f 100644 --- a/app/javascript/flavours/glitch/styles/rtl.scss +++ b/app/javascript/flavours/glitch/styles/rtl.scss @@ -28,6 +28,15 @@ body.rtl { margin-left: 4px; } + .composer--publisher { + text-align: left; + } + + .boost-modal__status-time, + .favourite-modal__status-time { + float: left; + } + .navigation-bar__profile { margin-left: 0; margin-right: 8px; @@ -50,8 +59,8 @@ body.rtl { .column-header__buttons { left: 0; right: auto; - margin-left: -15px; - margin-right: 0; + margin-left: 0; + margin-right: -15px; } .column-inline-form .icon-button { @@ -87,11 +96,14 @@ body.rtl { } .status__avatar { + margin-left: 10px; + margin-right: 0; + + // Those are used for public pages left: auto; right: 10px; } - .status, .activity-stream .status.light { padding-left: 10px; padding-right: 68px; @@ -110,7 +122,7 @@ body.rtl { .status__prepend { margin-left: 0; - margin-right: 68px; + margin-right: 58px; } .status__prepend-icon-wrapper { @@ -136,17 +148,7 @@ body.rtl { .status__relative-time, .activity-stream .status.light .status__header .status__meta { float: left; - } - - .activity-stream .detailed-status.light .detailed-status__display-name > div { - float: right; - margin-right: 0; - margin-left: 10px; - } - - .activity-stream .detailed-status.light .detailed-status__meta span > span { - margin-left: 0; - margin-right: 6px; + text-align: left; } .status__action-bar { @@ -182,6 +184,10 @@ body.rtl { margin-right: 0; } + .detailed-status__display-name .display-name { + text-align: right; + } + .detailed-status__display-avatar { margin-right: 0; margin-left: 10px; @@ -195,7 +201,6 @@ body.rtl { } .fa-ul { - margin-left: 0; margin-left: 2.14285714em; } diff --git a/app/javascript/flavours/glitch/styles/stream_entries.scss b/app/javascript/flavours/glitch/styles/statuses.scss index de9c2612c..611d5185b 100644 --- a/app/javascript/flavours/glitch/styles/stream_entries.scss +++ b/app/javascript/flavours/glitch/styles/statuses.scss @@ -205,9 +205,20 @@ } .rtl { - .embed, .public-layout { - .status .status__relative-time { - float: left; + .embed, + .public-layout { + .status { + padding-left: 10px; + padding-right: 68px; + + .status__info .status__display-name { + padding-left: 25px; + padding-right: 0; + } + + .status__relative-time { + float: left; + } } } } diff --git a/app/javascript/flavours/glitch/util/compare_id.js b/app/javascript/flavours/glitch/util/compare_id.js index aaff66481..66cf51c4b 100644 --- a/app/javascript/flavours/glitch/util/compare_id.js +++ b/app/javascript/flavours/glitch/util/compare_id.js @@ -1,10 +1,11 @@ -export default function compareId(id1, id2) { +export default function compareId (id1, id2) { if (id1 === id2) { return 0; } + if (id1.length === id2.length) { return id1 > id2 ? 1 : -1; } else { return id1.length > id2.length ? 1 : -1; } -} +}; diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js index e8811a6ce..caaa79bb3 100644 --- a/app/javascript/flavours/glitch/util/initial_state.js +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -30,5 +30,6 @@ export const isStaff = getMeta('is_staff'); export const defaultContentType = getMeta('default_content_type'); export const forceSingleColumn = getMeta('advanced_layout') === false; export const useBlurhash = getMeta('use_blurhash'); +export const usePendingItems = getMeta('use_pending_items'); export default initialState; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 56c952cb0..d92d972bc 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -12,6 +12,8 @@ import { defineMessages } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { unescapeHTML } from '../utils/html'; import { getFiltersRegex } from '../selectors'; +import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; +import compareId from 'mastodon/compare_id'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -22,8 +24,9 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; -export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; -export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, @@ -38,6 +41,10 @@ const fetchRelatedRelationships = (dispatch, notifications) => { } }; +export const loadPending = () => ({ + type: NOTIFICATIONS_LOAD_PENDING, +}); + export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true); @@ -69,6 +76,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch({ type: NOTIFICATIONS_UPDATE, notification, + usePendingItems: preferPendingItems, meta: (playSound && !filtered) ? { sound: 'boop' } : undefined, }); @@ -122,10 +130,19 @@ export function expandNotifications({ maxId } = {}, done = noOp) { : excludeTypesFromFilter(activeFilter), }; - if (!maxId && notifications.get('items').size > 0) { - params.since_id = notifications.getIn(['items', 0, 'id']); + if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) { + const a = notifications.getIn(['pendingItems', 0, 'id']); + const b = notifications.getIn(['items', 0, 'id']); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } } + const isLoadingRecent = !!params.since_id; + dispatch(expandNotificationsRequest(isLoadingMore)); api(getState).get('/api/v1/notifications', { params }).then(response => { @@ -134,7 +151,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) { dispatch(importFetchedAccounts(response.data.map(item => item.account))); dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); - dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore)); + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent && preferPendingItems)); fetchRelatedRelationships(dispatch, response.data); done(); }).catch(error => { @@ -151,11 +168,12 @@ export function expandNotificationsRequest(isLoadingMore) { }; }; -export function expandNotificationsSuccess(notifications, next, isLoadingMore) { +export function expandNotificationsSuccess(notifications, next, isLoadingMore, usePendingItems) { return { type: NOTIFICATIONS_EXPAND_SUCCESS, notifications, next, + usePendingItems, skipLoading: !isLoadingMore, }; }; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 06c21b96b..7eeba2aa7 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,6 +1,8 @@ import { importFetchedStatus, importFetchedStatuses } from './importer'; -import api, { getLinks } from '../api'; +import api, { getLinks } from 'mastodon/api'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import compareId from 'mastodon/compare_id'; +import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; @@ -10,10 +12,15 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; -export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; +export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; +export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING'; +export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; -export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; -export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const loadPending = timeline => ({ + type: TIMELINE_LOAD_PENDING, + timeline, +}); export function updateTimeline(timeline, status, accept) { return dispatch => { @@ -27,6 +34,7 @@ export function updateTimeline(timeline, status, accept) { type: TIMELINE_UPDATE, timeline, status, + usePendingItems: preferPendingItems, }); }; }; @@ -71,8 +79,15 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { return; } - if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) { - params.since_id = timeline.getIn(['items', 0]); + if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) { + const a = timeline.getIn(['pendingItems', 0]); + const b = timeline.getIn(['items', 0]); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } } const isLoadingRecent = !!params.since_id; @@ -82,7 +97,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); @@ -115,7 +130,7 @@ export function expandTimelineRequest(timeline, isLoadingMore) { }; }; -export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) { +export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) { return { type: TIMELINE_EXPAND_SUCCESS, timeline, @@ -123,6 +138,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi next, partial, isLoadingRecent, + usePendingItems, skipLoading: !isLoadingMore, }; }; @@ -151,9 +167,8 @@ export function connectTimeline(timeline) { }; }; -export function disconnectTimeline(timeline) { - return { - type: TIMELINE_DISCONNECT, - timeline, - }; -}; +export const disconnectTimeline = timeline => ({ + type: TIMELINE_DISCONNECT, + timeline, + usePendingItems: preferPendingItems, +}); diff --git a/app/javascript/mastodon/compare_id.js b/app/javascript/mastodon/compare_id.js index aaff66481..66cf51c4b 100644 --- a/app/javascript/mastodon/compare_id.js +++ b/app/javascript/mastodon/compare_id.js @@ -1,10 +1,11 @@ -export default function compareId(id1, id2) { +export default function compareId (id1, id2) { if (id1 === id2) { return 0; } + if (id1.length === id2.length) { return id1 > id2 ? 1 : -1; } else { return id1.length > id2.length ? 1 : -1; } -} +}; diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 91b65a02f..e122515c4 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -122,11 +122,11 @@ class DropdownMenu extends React.PureComponent { return <li key={`sep-${i}`} className='dropdown-menu__separator' />; } - const { text, href = '#' } = option; + const { text, href = '#', target = '_blank', method } = option; return ( <li className='dropdown-menu__item' key={`${text}-${i}`}> - <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleItemKeyDown} data-index={i}> + <a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleItemKeyDown} data-index={i}> {text} </a> </li> diff --git a/app/javascript/mastodon/components/load_pending.js b/app/javascript/mastodon/components/load_pending.js new file mode 100644 index 000000000..7e2702403 --- /dev/null +++ b/app/javascript/mastodon/components/load_pending.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadPending extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + count: PropTypes.number, + } + + render() { + const { count } = this.props; + + return ( + <button className='load-more load-gap' onClick={this.props.onClick}> + <FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} /> + </button> + ); + } + +} diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index 0376cf85a..0bf817923 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -3,6 +3,7 @@ import { ScrollContainer } from 'react-router-scroll-4'; import PropTypes from 'prop-types'; import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; import LoadMore from './load_more'; +import LoadPending from './load_pending'; import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import { throttle } from 'lodash'; import { List as ImmutableList } from 'immutable'; @@ -21,6 +22,7 @@ export default class ScrollableList extends PureComponent { static propTypes = { scrollKey: PropTypes.string.isRequired, onLoadMore: PropTypes.func, + onLoadPending: PropTypes.func, onScrollToTop: PropTypes.func, onScroll: PropTypes.func, trackScroll: PropTypes.bool, @@ -28,10 +30,12 @@ export default class ScrollableList extends PureComponent { isLoading: PropTypes.bool, showLoading: PropTypes.bool, hasMore: PropTypes.bool, + numPending: PropTypes.number, prepend: PropTypes.node, alwaysPrepend: PropTypes.bool, emptyMessage: PropTypes.node, children: PropTypes.node, + bindToDocument: PropTypes.bool, }; static defaultProps = { @@ -47,7 +51,9 @@ export default class ScrollableList extends PureComponent { handleScroll = throttle(() => { if (this.node) { - const { scrollTop, scrollHeight, clientHeight } = this.node; + const scrollTop = this.getScrollTop(); + const scrollHeight = this.getScrollHeight(); + const clientHeight = this.getClientHeight(); const offset = scrollHeight - scrollTop - clientHeight; if (400 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading) { @@ -77,9 +83,14 @@ export default class ScrollableList extends PureComponent { scrollToTopOnMouseIdle = false; setScrollTop = newScrollTop => { - if (this.node.scrollTop !== newScrollTop) { + if (this.getScrollTop() !== newScrollTop) { this.lastScrollWasSynthetic = true; - this.node.scrollTop = newScrollTop; + + if (this.props.bindToDocument) { + document.scrollingElement.scrollTop = newScrollTop; + } else { + this.node.scrollTop = newScrollTop; + } } }; @@ -97,7 +108,7 @@ export default class ScrollableList extends PureComponent { this.clearMouseIdleTimer(); this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); - if (!this.mouseMovedRecently && this.node.scrollTop === 0) { + if (!this.mouseMovedRecently && this.getScrollTop() === 0) { // Only set if we just started moving and are scrolled to the top. this.scrollToTopOnMouseIdle = true; } @@ -132,15 +143,27 @@ export default class ScrollableList extends PureComponent { } getScrollPosition = () => { - if (this.node && (this.node.scrollTop > 0 || this.mouseMovedRecently)) { - return { height: this.node.scrollHeight, top: this.node.scrollTop }; + if (this.node && (this.getScrollTop() > 0 || this.mouseMovedRecently)) { + return { height: this.getScrollHeight(), top: this.getScrollTop() }; } else { return null; } } + getScrollTop = () => { + return this.props.bindToDocument ? document.scrollingElement.scrollTop : this.node.scrollTop; + } + + getScrollHeight = () => { + return this.props.bindToDocument ? document.scrollingElement.scrollHeight : this.node.scrollHeight; + } + + getClientHeight = () => { + return this.props.bindToDocument ? document.scrollingElement.clientHeight : this.node.clientHeight; + } + updateScrollBottom = (snapshot) => { - const newScrollTop = this.node.scrollHeight - snapshot; + const newScrollTop = this.getScrollHeight() - snapshot; this.setScrollTop(newScrollTop); } @@ -150,8 +173,8 @@ export default class ScrollableList extends PureComponent { React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); - if (someItemInserted && (this.node.scrollTop > 0 || this.mouseMovedRecently)) { - return this.node.scrollHeight - this.node.scrollTop; + if (someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently)) { + return this.getScrollHeight() - this.getScrollTop(); } else { return null; } @@ -161,7 +184,7 @@ export default class ScrollableList extends PureComponent { // Reset the scroll position when a new child comes in in order not to // jerk the scrollbar around if you're already scrolled down the page. if (snapshot !== null) { - this.setScrollTop(this.node.scrollHeight - snapshot); + this.setScrollTop(this.getScrollHeight() - snapshot); } } @@ -194,13 +217,23 @@ export default class ScrollableList extends PureComponent { } attachScrollListener () { - this.node.addEventListener('scroll', this.handleScroll); - this.node.addEventListener('wheel', this.handleWheel); + if (this.props.bindToDocument) { + document.addEventListener('scroll', this.handleScroll); + document.addEventListener('wheel', this.handleWheel); + } else { + this.node.addEventListener('scroll', this.handleScroll); + this.node.addEventListener('wheel', this.handleWheel); + } } detachScrollListener () { - this.node.removeEventListener('scroll', this.handleScroll); - this.node.removeEventListener('wheel', this.handleWheel); + if (this.props.bindToDocument) { + document.removeEventListener('scroll', this.handleScroll); + document.removeEventListener('wheel', this.handleWheel); + } else { + this.node.removeEventListener('scroll', this.handleScroll); + this.node.removeEventListener('wheel', this.handleWheel); + } } getFirstChildKey (props) { @@ -225,12 +258,18 @@ export default class ScrollableList extends PureComponent { this.props.onLoadMore(); } + handleLoadPending = e => { + e.preventDefault(); + this.props.onLoadPending(); + } + render () { - const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = React.Children.count(children); const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; + const loadPending = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null; let scrollableArea = null; if (showLoading) { @@ -251,6 +290,8 @@ export default class ScrollableList extends PureComponent { <div role='feed' className='item-list'> {prepend} + {loadPending} + {React.Children.map(this.props.children, (child, index) => ( <IntersectionObserverArticleContainer key={child.key} diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index 51d4f0fed..48492f43d 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -8,6 +8,7 @@ import Video from '../features/video'; import Card from '../features/status/components/card'; import Poll from 'mastodon/components/poll'; import ModalRoot from '../components/modal_root'; +import { getScrollbarWidth } from '../features/ui/components/modal_root'; import MediaModal from '../features/ui/components/media_modal'; import { List as ImmutableList, fromJS } from 'immutable'; @@ -31,6 +32,8 @@ export default class MediaContainer extends PureComponent { handleOpenMedia = (media, index) => { document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + this.setState({ media, index }); } @@ -38,11 +41,15 @@ export default class MediaContainer extends PureComponent { const media = ImmutableList([video]); document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + this.setState({ media, time }); } handleCloseMedia = () => { document.body.classList.remove('with-modals--active'); + document.documentElement.style.marginRight = 0; + this.setState({ media: null, index: null, time: null }); } diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index 27581bfdc..9914b7e65 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -44,6 +44,7 @@ class AccountTimeline extends ImmutablePureComponent { withReplies: PropTypes.bool, blockedBy: PropTypes.bool, isAccount: PropTypes.bool, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -77,7 +78,7 @@ class AccountTimeline extends ImmutablePureComponent { } render () { - const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount } = this.props; + const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn } = this.props; if (!isAccount) { return ( @@ -112,6 +113,7 @@ class AccountTimeline extends ImmutablePureComponent { onLoadMore={this.handleLoadMore} shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js index 96a219c94..8fb0f051b 100644 --- a/app/javascript/mastodon/features/blocks/index.js +++ b/app/javascript/mastodon/features/blocks/index.js @@ -32,6 +32,7 @@ class Blocks extends ImmutablePureComponent { accountIds: ImmutablePropTypes.list, hasMore: PropTypes.bool, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -43,7 +44,7 @@ class Blocks extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { intl, accountIds, shouldUpdateScroll, hasMore } = this.props; + const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn } = this.props; if (!accountIds) { return ( @@ -64,6 +65,7 @@ class Blocks extends ImmutablePureComponent { hasMore={hasMore} shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} + bindToDocument={!multiColumn} > {accountIds.map(id => <AccountContainer key={id} id={id} /> diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.js b/app/javascript/mastodon/features/community_timeline/components/column_settings.js index 8250190a7..0cb6db883 100644 --- a/app/javascript/mastodon/features/community_timeline/components/column_settings.js +++ b/app/javascript/mastodon/features/community_timeline/components/column_settings.js @@ -20,7 +20,7 @@ class ColumnSettings extends React.PureComponent { return ( <div> <div className='column-settings__row'> - <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} /> + <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} /> </div> </div> ); diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 7d26c98b0..2f6999f61 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -126,6 +126,7 @@ class CommunityTimeline extends React.PureComponent { onLoadMore={this.handleLoadMore} emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js index 077226d70..d0303dbfb 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.js +++ b/app/javascript/mastodon/features/compose/components/action_bar.js @@ -15,6 +15,7 @@ const messages = defineMessages({ domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, }); export default @injectIntl @@ -42,6 +43,8 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' }); return ( <div className='compose__action-bar'> diff --git a/app/javascript/mastodon/features/domain_blocks/index.js b/app/javascript/mastodon/features/domain_blocks/index.js index 7c075f5a5..16e200b31 100644 --- a/app/javascript/mastodon/features/domain_blocks/index.js +++ b/app/javascript/mastodon/features/domain_blocks/index.js @@ -33,6 +33,7 @@ class Blocks extends ImmutablePureComponent { hasMore: PropTypes.bool, domains: ImmutablePropTypes.orderedSet, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -44,7 +45,7 @@ class Blocks extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { intl, domains, shouldUpdateScroll, hasMore } = this.props; + const { intl, domains, shouldUpdateScroll, hasMore, multiColumn } = this.props; if (!domains) { return ( @@ -65,6 +66,7 @@ class Blocks extends ImmutablePureComponent { hasMore={hasMore} shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} + bindToDocument={!multiColumn} > {domains.map(domain => <DomainContainer key={domain} domain={domain} /> diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index fa9401b90..8c7b23869 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -95,6 +95,7 @@ class Favourites extends ImmutablePureComponent { onLoadMore={this.handleLoadMore} shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js index d1ac229a2..464f7aeb0 100644 --- a/app/javascript/mastodon/features/favourites/index.js +++ b/app/javascript/mastodon/features/favourites/index.js @@ -23,6 +23,7 @@ class Favourites extends ImmutablePureComponent { dispatch: PropTypes.func.isRequired, shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -36,7 +37,7 @@ class Favourites extends ImmutablePureComponent { } render () { - const { shouldUpdateScroll, accountIds } = this.props; + const { shouldUpdateScroll, accountIds, multiColumn } = this.props; if (!accountIds) { return ( @@ -56,6 +57,7 @@ class Favourites extends ImmutablePureComponent { scrollKey='favourites' shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} + bindToDocument={!multiColumn} > {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} /> diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js index 44624cb40..570cf57c8 100644 --- a/app/javascript/mastodon/features/follow_requests/index.js +++ b/app/javascript/mastodon/features/follow_requests/index.js @@ -32,6 +32,7 @@ class FollowRequests extends ImmutablePureComponent { hasMore: PropTypes.bool, accountIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -43,7 +44,7 @@ class FollowRequests extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { intl, shouldUpdateScroll, accountIds, hasMore } = this.props; + const { intl, shouldUpdateScroll, accountIds, hasMore, multiColumn } = this.props; if (!accountIds) { return ( @@ -64,6 +65,7 @@ class FollowRequests extends ImmutablePureComponent { hasMore={hasMore} shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} + bindToDocument={!multiColumn} > {accountIds.map(id => <AccountAuthorizeContainer key={id} id={id} /> diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js index e3387e1be..dce05bdc6 100644 --- a/app/javascript/mastodon/features/followers/index.js +++ b/app/javascript/mastodon/features/followers/index.js @@ -36,6 +36,7 @@ class Followers extends ImmutablePureComponent { hasMore: PropTypes.bool, blockedBy: PropTypes.bool, isAccount: PropTypes.bool, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -55,7 +56,7 @@ class Followers extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount } = this.props; + const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn } = this.props; if (!isAccount) { return ( @@ -87,6 +88,7 @@ class Followers extends ImmutablePureComponent { prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} alwaysPrepend emptyMessage={emptyMessage} + bindToDocument={!multiColumn} > {blockedBy ? [] : accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} /> diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js index 3bf89fb2b..d9f2ef079 100644 --- a/app/javascript/mastodon/features/following/index.js +++ b/app/javascript/mastodon/features/following/index.js @@ -36,6 +36,7 @@ class Following extends ImmutablePureComponent { hasMore: PropTypes.bool, blockedBy: PropTypes.bool, isAccount: PropTypes.bool, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -55,7 +56,7 @@ class Following extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount } = this.props; + const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn } = this.props; if (!isAccount) { return ( @@ -87,6 +88,7 @@ class Following extends ImmutablePureComponent { prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} alwaysPrepend emptyMessage={emptyMessage} + bindToDocument={!multiColumn} > {blockedBy ? [] : accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} /> diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index 0d3c97a64..c50f6a79a 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -157,6 +157,7 @@ class HashtagTimeline extends React.PureComponent { onLoadMore={this.handleLoadMore} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index 097f91c16..bf8ff117b 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -119,6 +119,7 @@ class HomeTimeline extends React.PureComponent { timelineId='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js index 0db6d2228..844c93db1 100644 --- a/app/javascript/mastodon/features/list_timeline/index.js +++ b/app/javascript/mastodon/features/list_timeline/index.js @@ -184,6 +184,7 @@ class ListTimeline extends React.PureComponent { onLoadMore={this.handleLoadMore} emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet. When members of this list post new statuses, they will appear here.' />} shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/mastodon/features/lists/index.js b/app/javascript/mastodon/features/lists/index.js index 015e21b68..a06e0b934 100644 --- a/app/javascript/mastodon/features/lists/index.js +++ b/app/javascript/mastodon/features/lists/index.js @@ -40,6 +40,7 @@ class Lists extends ImmutablePureComponent { dispatch: PropTypes.func.isRequired, lists: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -47,7 +48,7 @@ class Lists extends ImmutablePureComponent { } render () { - const { intl, shouldUpdateScroll, lists } = this.props; + const { intl, shouldUpdateScroll, lists, multiColumn } = this.props; if (!lists) { return ( @@ -70,6 +71,7 @@ class Lists extends ImmutablePureComponent { shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />} + bindToDocument={!multiColumn} > {lists.map(list => <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js index 4ed29a1ce..57d8b9915 100644 --- a/app/javascript/mastodon/features/mutes/index.js +++ b/app/javascript/mastodon/features/mutes/index.js @@ -32,6 +32,7 @@ class Mutes extends ImmutablePureComponent { hasMore: PropTypes.bool, accountIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -43,7 +44,7 @@ class Mutes extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { intl, shouldUpdateScroll, hasMore, accountIds } = this.props; + const { intl, shouldUpdateScroll, hasMore, accountIds, multiColumn } = this.props; if (!accountIds) { return ( @@ -64,6 +65,7 @@ class Mutes extends ImmutablePureComponent { hasMore={hasMore} shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} + bindToDocument={!multiColumn} > {accountIds.map(id => <AccountContainer key={id} id={id} /> diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js index 7aec16d2e..e6f593ef8 100644 --- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -11,6 +11,7 @@ export default class SettingToggle extends React.PureComponent { settingPath: PropTypes.array.isRequired, label: PropTypes.node.isRequired, onChange: PropTypes.func.isRequired, + defaultValue: PropTypes.bool, } onChange = ({ target }) => { @@ -18,12 +19,12 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingPath, label } = this.props; + const { prefix, settings, settingPath, label, defaultValue } = this.props; const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); return ( <div className='setting-toggle'> - <Toggle id={id} checked={settings.getIn(settingPath)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <label htmlFor={id} className='setting-toggle__label'>{label}</label> </div> ); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 006c45657..e708c4fcf 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; -import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; +import { expandNotifications, scrollTopNotifications, loadPending } from '../../actions/notifications'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import NotificationContainer from './containers/notification_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; @@ -41,6 +41,7 @@ const mapStateToProps = state => ({ isLoading: state.getIn(['notifications', 'isLoading'], true), isUnread: state.getIn(['notifications', 'unread']) > 0, hasMore: state.getIn(['notifications', 'hasMore']), + numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, }); export default @connect(mapStateToProps) @@ -58,6 +59,7 @@ class Notifications extends React.PureComponent { isUnread: PropTypes.bool, multiColumn: PropTypes.bool, hasMore: PropTypes.bool, + numPending: PropTypes.number, }; static defaultProps = { @@ -80,6 +82,10 @@ class Notifications extends React.PureComponent { this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); }, 300, { leading: true }); + handleLoadPending = () => { + this.props.dispatch(loadPending()); + }; + handleScrollToTop = debounce(() => { this.props.dispatch(scrollTopNotifications(true)); }, 100); @@ -136,7 +142,7 @@ class Notifications extends React.PureComponent { } render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, showFilterBar } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props; const pinned = !!columnId; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; @@ -178,11 +184,14 @@ class Notifications extends React.PureComponent { isLoading={isLoading} showLoading={isLoading && notifications.size === 0} hasMore={hasMore} + numPending={numPending} emptyMessage={emptyMessage} onLoadMore={this.handleLoadOlder} + onLoadPending={this.handleLoadPending} onScrollToTop={this.handleScrollToTop} onScroll={this.handleScroll} shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} > {scrollableContent} </ScrollableList> diff --git a/app/javascript/mastodon/features/pinned_statuses/index.js b/app/javascript/mastodon/features/pinned_statuses/index.js index 98cdbda3c..64ebfc7ae 100644 --- a/app/javascript/mastodon/features/pinned_statuses/index.js +++ b/app/javascript/mastodon/features/pinned_statuses/index.js @@ -28,6 +28,7 @@ class PinnedStatuses extends ImmutablePureComponent { statusIds: ImmutablePropTypes.list.isRequired, intl: PropTypes.object.isRequired, hasMore: PropTypes.bool.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -43,7 +44,7 @@ class PinnedStatuses extends ImmutablePureComponent { } render () { - const { intl, shouldUpdateScroll, statusIds, hasMore } = this.props; + const { intl, shouldUpdateScroll, statusIds, hasMore, multiColumn } = this.props; return ( <Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> @@ -53,6 +54,7 @@ class PinnedStatuses extends ImmutablePureComponent { scrollKey='pinned_statuses' hasMore={hasMore} shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index 2b7d9c56f..1edb303b8 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -126,6 +126,7 @@ class PublicTimeline extends React.PureComponent { scrollKey={`public_timeline-${columnId}`} emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />} shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} /> </Column> ); diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js index c05d21c74..26f93ad1b 100644 --- a/app/javascript/mastodon/features/reblogs/index.js +++ b/app/javascript/mastodon/features/reblogs/index.js @@ -23,6 +23,7 @@ class Reblogs extends ImmutablePureComponent { dispatch: PropTypes.func.isRequired, shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -36,7 +37,7 @@ class Reblogs extends ImmutablePureComponent { } render () { - const { shouldUpdateScroll, accountIds } = this.props; + const { shouldUpdateScroll, accountIds, multiColumn } = this.props; if (!accountIds) { return ( @@ -56,6 +57,7 @@ class Reblogs extends ImmutablePureComponent { scrollKey='reblogs' shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} + bindToDocument={!multiColumn} > {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} /> diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index cc2ab6c8c..06f9e1bc4 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -32,6 +32,28 @@ const MODAL_COMPONENTS = { 'LIST_ADDER':ListAdder, }; +let cachedScrollbarWidth = null; + +export const getScrollbarWidth = () => { + if (cachedScrollbarWidth !== null) { + return cachedScrollbarWidth; + } + + const outer = document.createElement('div'); + outer.style.visibility = 'hidden'; + outer.style.overflow = 'scroll'; + document.body.appendChild(outer); + + const inner = document.createElement('div'); + outer.appendChild(inner); + + const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; + cachedScrollbarWidth = scrollbarWidth; + outer.parentNode.removeChild(outer); + + return scrollbarWidth; +}; + export default class ModalRoot extends React.PureComponent { static propTypes = { @@ -47,8 +69,10 @@ export default class ModalRoot extends React.PureComponent { componentDidUpdate (prevProps, prevState, { visible }) { if (visible) { document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; } else { document.body.classList.remove('with-modals--active'); + document.documentElement.style.marginRight = 0; } } diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js index 3df5b7bea..7b8eb652b 100644 --- a/app/javascript/mastodon/features/ui/containers/status_list_container.js +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import StatusList from '../../../components/status_list'; -import { scrollTopTimeline } from '../../../actions/timelines'; +import { scrollTopTimeline, loadPending } from '../../../actions/timelines'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { createSelector } from 'reselect'; import { debounce } from 'lodash'; @@ -37,6 +37,7 @@ const makeMapStateToProps = () => { isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), hasMore: state.getIn(['timelines', timelineId, 'hasMore']), + numPending: state.getIn(['timelines', timelineId, 'pendingItems'], ImmutableList()).size, }); return mapStateToProps; @@ -52,6 +53,8 @@ const mapDispatchToProps = (dispatch, { timelineId }) => ({ dispatch(scrollTopTimeline(timelineId, false)); }, 100), + onLoadPending: () => dispatch(loadPending(timelineId)), + }); export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 791133afd..d1a3dc949 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -110,12 +110,25 @@ class SwitchingColumnsArea extends React.PureComponent { componentWillMount () { window.addEventListener('resize', this.handleResize, { passive: true }); + + if (this.state.mobile || forceSingleColumn) { + document.body.classList.toggle('layout-single-column', true); + document.body.classList.toggle('layout-multiple-columns', false); + } else { + document.body.classList.toggle('layout-single-column', false); + document.body.classList.toggle('layout-multiple-columns', true); + } } - componentDidUpdate (prevProps) { + componentDidUpdate (prevProps, prevState) { if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { this.node.handleChildrenContentChange(); } + + if (prevState.mobile !== this.state.mobile && !forceSingleColumn) { + document.body.classList.toggle('layout-single-column', this.state.mobile); + document.body.classList.toggle('layout-multiple-columns', !this.state.mobile); + } } componentWillUnmount () { diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 7df2a90bc..3c3c80e99 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -22,5 +22,6 @@ export const profile_directory = getMeta('profile_directory'); export const isStaff = getMeta('is_staff'); export const forceSingleColumn = !getMeta('advanced_layout'); export const useBlurhash = getMeta('use_blurhash'); +export const usePendingItems = getMeta('use_pending_items'); export default initialState; diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index d05c61f98..d62ee90c2 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "أساسية", "home.column_settings.show_reblogs": "عرض الترقيات", "home.column_settings.show_replies": "عرض الردود", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# يوم} other {# أيام}}", "intervals.full.hours": "{number, plural, one {# ساعة} other {# ساعات}}", "intervals.full.minutes": "{number, plural, one {# دقيقة} other {# دقائق}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "عنوان القائمة الجديدة", "lists.search": "إبحث في قائمة الحسابات التي تُتابِعها", "lists.subheading": "قوائمك", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "تحميل...", "media_gallery.toggle_visible": "عرض / إخفاء", "missing_indicator.label": "تعذر العثور عليه", @@ -314,6 +316,7 @@ "search_results.accounts": "أشخاص", "search_results.hashtags": "الوُسوم", "search_results.statuses": "التبويقات", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} و {results}}", "status.admin_account": "افتح الواجهة الإدارية لـ @{name}", "status.admin_status": "افتح هذا المنشور على واجهة الإشراف", diff --git a/app/javascript/mastodon/locales/ast.json b/app/javascript/mastodon/locales/ast.json index b911848ee..3ae4e5e5e 100644 --- a/app/javascript/mastodon/locales/ast.json +++ b/app/javascript/mastodon/locales/ast.json @@ -64,7 +64,7 @@ "column_header.show_settings": "Show settings", "column_header.unpin": "Desfixar", "column_subheading.settings": "Axustes", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Media only", "compose_form.direct_message_warning": "Esti toot namái va unviase a los usuarios mentaos.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Amosar toots compartíos", "home.column_settings.show_replies": "Amosar rempuestes", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Títulu nuevu de la llista", "lists.search": "Guetar ente la xente que sigues", "lists.subheading": "Les tos llistes", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Cargando...", "media_gallery.toggle_visible": "Toggle visibility", "missing_indicator.label": "Nun s'alcontró", @@ -314,6 +316,7 @@ "search_results.accounts": "Xente", "search_results.hashtags": "Etiquetes", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json index 783f9eb68..4c97fe1fc 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -64,7 +64,7 @@ "column_header.show_settings": "Show settings", "column_header.unpin": "Unpin", "column_subheading.settings": "Settings", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Media only", "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Зареждане...", "media_gallery.toggle_visible": "Toggle visibility", "missing_indicator.label": "Not found", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/bn.json b/app/javascript/mastodon/locales/bn.json index 5b7162ec1..358f994f3 100644 --- a/app/javascript/mastodon/locales/bn.json +++ b/app/javascript/mastodon/locales/bn.json @@ -1,13 +1,13 @@ { "account.add_or_remove_from_list": "তালিকাতে আরো যুক্ত বা মুছে ফেলুন", "account.badges.bot": "রোবট", - "account.block": "@{name} বন্ধ করুন", + "account.block": "@{name} কে বন্ধ করুন", "account.block_domain": "{domain} থেকে সব সরিয়ে ফেলুন", "account.blocked": "বন্ধ করা হয়েছে", - "account.direct": "@{name}কে সরকারি লিখুন", + "account.direct": "@{name} এর কাছে সরকারি লেখা পাঠাতে", "account.domain_blocked": "ওয়েবসাইট সরিয়ে ফেলা হয়েছে", - "account.edit_profile": "নিজের পাতা সম্পাদনা করুন", - "account.endorse": "নিজের পাতায় দেখান", + "account.edit_profile": "নিজের পাতা সম্পাদনা করতে", + "account.endorse": "আপনার নিজের পাতায় দেখাতে", "account.follow": "অনুসরণ করুন", "account.followers": "অনুসরণকারক", "account.followers.empty": "এই ব্যবহারকারীকে কেও এখনো অনুসরণ করে না।", @@ -18,21 +18,21 @@ "account.link_verified_on": "এই লিংকের মালিকানা চেক করা হয়েছে {date} তারিকে", "account.locked_info": "এই নিবন্ধনের গোপনীয়তার ক্ষেত্র তালা দেওয়া আছে। নিবন্ধনকারী অনুসরণ করার অনুমতি যাদেরকে দেবেন, শুধু তারাই অনুসরণ করতে পারবেন।", "account.media": "ছবি বা ভিডিও", - "account.mention": "@{name} কে উল্লেখ করুন", + "account.mention": "@{name} কে উল্লেখ করতে", "account.moved_to": "{name} চলে গেছে এখানে:", - "account.mute": "@{name}র কার্যক্রম সরিয়ে ফেলুন", + "account.mute": "@{name} সব কার্যক্রম আপনার সময়রেখা থেকে সরিয়ে ফেলতে", "account.mute_notifications": "@{name}র প্রজ্ঞাপন আপনার কাছ থেকে সরিয়ে ফেলুন", "account.muted": "সরানো আছে", "account.posts": "টুট", "account.posts_with_replies": "টুট এবং মতামত", - "account.report": "@{name}কে রিপোর্ট করে দিন", + "account.report": "@{name} কে রিপোর্ট করতে", "account.requested": "অনুমতির অপেক্ষায় আছে। অনুসরণ করার অনুরোধ বাতিল করতে এখানে ক্লিক করুন", "account.share": "@{name}র পাতা অন্যদের দেখান", "account.show_reblogs": "@{name}র সমর্থনগুলো দেখুন", "account.unblock": "@{name}র কার্যকলাপ আবার দেখুন", "account.unblock_domain": "{domain}থেকে আবার দেখুন", - "account.unendorse": "নিজের পাতায় এটা দেখতে চান না", - "account.unfollow": "অনুসরণ বন্ধ করুন", + "account.unendorse": "আপনার নিজের পাতায় এটা না দেখাতে", + "account.unfollow": "অনুসরণ না করতে", "account.unmute": "@{name}র কার্যকলাপ আবার দেখুন", "account.unmute_notifications": "@{name}র প্রজ্ঞাপন দেওয়ার অনুমতি দিন", "alert.unexpected.message": "অপ্রত্যাশিত একটি সমস্যা হয়েছে।", @@ -42,7 +42,7 @@ "bundle_column_error.retry": "আবার চেষ্টা করুন", "bundle_column_error.title": "নেটওয়ার্কের সমস্যা হচ্ছে", "bundle_modal_error.close": "বন্ধ করুন", - "bundle_modal_error.message": "এই অংশটি দেখতে যেয়ে কোনো সমস্যা হয়েছে।", + "bundle_modal_error.message": "এই অংশটি দেখাতে যেয়ে কোনো সমস্যা হয়েছে।", "bundle_modal_error.retry": "আবার চেষ্টা করুন", "column.blocks": "যাদের বন্ধ করে রাখা হয়েছে", "column.community": "স্থানীয় সময়সারি", @@ -77,12 +77,12 @@ "compose_form.poll.remove_option": "এই বিকল্পটি মুছে ফেলুন", "compose_form.publish": "টুট", "compose_form.publish_loud": "{publish}!", - "compose_form.sensitive.hide": "Mark media as sensitive", + "compose_form.sensitive.hide": "এই ছবি বা ভিডিওটি সংবেদনশীল হিসেবে চিহ্নিত করতে", "compose_form.sensitive.marked": "এই ছবি বা ভিডিওটি সংবেদনশীল হিসেবে চিহ্নিত করা হয়েছে", "compose_form.sensitive.unmarked": "এই ছবি বা ভিডিওটি সংবেদনশীল হিসেবে চিহ্নিত করা হয়নি", "compose_form.spoiler.marked": "লেখাটি সাবধানতার পেছনে লুকানো আছে", "compose_form.spoiler.unmarked": "লেখাটি লুকানো নেই", - "compose_form.spoiler_placeholder": "আপনার সাবধানতা এখানে লিখুন", + "compose_form.spoiler_placeholder": "আপনার লেখা দেখার সাবধানবাণী লিখুন", "confirmation_modal.cancel": "বাতিল করুন", "confirmations.block.block_and_report": "বন্ধ করুন এবং রিপোর্ট করুন", "confirmations.block.confirm": "বন্ধ করুন", @@ -99,7 +99,7 @@ "confirmations.redraft.message": "আপনি কি নিশ্চিত এটি মুছে ফেলে এবং আবার সম্পাদন করতে চান ? এটাতে যা পছন্দিত, সমর্থন বা মতামত আছে সেগুলো নতুন লেখার সাথে যুক্ত থাকবে না।", "confirmations.reply.confirm": "মতামত", "confirmations.reply.message": "এখন মতামত লিখতে গেলে আপনার এখন যেটা লিখছেন সেটা মুছে যাবে। আপনি নি নিশ্চিত এটা করতে চান ?", - "confirmations.unfollow.confirm": "অনুসরণ বন্ধ করুন", + "confirmations.unfollow.confirm": "অনুসরণ করা বাতিল করতে", "confirmations.unfollow.message": "আপনি কি নিশ্চিত {name} কে আর অনুসরণ করতে চান না ?", "embed.instructions": "এই লেখাটি আপনার ওয়েবসাইটে যুক্ত করতে নিচের কোডটি বেবহার করুন।", "embed.preview": "সেটা দেখতে এরকম হবে:", @@ -137,11 +137,11 @@ "follow_request.authorize": "অনুমতি দিন", "follow_request.reject": "প্রত্যাখ্যান করুন", "getting_started.developers": "তৈরিকারকদের জন্য", - "getting_started.directory": "নিজস্ব পাতার তালিকা", + "getting_started.directory": "নিজস্ব-পাতাগুলির তালিকা", "getting_started.documentation": "নথিপত্র", "getting_started.heading": "শুরু করা", "getting_started.invite": "অন্যদের আমন্ত্রণ করুন", - "getting_started.open_source_notice": "মাস্টাডন একটি মুক্ত সফটওয়্যার। আপনি তৈরিতে সাহায্য করতে পারেন অথবা সমস্যা রিপোর্ট করতে পারেন গিটহাবে {github}।", + "getting_started.open_source_notice": "মাস্টাডন একটি মুক্ত সফটওয়্যার। তৈরিতে সাহায্য করতে বা কোনো সমস্যা সম্পর্কে জানাতে আমাদের গিটহাবে যেতে পারেন {github}।", "getting_started.security": "নিরাপত্তা", "getting_started.terms": "ব্যবহারের নিয়মাবলী", "hashtag.column_header.tag_mode.all": "এবং {additional}", @@ -152,10 +152,11 @@ "hashtag.column_settings.tag_mode.all": "এগুলো সব", "hashtag.column_settings.tag_mode.any": "এর ভেতরে যেকোনোটা", "hashtag.column_settings.tag_mode.none": "এগুলোর একটাও না", - "hashtag.column_settings.tag_toggle": "আরো ট্যাগ এই কলামে যুক্ত করুন", + "hashtag.column_settings.tag_toggle": "আরো ট্যাগ এই কলামে যুক্ত করতে", "home.column_settings.basic": "সাধারণ", "home.column_settings.show_reblogs": "সমর্থনগুলো দেখান", "home.column_settings.show_replies": "মতামত দেখান", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# ঘটা} other {# ঘটা}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -195,7 +196,7 @@ "keyboard_shortcuts.local": "স্থানীয় সময়রেখাতে যেতে", "keyboard_shortcuts.mention": "লেখককে উল্লেখ করতে", "keyboard_shortcuts.muted": "বন্ধ করা ব্যবহারকারীদের তালিকা খুলতে", - "keyboard_shortcuts.my_profile": "নিজের পাতা দেখতে", + "keyboard_shortcuts.my_profile": "আপনার নিজের পাতা দেখতে", "keyboard_shortcuts.notifications": "প্রজ্ঞাপনের কলাম খুলতে", "keyboard_shortcuts.pinned": "পিন দেওয়া টুটের তালিকা খুলতে", "keyboard_shortcuts.profile": "লেখকের পাতা দেখতে", @@ -204,14 +205,14 @@ "keyboard_shortcuts.search": "খোঁজার অংশে ফোকাস করতে", "keyboard_shortcuts.start": "\"প্রথম শুরুর\" কলাম বের করতে", "keyboard_shortcuts.toggle_hidden": "CW লেখা দেখতে বা লুকাতে", - "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", + "keyboard_shortcuts.toggle_sensitivity": "ভিডিও/ছবি দেখতে বা বন্ধ করতে", "keyboard_shortcuts.toot": "নতুন একটা টুট লেখা শুরু করতে", "keyboard_shortcuts.unfocus": "লেখা বা খোঁজার জায়গায় ফোকাস না করতে", "keyboard_shortcuts.up": "তালিকার উপরের দিকে যেতে", "lightbox.close": "বন্ধ", "lightbox.next": "পরবর্তী", "lightbox.previous": "পূর্ববর্তী", - "lightbox.view_context": "View context", + "lightbox.view_context": "প্রসঙ্গটি দেখতে", "lists.account.add": "তালিকাতে যুক্ত করতে", "lists.account.remove": "তালিকা থেকে বাদ দিতে", "lists.delete": "তালিকা মুছে ফেলতে", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "তালিকার নতুন শিরোনাম দিতে", "lists.search": "যাদের অনুসরণ করেন তাদের ভেতরে খুঁজুন", "lists.subheading": "আপনার তালিকা", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "আসছে...", "media_gallery.toggle_visible": "দৃশ্যতার অবস্থা বদলান", "missing_indicator.label": "খুঁজে পাওয়া যায়নি", @@ -230,14 +232,14 @@ "navigation_bar.blocks": "বন্ধ করা ব্যবহারকারী", "navigation_bar.community_timeline": "স্থানীয় সময়রেখা", "navigation_bar.compose": "নতুন টুট লিখুন", - "navigation_bar.direct": "সরাসরি লেখা", + "navigation_bar.direct": "সরাসরি লেখাগুলি", "navigation_bar.discover": "ঘুরে দেখুন", "navigation_bar.domain_blocks": "বন্ধ করা ওয়েবসাইট", - "navigation_bar.edit_profile": "নিজের পাতা সম্পাদনা করুন", + "navigation_bar.edit_profile": "নিজের পাতা সম্পাদনা করতে", "navigation_bar.favourites": "পছন্দের", "navigation_bar.filters": "বন্ধ করা শব্দ", "navigation_bar.follow_requests": "অনুসরণের অনুরোধগুলি", - "navigation_bar.follows_and_followers": "Follows and followers", + "navigation_bar.follows_and_followers": "যাদেরকে অনুসরণ করেন এবং যারা তাকে অনুসরণ করে", "navigation_bar.info": "এই সার্ভার সম্পর্কে", "navigation_bar.keyboard_shortcuts": "হটকীগুলি", "navigation_bar.lists": "তালিকাগুলো", @@ -246,7 +248,7 @@ "navigation_bar.personal": "নিজস্ব", "navigation_bar.pins": "পিন দেওয়া টুট", "navigation_bar.preferences": "পছন্দসমূহ", - "navigation_bar.profile_directory": "Profile directory", + "navigation_bar.profile_directory": "নিজস্ব পাতার তালিকা", "navigation_bar.public_timeline": "যুক্তবিশ্বের সময়রেখা", "navigation_bar.security": "নিরাপত্তা", "notification.favourite": "{name} আপনার কার্যক্রম পছন্দ করেছেন", @@ -256,18 +258,18 @@ "notification.reblog": "{name} আপনার কার্যক্রমে সমর্থন দেখিয়েছেন", "notifications.clear": "প্রজ্ঞাপনগুলো মুছে ফেলতে", "notifications.clear_confirmation": "আপনি কি নির্চিত প্রজ্ঞাপনগুলো মুছে ফেলতে চান ?", - "notifications.column_settings.alert": "কম্পিউটারে প্রজ্ঞাপন", + "notifications.column_settings.alert": "কম্পিউটারে প্রজ্ঞাপনগুলি", "notifications.column_settings.favourite": "পছন্দের:", - "notifications.column_settings.filter_bar.advanced": "সব শ্রেণীগুলো দেখতে", - "notifications.column_settings.filter_bar.category": "দ্রুত ছাঁকনি বার", - "notifications.column_settings.filter_bar.show": "দেখতে", + "notifications.column_settings.filter_bar.advanced": "সব শ্রেণীগুলো দেখানো", + "notifications.column_settings.filter_bar.category": "সংক্ষিপ্ত ছাঁকনি অংশ", + "notifications.column_settings.filter_bar.show": "দেখানো", "notifications.column_settings.follow": "নতুন অনুসরণকারীরা:", "notifications.column_settings.mention": "প্রজ্ঞাপনগুলো:", "notifications.column_settings.poll": "নির্বাচনের ফলাফল:", - "notifications.column_settings.push": "পুশ প্রজ্ঞাপন", + "notifications.column_settings.push": "পুশ প্রজ্ঞাপনগুলি", "notifications.column_settings.reblog": "সমর্থনগুলো:", - "notifications.column_settings.show": "কলামে দেখান", - "notifications.column_settings.sound": "শব্দ বাজাতে", + "notifications.column_settings.show": "কলামে দেখানো", + "notifications.column_settings.sound": "শব্দ বাজানো", "notifications.filter.all": "সব", "notifications.filter.boosts": "সমর্থনগুলো", "notifications.filter.favourites": "পছন্দের গুলো", @@ -276,7 +278,7 @@ "notifications.filter.polls": "নির্বাচনের ফলাফল", "notifications.group": "{count} প্রজ্ঞাপন", "poll.closed": "বন্ধ", - "poll.refresh": "আবার সতেজ করতে", + "poll.refresh": "বদলেছে কিনা দেখতে", "poll.total_votes": "{count, plural, one {# ভোট} other {# ভোট}}", "poll.vote": "ভোট", "poll_button.add_poll": "একটা নির্বাচন যোগ করতে", @@ -314,6 +316,7 @@ "search_results.accounts": "মানুষ", "search_results.hashtags": "হ্যাশট্যাগগুলি", "search_results.statuses": "টুট", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {ফলাফল} other {ফলাফল}}", "status.admin_account": "@{name} র জন্য পরিচালনার ইন্টারফেসে ঢুকুন", "status.admin_status": "যায় লেখাটি পরিচালনার ইন্টারফেসে খুলুন", @@ -323,7 +326,7 @@ "status.copy": "লেখাটির লিংক কপি করতে", "status.delete": "মুছে ফেলতে", "status.detailed_status": "বিস্তারিত কথোপকথনের হিসেবে দেখতে", - "status.direct": "@{name} কে সরাসরি পাঠান", + "status.direct": "@{name} কে সরাসরি লেখা পাঠাতে", "status.embed": "এমবেড করতে", "status.favourite": "পছন্দের করতে", "status.filtered": "ছাঁকনিদিত", @@ -344,7 +347,7 @@ "status.redraft": "মুছে আবার নতুন করে লিখতে", "status.reply": "মতামত জানাতে", "status.replyAll": "লেখাযুক্ত সবার কাছে মতামত জানাতে", - "status.report": "@{name}কে রিপোর্ট করতে", + "status.report": "@{name} কে রিপোর্ট করতে", "status.sensitive_warning": "সংবেদনশীল কিছু", "status.share": "অন্যদের জানান", "status.show_less": "কম দেখতে", @@ -354,7 +357,7 @@ "status.show_thread": "আলোচনা দেখতে", "status.unmute_conversation": "আলোচনার প্রজ্ঞাপন চালু করতে", "status.unpin": "নিজের পাতা থেকে পিন করে রাখাটির পিন খুলতে", - "suggestions.dismiss": "সাহায্যের জন্য পরামর্শগুলো সরাতে", + "suggestions.dismiss": "সাহায্যের পরামর্শগুলো সরাতে", "suggestions.header": "আপনি হয়তোবা এগুলোতে আগ্রহী হতে পারেন…", "tabs_bar.federated_timeline": "যুক্তবিশ্ব", "tabs_bar.home": "বাড়ি", @@ -369,7 +372,7 @@ "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} কথা বলছে", "ui.beforeunload": "যে পর্যন্ত এটা লেখা হয়েছে, মাস্টাডন থেকে চলে গেলে এটা মুছে যাবে।", "upload_area.title": "টেনে এখানে ছেড়ে দিলে এখানে যুক্ত করা যাবে", - "upload_button.label": "ছবি বা ভিডিও যুক্ত করতে (এসব ধরণের JPEG, PNG, GIF, WebM, MP4, MOV)", + "upload_button.label": "ছবি বা ভিডিও যুক্ত করতে (এসব ধরণের: JPEG, PNG, GIF, WebM, MP4, MOV)", "upload_error.limit": "যা যুক্ত করতে চাচ্ছেন সেটি বেশি বড়, এখানকার সর্বাধিকের মেমোরির উপরে চলে গেছে।", "upload_error.poll": "নির্বাচনক্ষেত্রে কোনো ফাইল যুক্ত করা যাবেনা।", "upload_form.description": "যারা দেখতে পায়না তাদের জন্য এটা বর্ণনা করতে", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index bb73b2a41..09f8838e9 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Bàsic", "home.column_settings.show_reblogs": "Mostrar impulsos", "home.column_settings.show_replies": "Mostrar respostes", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# dia} other {# dies}}", "intervals.full.hours": "{number, plural, one {# hora} other {# hores}}", "intervals.full.minutes": "{number, plural, one {# minut} other {# minuts}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Nova llista", "lists.search": "Cercar entre les persones que segueixes", "lists.subheading": "Les teves llistes", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Carregant...", "media_gallery.toggle_visible": "Alternar visibilitat", "missing_indicator.label": "No trobat", @@ -314,6 +316,7 @@ "search_results.accounts": "Gent", "search_results.hashtags": "Etiquetes", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}", "status.admin_account": "Obre l'interfície de moderació per a @{name}", "status.admin_status": "Obre aquest toot a la interfície de moderació", diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json index fb8ffdd51..7a1ff863b 100644 --- a/app/javascript/mastodon/locales/co.json +++ b/app/javascript/mastodon/locales/co.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Bàsichi", "home.column_settings.show_reblogs": "Vede e spartere", "home.column_settings.show_replies": "Vede e risposte", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# ghjornu} other {# ghjorni}}", "intervals.full.hours": "{number, plural, one {# ora} other {# ore}}", "intervals.full.minutes": "{number, plural, one {# minuta} other {# minute}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Titulu di a lista", "lists.search": "Circà indè i vostr'abbunamenti", "lists.subheading": "E vo liste", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Caricamentu...", "media_gallery.toggle_visible": "Cambià a visibilità", "missing_indicator.label": "Micca trovu", @@ -314,6 +316,7 @@ "search_results.accounts": "Ghjente", "search_results.hashtags": "Hashtag", "search_results.statuses": "Statuti", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {risultatu} other {risultati}}", "status.admin_account": "Apre l'interfaccia di muderazione per @{name}", "status.admin_status": "Apre stu statutu in l'interfaccia di muderazione", diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json index f10a3f38b..020fd35b0 100644 --- a/app/javascript/mastodon/locales/cs.json +++ b/app/javascript/mastodon/locales/cs.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Základní", "home.column_settings.show_reblogs": "Zobrazit boosty", "home.column_settings.show_replies": "Zobrazit odpovědi", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# den} few {# dny} many {# dne} other {# dní}}", "intervals.full.hours": "{number, plural, one {# hodina} few {# hodiny} many {# hodiny} other {# hodin}}", "intervals.full.minutes": "{number, plural, one {# minuta} few {# minuty} many {# minuty} other {# minut}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Název nového seznamu", "lists.search": "Hledejte mezi lidmi, které sledujete", "lists.subheading": "Vaše seznamy", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Načítám…", "media_gallery.toggle_visible": "Přepínat viditelnost", "missing_indicator.label": "Nenalezeno", @@ -314,6 +316,7 @@ "search_results.accounts": "Lidé", "search_results.hashtags": "Hashtagy", "search_results.statuses": "Tooty", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {výsledek} few {výsledky} many {výsledku} other {výsledků}}", "status.admin_account": "Otevřít moderátorské rozhraní pro uživatele @{name}", "status.admin_status": "Otevřít tento toot v moderátorském rozhraní", diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json index 4ce5d7ad9..9de3efda8 100644 --- a/app/javascript/mastodon/locales/cy.json +++ b/app/javascript/mastodon/locales/cy.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Syml", "home.column_settings.show_reblogs": "Dangos bŵstiau", "home.column_settings.show_replies": "Dangos ymatebion", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# ddydd} other {# o ddyddiau}}", "intervals.full.hours": "{number, plural, one {# awr} other {# o oriau}}", "intervals.full.minutes": "{number, plural, one {# funud} other {# o funudau}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Teitl rhestr newydd", "lists.search": "Chwilio ymysg pobl yr ydych yn ei ddilyn", "lists.subheading": "Eich rhestrau", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Llwytho...", "media_gallery.toggle_visible": "Toglo gwelededd", "missing_indicator.label": "Heb ei ganfod", @@ -314,6 +316,7 @@ "search_results.accounts": "Pobl", "search_results.hashtags": "Hanshnodau", "search_results.statuses": "Tŵtiau", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Agor rhyngwyneb goruwchwylio ar gyfer @{name}", "status.admin_status": "Agor y tŵt yn y rhyngwyneb goruwchwylio", diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json index ba8ba7a28..17080c41e 100644 --- a/app/javascript/mastodon/locales/da.json +++ b/app/javascript/mastodon/locales/da.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Grundlæggende", "home.column_settings.show_reblogs": "Vis fremhævelser", "home.column_settings.show_replies": "Vis svar", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Ny liste titel", "lists.search": "Søg iblandt folk du følger", "lists.subheading": "Dine lister", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Indlæser...", "media_gallery.toggle_visible": "Ændre synlighed", "missing_indicator.label": "Ikke fundet", @@ -314,6 +316,7 @@ "search_results.accounts": "Folk", "search_results.hashtags": "Emnetags", "search_results.statuses": "Trut", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, et {result} andre {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index ac8bc9b9f..4ae785270 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Einfach", "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen", "home.column_settings.show_replies": "Antworten anzeigen", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# Tag} other {# Tage}}", "intervals.full.hours": "{number, plural, one {# Stunde} other {# Stunden}}", "intervals.full.minutes": "{number, plural, one {# Minute} other {# Minuten}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Neuer Titel der Liste", "lists.search": "Suche nach Leuten denen du folgst", "lists.subheading": "Deine Listen", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Wird geladen …", "media_gallery.toggle_visible": "Sichtbarkeit umschalten", "missing_indicator.label": "Nicht gefunden", @@ -314,6 +316,7 @@ "search_results.accounts": "Personen", "search_results.hashtags": "Hashtags", "search_results.statuses": "Beiträge", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}", "status.admin_account": "Öffne Moderationsoberfläche für @{name}", "status.admin_status": "Öffne Beitrag in der Moderationsoberfläche", diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 076aca2b1..8c8c89115 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -161,6 +161,15 @@ { "descriptors": [ { + "defaultMessage": "{count, plural, one {# new item} other {# new items}}", + "id": "load_pending" + } + ], + "path": "app/javascript/mastodon/components/load_pending.json" + }, + { + "descriptors": [ + { "defaultMessage": "Loading...", "id": "loading_indicator.label" } @@ -735,7 +744,7 @@ { "descriptors": [ { - "defaultMessage": "Media Only", + "defaultMessage": "Media only", "id": "community.column_settings.media_only" } ], @@ -1005,6 +1014,10 @@ "id": "search_results.statuses" }, { + "defaultMessage": "Searching toots by their content is not enabled on this Mastodon server.", + "id": "search_results.statuses_fts_disabled" + }, + { "defaultMessage": "Hashtags", "id": "search_results.hashtags" }, @@ -1413,10 +1426,6 @@ { "descriptors": [ { - "defaultMessage": "Basic", - "id": "home.column_settings.basic" - }, - { "defaultMessage": "Show boosts", "id": "home.column_settings.show_reblogs" }, @@ -1798,6 +1807,14 @@ "id": "notifications.column_settings.push" }, { + "defaultMessage": "Basic", + "id": "home.column_settings.basic" + }, + { + "defaultMessage": "Update in real-time", + "id": "home.column_settings.update_live" + }, + { "defaultMessage": "Quick filter bar", "id": "notifications.column_settings.filter_bar.category" }, diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index e118e427b..df85c025f 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Βασικά", "home.column_settings.show_reblogs": "Εμφάνιση προωθήσεων", "home.column_settings.show_replies": "Εμφάνιση απαντήσεων", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# μέρα} other {# μέρες}}", "intervals.full.hours": "{number, plural, one {# ώρα} other {# ώρες}}", "intervals.full.minutes": "{number, plural, one {# λεπτό} other {# λεπτά}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Τίτλος νέας λίστα", "lists.search": "Αναζήτησε μεταξύ των ανθρώπων που ακουλουθείς", "lists.subheading": "Οι λίστες σου", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Φορτώνει...", "media_gallery.toggle_visible": "Εναλλαγή ορατότητας", "missing_indicator.label": "Δε βρέθηκε", @@ -314,6 +316,7 @@ "search_results.accounts": "Άνθρωποι", "search_results.hashtags": "Ταμπέλες", "search_results.statuses": "Τουτ", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, zero {αποτελέσματα} one {αποτέλεσμα} other {αποτελέσματα}}", "status.admin_account": "Άνοιγμα λειτουργίας διαμεσολάβησης για τον/την @{name}", "status.admin_status": "Άνοιγμα αυτής της δημοσίευσης στη λειτουργία διαμεσολάβησης", diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index a75c41799..7bed98530 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -160,6 +160,7 @@ "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -225,6 +226,7 @@ "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Loading...", "media_gallery.toggle_visible": "Toggle visibility", "missing_indicator.label": "Not found", @@ -319,6 +321,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 897cb6353..ddc694252 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Bazaj agordoj", "home.column_settings.show_reblogs": "Montri diskonigojn", "home.column_settings.show_replies": "Montri respondojn", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# tago} other {# tagoj}}", "intervals.full.hours": "{number, plural, one {# horo} other {# horoj}}", "intervals.full.minutes": "{number, plural, one {# minuto} other {# minutoj}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Titolo de la nova listo", "lists.search": "Serĉi inter la homoj, kiujn vi sekvas", "lists.subheading": "Viaj listoj", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Ŝargado…", "media_gallery.toggle_visible": "Baskuligi videblecon", "missing_indicator.label": "Ne trovita", @@ -314,6 +316,7 @@ "search_results.accounts": "Homoj", "search_results.hashtags": "Kradvortoj", "search_results.statuses": "Mesaĝoj", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezultoj}}", "status.admin_account": "Malfermi la kontrolan interfacon por @{name}", "status.admin_status": "Malfermi ĉi tiun mesaĝon en la kontrola interfaco", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index 8fe50ace5..dc42bc7ef 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -98,7 +98,7 @@ "confirmations.redraft.confirm": "Borrar y volver a borrador", "confirmations.redraft.message": "Estás seguro de que quieres borrar este estado y volverlo a borrador? Perderás todas las respuestas, impulsos y favoritos asociados a él, y las respuestas a la publicación original quedarán huérfanos.", "confirmations.reply.confirm": "Responder", - "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?", + "confirmations.reply.message": "Responder sobrescribirá el mensaje que estás escribiendo. ¿Estás seguro de que deseas continuar?", "confirmations.unfollow.confirm": "Dejar de seguir", "confirmations.unfollow.message": "¿Estás seguro de que quieres dejar de seguir a {name}?", "embed.instructions": "Añade este toot a tu sitio web con el siguiente código.", @@ -149,33 +149,34 @@ "hashtag.column_header.tag_mode.none": "sin {additional}", "hashtag.column_settings.select.no_options_message": "No se encontraron sugerencias", "hashtag.column_settings.select.placeholder": "Introduzca hashtags…", - "hashtag.column_settings.tag_mode.all": "All of these", + "hashtag.column_settings.tag_mode.all": "Cualquiera de estos", "hashtag.column_settings.tag_mode.any": "Cualquiera de estos", "hashtag.column_settings.tag_mode.none": "Ninguno de estos", "hashtag.column_settings.tag_toggle": "Include additional tags in this column", "home.column_settings.basic": "Básico", "home.column_settings.show_reblogs": "Mostrar retoots", "home.column_settings.show_replies": "Mostrar respuestas", - "intervals.full.days": "{number, plural, one {# day} other {# days}}", - "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", - "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", + "home.column_settings.update_live": "Update in real-time", + "intervals.full.days": "{number, plural, one {# día} other {# días}}", + "intervals.full.hours": "{number, plural, one {# hora} other {# horas}}", + "intervals.full.minutes": "{number, plural, one {# minuto} other {# minutos}}", "introduction.federation.action": "Siguiente", "introduction.federation.federated.headline": "Federado", - "introduction.federation.federated.text": "Public posts from other servers of the fediverse will appear in the federated timeline.", + "introduction.federation.federated.text": "Los mensajes públicos de otros servidores del fediverso aparecerán en la cronología federada.", "introduction.federation.home.headline": "Inicio", - "introduction.federation.home.text": "Posts from people you follow will appear in your home feed. You can follow anyone on any server!", + "introduction.federation.home.text": "Los posts de personas que sigues aparecerán en tu cronología. ¡Puedes seguir a cualquiera en cualquier servidor!", "introduction.federation.local.headline": "Local", - "introduction.federation.local.text": "Public posts from people on the same server as you will appear in the local timeline.", + "introduction.federation.local.text": "Los posts públicos de personas en el mismo servidor que aparecerán en la cronología local.", "introduction.interactions.action": "¡Terminar tutorial!", "introduction.interactions.favourite.headline": "Favorito", - "introduction.interactions.favourite.text": "You can save a toot for later, and let the author know that you liked it, by favouriting it.", - "introduction.interactions.reblog.headline": "Boost", - "introduction.interactions.reblog.text": "You can share other people's toots with your followers by boosting them.", + "introduction.interactions.favourite.text": "Puedes guardar un toot para más tarde, y hacer saber al autor que te gustó, dándole a favorito.", + "introduction.interactions.reblog.headline": "Retootear", + "introduction.interactions.reblog.text": "Puedes compartir los toots de otras personas con tus seguidores retooteando los mismos.", "introduction.interactions.reply.headline": "Responder", - "introduction.interactions.reply.text": "You can reply to other people's and your own toots, which will chain them together in a conversation.", + "introduction.interactions.reply.text": "Puedes responder a tus propios toots y los de otras personas, que se encadenarán juntos en una conversación.", "introduction.welcome.action": "¡Vamos!", "introduction.welcome.headline": "Primeros pasos", - "introduction.welcome.text": "Welcome to the fediverse! In a few moments, you'll be able to broadcast messages and talk to your friends across a wide variety of servers. But this server, {domain}, is special—it hosts your profile, so remember its name.", + "introduction.welcome.text": "¡Bienvenido al fediverso! En unos momentos, podrás transmitir mensajes y hablar con tus amigos a través de una amplia variedad de servidores. Pero este servidor, {domain}, es especial, alberga tu perfil, así que recuerda su nombre.", "keyboard_shortcuts.back": "volver atrás", "keyboard_shortcuts.blocked": "abrir una lista de usuarios bloqueados", "keyboard_shortcuts.boost": "retootear", @@ -184,7 +185,7 @@ "keyboard_shortcuts.description": "Descripción", "keyboard_shortcuts.direct": "abrir la columna de mensajes directos", "keyboard_shortcuts.down": "mover hacia abajo en la lista", - "keyboard_shortcuts.enter": "to open status", + "keyboard_shortcuts.enter": "abrir estado", "keyboard_shortcuts.favourite": "añadir a favoritos", "keyboard_shortcuts.favourites": "abrir la lista de favoritos", "keyboard_shortcuts.federated": "abrir el timeline federado", @@ -204,7 +205,7 @@ "keyboard_shortcuts.search": "para poner el foco en la búsqueda", "keyboard_shortcuts.start": "abrir la columna \"comenzar\"", "keyboard_shortcuts.toggle_hidden": "mostrar/ocultar texto tras aviso de contenido (CW)", - "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", + "keyboard_shortcuts.toggle_sensitivity": "mostrar/ocultar medios", "keyboard_shortcuts.toot": "para comenzar un nuevo toot", "keyboard_shortcuts.unfocus": "para retirar el foco de la caja de redacción/búsqueda", "keyboard_shortcuts.up": "para ir hacia arriba en la lista", @@ -216,11 +217,12 @@ "lists.account.remove": "Quitar de lista", "lists.delete": "Borrar lista", "lists.edit": "Editar lista", - "lists.edit.submit": "Change title", + "lists.edit.submit": "Cambiar título", "lists.new.create": "Añadir lista", "lists.new.title_placeholder": "Título de la nueva lista", "lists.search": "Buscar entre la gente a la que sigues", "lists.subheading": "Tus listas", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Cargando…", "media_gallery.toggle_visible": "Cambiar visibilidad", "missing_indicator.label": "No encontrado", @@ -237,7 +239,7 @@ "navigation_bar.favourites": "Favoritos", "navigation_bar.filters": "Palabras silenciadas", "navigation_bar.follow_requests": "Solicitudes para seguirte", - "navigation_bar.follows_and_followers": "Follows and followers", + "navigation_bar.follows_and_followers": "Siguiendo y seguidores", "navigation_bar.info": "Información adicional", "navigation_bar.keyboard_shortcuts": "Atajos", "navigation_bar.lists": "Listas", @@ -246,41 +248,41 @@ "navigation_bar.personal": "Personal", "navigation_bar.pins": "Toots fijados", "navigation_bar.preferences": "Preferencias", - "navigation_bar.profile_directory": "Profile directory", + "navigation_bar.profile_directory": "Directorio de perfiles", "navigation_bar.public_timeline": "Historia federada", "navigation_bar.security": "Seguridad", "notification.favourite": "{name} marcó tu estado como favorito", "notification.follow": "{name} te empezó a seguir", "notification.mention": "{name} te ha mencionado", - "notification.poll": "A poll you have voted in has ended", + "notification.poll": "Una encuesta en la que has votado ha terminado", "notification.reblog": "{name} ha retooteado tu estado", "notifications.clear": "Limpiar notificaciones", "notifications.clear_confirmation": "¿Seguro que quieres limpiar permanentemente todas tus notificaciones?", "notifications.column_settings.alert": "Notificaciones de escritorio", "notifications.column_settings.favourite": "Favoritos:", - "notifications.column_settings.filter_bar.advanced": "Display all categories", - "notifications.column_settings.filter_bar.category": "Quick filter bar", - "notifications.column_settings.filter_bar.show": "Show", + "notifications.column_settings.filter_bar.advanced": "Mostrar todas las categorías", + "notifications.column_settings.filter_bar.category": "Barra de filtrado rápido", + "notifications.column_settings.filter_bar.show": "Mostrar", "notifications.column_settings.follow": "Nuevos seguidores:", "notifications.column_settings.mention": "Menciones:", - "notifications.column_settings.poll": "Poll results:", + "notifications.column_settings.poll": "Resultados de la votación:", "notifications.column_settings.push": "Notificaciones push", "notifications.column_settings.reblog": "Retoots:", "notifications.column_settings.show": "Mostrar en columna", "notifications.column_settings.sound": "Reproducir sonido", - "notifications.filter.all": "All", - "notifications.filter.boosts": "Boosts", - "notifications.filter.favourites": "Favourites", - "notifications.filter.follows": "Follows", - "notifications.filter.mentions": "Mentions", - "notifications.filter.polls": "Poll results", + "notifications.filter.all": "Todos", + "notifications.filter.boosts": "Retoots", + "notifications.filter.favourites": "Favoritos", + "notifications.filter.follows": "Seguidores", + "notifications.filter.mentions": "Menciones", + "notifications.filter.polls": "Resultados de la votación", "notifications.group": "{count} notificaciones", - "poll.closed": "Closed", - "poll.refresh": "Refresh", - "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", - "poll.vote": "Vote", - "poll_button.add_poll": "Add a poll", - "poll_button.remove_poll": "Remove poll", + "poll.closed": "Cerrada", + "poll.refresh": "Actualizar", + "poll.total_votes": "{count, plural, one {# voto} other {# votos}}", + "poll.vote": "Votar", + "poll_button.add_poll": "Añadir una encuesta", + "poll_button.remove_poll": "Eliminar encuesta", "privacy.change": "Ajustar privacidad", "privacy.direct.long": "Sólo mostrar a los usuarios mencionados", "privacy.direct.short": "Directo", @@ -289,7 +291,7 @@ "privacy.public.long": "Mostrar en la historia federada", "privacy.public.short": "Público", "privacy.unlisted.long": "No mostrar en la historia federada", - "privacy.unlisted.short": "Sin federar", + "privacy.unlisted.short": "No listado", "regeneration_indicator.label": "Cargando…", "regeneration_indicator.sublabel": "¡Tu historia de inicio se está preparando!", "relative_time.days": "{number}d", @@ -308,19 +310,20 @@ "search_popout.search_format": "Formato de búsqueda avanzada", "search_popout.tips.full_text": "Búsquedas de texto recuperan posts que has escrito, marcado como favoritos, retooteado o en los que has sido mencionado, así como usuarios, nombres y hashtags.", "search_popout.tips.hashtag": "etiqueta", - "search_popout.tips.status": "status", + "search_popout.tips.status": "estado", "search_popout.tips.text": "El texto simple devuelve correspondencias de nombre, usuario y hashtag", "search_popout.tips.user": "usuario", "search_results.accounts": "Gente", "search_results.hashtags": "Etiquetas", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", - "status.admin_account": "Open moderation interface for @{name}", - "status.admin_status": "Open this status in the moderation interface", - "status.block": "Block @{name}", + "status.admin_account": "Abrir interfaz de moderación para @{name}", + "status.admin_status": "Abrir este estado en la interfaz de moderación", + "status.block": "Bloquear a @{name}", "status.cancel_reblog_private": "Des-impulsar", "status.cannot_reblog": "Este toot no puede retootearse", - "status.copy": "Copy link to status", + "status.copy": "Copiar enlace al estado", "status.delete": "Borrar", "status.detailed_status": "Vista de conversación detallada", "status.direct": "Mensaje directo a @{name}", @@ -336,7 +339,7 @@ "status.open": "Expandir estado", "status.pin": "Fijar", "status.pinned": "Toot fijado", - "status.read_more": "Read more", + "status.read_more": "Leer más", "status.reblog": "Retootear", "status.reblog_private": "Implusar a la audiencia original", "status.reblogged_by": "Retooteado por {name}", @@ -351,27 +354,27 @@ "status.show_less_all": "Mostrar menos para todo", "status.show_more": "Mostrar más", "status.show_more_all": "Mostrar más para todo", - "status.show_thread": "Show thread", + "status.show_thread": "Ver hilo", "status.unmute_conversation": "Dejar de silenciar conversación", "status.unpin": "Dejar de fijar", - "suggestions.dismiss": "Dismiss suggestion", - "suggestions.header": "You might be interested in…", + "suggestions.dismiss": "Descartar sugerencia", + "suggestions.header": "Es posible que te interese…", "tabs_bar.federated_timeline": "Federado", "tabs_bar.home": "Inicio", "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notificaciones", "tabs_bar.search": "Buscar", - "time_remaining.days": "{number, plural, one {# day} other {# days}} left", - "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", - "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", - "time_remaining.moments": "Moments remaining", - "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", - "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", + "time_remaining.days": "{number, plural, one {# día restante} other {# días restantes}}", + "time_remaining.hours": "{number, plural, one {# hora restante} other {# horas restantes}}", + "time_remaining.minutes": "{number, plural, one {# minuto restante} other {# minutos restantes}}", + "time_remaining.moments": "Momentos restantes", + "time_remaining.seconds": "{number, plural, one {# segundo restante} other {# segundos restantes}}", + "trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {personas}} hablando", "ui.beforeunload": "Tu borrador se perderá si sales de Mastodon.", "upload_area.title": "Arrastra y suelta para subir", "upload_button.label": "Subir multimedia (JPEG, PNG, GIF, WebM, MP4, MOV)", - "upload_error.limit": "File upload limit exceeded.", - "upload_error.poll": "File upload not allowed with polls.", + "upload_error.limit": "Límite de subida de archivos excedido.", + "upload_error.poll": "Subida de archivos no permitida con encuestas.", "upload_form.description": "Describir para los usuarios con dificultad visual", "upload_form.focus": "Recortar", "upload_form.undo": "Borrar", diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json index 3e91012b3..0c078840a 100644 --- a/app/javascript/mastodon/locales/eu.json +++ b/app/javascript/mastodon/locales/eu.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Oinarrizkoa", "home.column_settings.show_reblogs": "Erakutsi bultzadak", "home.column_settings.show_replies": "Erakutsi erantzunak", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {egun #} other {# egun}}", "intervals.full.hours": "{number, plural, one {ordu #} other {# ordu}}", "intervals.full.minutes": "{number, plural, one {minutu #} other {# minutu}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Zerrenda berriaren izena", "lists.search": "Bilatu jarraitzen dituzun pertsonen artean", "lists.subheading": "Zure zerrendak", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Kargatzen...", "media_gallery.toggle_visible": "Txandakatu ikusgaitasuna", "missing_indicator.label": "Ez aurkitua", @@ -314,6 +316,7 @@ "search_results.accounts": "Jendea", "search_results.hashtags": "Traolak", "search_results.statuses": "Toot-ak", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {emaitza} other {emaitzak}}", "status.admin_account": "Ireki @{name} erabiltzailearen moderazio interfazea", "status.admin_status": "Ireki mezu hau moderazio interfazean", diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index 68d231ce9..41143bcc8 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "اصلی", "home.column_settings.show_reblogs": "نمایش بازبوقها", "home.column_settings.show_replies": "نمایش پاسخها", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# روز} other {# روز}}", "intervals.full.hours": "{number, plural, one {# ساعت} other {# ساعت}}", "intervals.full.minutes": "{number, plural, one {# دقیقه} other {# دقیقه}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "نام فهرست تازه", "lists.search": "بین کسانی که پی میگیرید بگردید", "lists.subheading": "فهرستهای شما", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "بارگیری...", "media_gallery.toggle_visible": "تغییر پیدایی", "missing_indicator.label": "پیدا نشد", @@ -314,6 +316,7 @@ "search_results.accounts": "افراد", "search_results.hashtags": "هشتگها", "search_results.statuses": "بوقها", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}", "status.admin_account": "محیط مدیریت مربوط به @{name} را باز کن", "status.admin_status": "این نوشته را در محیط مدیریت باز کن", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 342a15bfb..05495d5d7 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Perusasetukset", "home.column_settings.show_reblogs": "Näytä buustaukset", "home.column_settings.show_replies": "Näytä vastaukset", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "Päivä päiviä", "intervals.full.hours": "Tunti tunteja", "intervals.full.minutes": "Minuuti minuuteja", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Uuden listan nimi", "lists.search": "Etsi seuraamistasi henkilöistä", "lists.subheading": "Omat listat", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Ladataan...", "media_gallery.toggle_visible": "Säädä näkyvyyttä", "missing_indicator.label": "Ei löytynyt", @@ -314,6 +316,7 @@ "search_results.accounts": "Ihmiset", "search_results.hashtags": "Hashtagit", "search_results.statuses": "Tuuttaukset", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 06bb70e02..f4db2e7a1 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Basique", "home.column_settings.show_reblogs": "Afficher les partages", "home.column_settings.show_replies": "Afficher les réponses", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# jour} other {# jours}}", "intervals.full.hours": "{number, plural, one {# heure} other {# heures}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Titre de la nouvelle liste", "lists.search": "Rechercher parmi les gens que vous suivez", "lists.subheading": "Vos listes", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Chargement…", "media_gallery.toggle_visible": "Modifier la visibilité", "missing_indicator.label": "Non trouvé", @@ -314,6 +316,7 @@ "search_results.accounts": "Comptes", "search_results.hashtags": "Hashtags", "search_results.statuses": "Pouets", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}", "status.admin_account": "Ouvrir l'interface de modération pour @{name}", "status.admin_status": "Ouvrir ce statut dans l'interface de modération", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index 9b19d6f11..2605f61f8 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Básico", "home.column_settings.show_reblogs": "Mostrar repeticións", "home.column_settings.show_replies": "Mostrar respostas", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural,one {# día} other {# días}}", "intervals.full.hours": "{number, plural, one {# hora} other {# horas}}", "intervals.full.minutes": "{number, plural, one {# minuto} other {# minutos}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Novo título da lista", "lists.search": "Procurar entre a xente que segues", "lists.subheading": "As túas listas", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Cargando...", "media_gallery.toggle_visible": "Ocultar", "missing_indicator.label": "Non atopado", @@ -314,6 +316,7 @@ "search_results.accounts": "Xente", "search_results.hashtags": "Etiquetas", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count,plural,one {result} outros {results}}", "status.admin_account": "Abrir interface de moderación para @{name}", "status.admin_status": "Abrir este estado na interface de moderación", @@ -369,7 +372,7 @@ "trends.count_by_accounts": "{count} {rawCount, plural, one {person} outras {people}} conversando", "ui.beforeunload": "O borrador perderase se sae de Mastodon.", "upload_area.title": "Arrastre e solte para subir", - "upload_button.label": "Engadir medios (JPEG, PNG, GIF, WebM, MP4, MOV)", + "upload_button.label": "Engadir medios ({formats})", "upload_error.limit": "Excedeu o límite de subida de ficheiros.", "upload_error.poll": "Non se poden subir ficheiros nas sondaxes.", "upload_form.description": "Describa para deficientes visuais", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index 248be3c7b..99bb87a5f 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -64,7 +64,7 @@ "column_header.show_settings": "הצגת העדפות", "column_header.unpin": "שחרור קיבוע", "column_subheading.settings": "אפשרויות", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Media only", "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", @@ -156,6 +156,7 @@ "home.column_settings.basic": "למתחילים", "home.column_settings.show_reblogs": "הצגת הדהודים", "home.column_settings.show_replies": "הצגת תגובות", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "טוען...", "media_gallery.toggle_visible": "נראה\\בלתי נראה", "missing_indicator.label": "לא נמצא", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json index ac58514d4..d4d9e5f64 100644 --- a/app/javascript/mastodon/locales/hi.json +++ b/app/javascript/mastodon/locales/hi.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Loading...", "media_gallery.toggle_visible": "Toggle visibility", "missing_indicator.label": "Not found", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json index 6f9b5343a..273b70d07 100644 --- a/app/javascript/mastodon/locales/hr.json +++ b/app/javascript/mastodon/locales/hr.json @@ -64,7 +64,7 @@ "column_header.show_settings": "Show settings", "column_header.unpin": "Unpin", "column_subheading.settings": "Postavke", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Media only", "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Osnovno", "home.column_settings.show_reblogs": "Pokaži boostove", "home.column_settings.show_replies": "Pokaži odgovore", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Učitavam...", "media_gallery.toggle_visible": "Preklopi vidljivost", "missing_indicator.label": "Nije nađen", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 1c3b63d7d..38d30efe4 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Alapértelmezések", "home.column_settings.show_reblogs": "Megtolások mutatása", "home.column_settings.show_replies": "Válaszok mutatása", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# nap} other {# nap}}", "intervals.full.hours": "{number, plural, one {# óra} other {# óra}}", "intervals.full.minutes": "{number, plural, one {# perc} other {# perc}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Új lista címe", "lists.search": "Keresés a követett személyek között", "lists.subheading": "Listáid", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Betöltés...", "media_gallery.toggle_visible": "Láthatóság állítása", "missing_indicator.label": "Nincs találat", @@ -314,6 +316,7 @@ "search_results.accounts": "Emberek", "search_results.hashtags": "Hashtagek", "search_results.statuses": "Tülkök", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {találat} other {találat}}", "status.admin_account": "Moderáció megnyitása @{name} felhasználóhoz", "status.admin_status": "Tülk megnyitása moderációra", diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json index b2dc16a48..801d34380 100644 --- a/app/javascript/mastodon/locales/hy.json +++ b/app/javascript/mastodon/locales/hy.json @@ -64,7 +64,7 @@ "column_header.show_settings": "Ցուցադրել կարգավորումները", "column_header.unpin": "Հանել", "column_subheading.settings": "Կարգավորումներ", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Media only", "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "Այս թութը չի հաշվառվի որեւէ պիտակի տակ, քանզի այն ծածուկ է։ Միայն հրապարակային թթերը հնարավոր է որոնել պիտակներով։", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Հիմնական", "home.column_settings.show_reblogs": "Ցուցադրել տարածածները", "home.column_settings.show_replies": "Ցուցադրել պատասխանները", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Նոր ցանկի վերնագիր", "lists.search": "Փնտրել քո հետեւած մարդկանց մեջ", "lists.subheading": "Քո ցանկերը", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Բեռնվում է…", "media_gallery.toggle_visible": "Ցուցադրել/թաքցնել", "missing_indicator.label": "Չգտնվեց", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {արդյունք} other {արդյունք}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json index 07ce0eb98..daa87f955 100644 --- a/app/javascript/mastodon/locales/id.json +++ b/app/javascript/mastodon/locales/id.json @@ -1,5 +1,5 @@ { - "account.add_or_remove_from_list": "Add or Remove from lists", + "account.add_or_remove_from_list": "Tambah atau Hapus dari daftar", "account.badges.bot": "Bot", "account.block": "Blokir @{name}", "account.block_domain": "Sembunyikan segalanya dari {domain}", @@ -7,23 +7,23 @@ "account.direct": "Direct Message @{name}", "account.domain_blocked": "Domain disembunyikan", "account.edit_profile": "Ubah profil", - "account.endorse": "Feature on profile", + "account.endorse": "Tampilkan di profil", "account.follow": "Ikuti", "account.followers": "Pengikut", - "account.followers.empty": "No one follows this user yet.", + "account.followers.empty": "Tidak ada satupun yang mengkuti pengguna ini saat ini.", "account.follows": "Mengikuti", - "account.follows.empty": "This user doesn't follow anyone yet.", + "account.follows.empty": "Pengguna ini belum mengikuti siapapun.", "account.follows_you": "Mengikuti anda", "account.hide_reblogs": "Sembunyikan boosts dari @{name}", - "account.link_verified_on": "Ownership of this link was checked on {date}", - "account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.", + "account.link_verified_on": "Kepemilikan tautan ini telah dicek pada {date}", + "account.locked_info": "Status privasi akun ini disetel untuk dikunci. Pemilik secara manual meninjau siapa yang dapat mengikuti mereka.", "account.media": "Media", "account.mention": "Balasan @{name}", "account.moved_to": "{name} telah pindah ke:", "account.mute": "Bisukan @{name}", "account.mute_notifications": "Sembunyikan notifikasi dari @{name}", "account.muted": "Dibisukan", - "account.posts": "Toots", + "account.posts": "Toot", "account.posts_with_replies": "Postingan dengan balasan", "account.report": "Laporkan @{name}", "account.requested": "Menunggu persetujuan. Klik untuk membatalkan permintaan", @@ -31,23 +31,23 @@ "account.show_reblogs": "Tampilkan boost dari @{name}", "account.unblock": "Hapus blokir @{name}", "account.unblock_domain": "Tampilkan {domain}", - "account.unendorse": "Don't feature on profile", + "account.unendorse": "Jangan tampilkan di profil", "account.unfollow": "Berhenti mengikuti", "account.unmute": "Berhenti membisukan @{name}", "account.unmute_notifications": "Munculkan notifikasi dari @{name}", - "alert.unexpected.message": "An unexpected error occurred.", + "alert.unexpected.message": "Terjadi kesalahan yang tidak terduga.", "alert.unexpected.title": "Oops!", "boost_modal.combo": "Anda dapat menekan {combo} untuk melewati ini", "bundle_column_error.body": "Kesalahan terjadi saat memuat komponen ini.", "bundle_column_error.retry": "Coba lagi", - "bundle_column_error.title": "Network error", + "bundle_column_error.title": "Kesalahan jaringan", "bundle_modal_error.close": "Tutup", "bundle_modal_error.message": "Kesalahan terjadi saat memuat komponen ini.", "bundle_modal_error.retry": "Coba lagi", "column.blocks": "Pengguna diblokir", "column.community": "Linimasa Lokal", - "column.direct": "Direct messages", - "column.domain_blocks": "Hidden domains", + "column.direct": "Pesan langsung", + "column.domain_blocks": "Topik tersembunyi", "column.favourites": "Favorit", "column.follow_requests": "Permintaan mengikuti", "column.home": "Beranda", @@ -64,41 +64,41 @@ "column_header.show_settings": "Tampilkan pengaturan", "column_header.unpin": "Lepaskan", "column_subheading.settings": "Pengaturan", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Hanya media", "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.", - "compose_form.direct_message_warning_learn_more": "Learn more", + "compose_form.direct_message_warning_learn_more": "Pelajari selengkapnya", "compose_form.hashtag_warning": "Toot ini tidak akan ada dalam daftar tagar manapun karena telah di set sebagai tidak terdaftar. Hanya postingan publik yang bisa dicari dengan tagar.", "compose_form.lock_disclaimer": "Akun anda tidak {locked}. Semua orang dapat mengikuti anda untuk melihat postingan khusus untuk pengikut anda.", "compose_form.lock_disclaimer.lock": "terkunci", "compose_form.placeholder": "Apa yang ada di pikiran anda?", - "compose_form.poll.add_option": "Add a choice", - "compose_form.poll.duration": "Poll duration", - "compose_form.poll.option_placeholder": "Choice {number}", - "compose_form.poll.remove_option": "Remove this choice", + "compose_form.poll.add_option": "Tambahkan pilihan", + "compose_form.poll.duration": "Durasi jajak pendapat", + "compose_form.poll.option_placeholder": "Pilihan {number}", + "compose_form.poll.remove_option": "Hapus opsi ini", "compose_form.publish": "Toot", "compose_form.publish_loud": "{publish}!", - "compose_form.sensitive.hide": "Mark media as sensitive", + "compose_form.sensitive.hide": "Tandai sebagai media sensitif", "compose_form.sensitive.marked": "Sumber ini telah ditandai sebagai sumber sensitif.", "compose_form.sensitive.unmarked": "Sumber ini tidak ditandai sebagai sumber sensitif", "compose_form.spoiler.marked": "Teks disembunyikan dibalik peringatan", "compose_form.spoiler.unmarked": "Teks tidak tersembunyi", "compose_form.spoiler_placeholder": "Peringatan konten", "confirmation_modal.cancel": "Batal", - "confirmations.block.block_and_report": "Block & Report", + "confirmations.block.block_and_report": "Blokir & Laporkan", "confirmations.block.confirm": "Blokir", "confirmations.block.message": "Apa anda yakin ingin memblokir {name}?", "confirmations.delete.confirm": "Hapus", "confirmations.delete.message": "Apa anda yakin untuk menghapus status ini?", - "confirmations.delete_list.confirm": "Delete", + "confirmations.delete_list.confirm": "Hapus", "confirmations.delete_list.message": "Apakah anda yakin untuk menghapus daftar ini secara permanen?", "confirmations.domain_block.confirm": "Sembunyikan keseluruhan domain", "confirmations.domain_block.message": "Apakah anda benar benar yakin untuk memblokir keseluruhan {domain}? Dalam kasus tertentu beberapa pemblokiran atau penyembunyian lebih baik.", "confirmations.mute.confirm": "Bisukan", "confirmations.mute.message": "Apa anda yakin ingin membisukan {name}?", - "confirmations.redraft.confirm": "Delete & redraft", + "confirmations.redraft.confirm": "Hapus dan konsep ulang", "confirmations.redraft.message": "Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.", - "confirmations.reply.confirm": "Reply", - "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?", + "confirmations.reply.confirm": "Balas", + "confirmations.reply.message": "Membalas sekarang akan menimpa pesan yang sedang Anda buat. Anda yakin ingin melanjutkan?", "confirmations.unfollow.confirm": "Berhenti mengikuti", "confirmations.unfollow.message": "Apakah anda ingin berhenti mengikuti {name}?", "embed.instructions": "Sematkan status ini di website anda dengan menyalin kode di bawah ini.", @@ -117,38 +117,38 @@ "emoji_button.search_results": "Hasil pencarian", "emoji_button.symbols": "Simbol", "emoji_button.travel": "Tempat Wisata", - "empty_column.account_timeline": "No toots here!", - "empty_column.account_unavailable": "Profile unavailable", - "empty_column.blocks": "You haven't blocked any users yet.", + "empty_column.account_timeline": "Tidak ada toot di sini!", + "empty_column.account_unavailable": "Profil tidak tersedia", + "empty_column.blocks": "Anda belum memblokir siapapun.", "empty_column.community": "Linimasa lokal masih kosong. Tulis sesuatu secara publik dan buat roda berputar!", - "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.", - "empty_column.domain_blocks": "There are no hidden domains yet.", - "empty_column.favourited_statuses": "You don't have any favourite toots yet. When you favourite one, it will show up here.", - "empty_column.favourites": "No one has favourited this toot yet. When someone does, they will show up here.", - "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", + "empty_column.direct": "Anda belum memiliki pesan langsung. Ketika Anda mengirim atau menerimanya, maka akan muncul di sini.", + "empty_column.domain_blocks": "Tidak ada topik tersembunyi.", + "empty_column.favourited_statuses": "Anda belum memiliki toot favorit. Ketika Anda mengirim atau menerimanya, maka akan muncul di sini.", + "empty_column.favourites": "Tidak ada seorangpun yang memfavoritkan toot ini. Ketika seseorang melakukannya, maka akan muncul disini.", + "empty_column.follow_requests": "Anda belum memiliki permintaan mengikuti. Ketika Anda menerimanya, maka akan muncul disini.", "empty_column.hashtag": "Tidak ada apapun dalam hashtag ini.", "empty_column.home": "Linimasa anda kosong! Kunjungi {public} atau gunakan pencarian untuk memulai dan bertemu pengguna lain.", "empty_column.home.public_timeline": "linimasa publik", "empty_column.list": "Tidak ada postingan di list ini. Ketika anggota dari list ini memposting status baru, status tersebut akan tampil disini.", - "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", - "empty_column.mutes": "You haven't muted any users yet.", + "empty_column.lists": "Anda belum memiliki daftar. Ketika Anda membuatnya, maka akan muncul disini.", + "empty_column.mutes": "Anda belum membisukan siapapun.", "empty_column.notifications": "Anda tidak memiliki notifikasi apapun. Berinteraksi dengan orang lain untuk memulai percakapan.", "empty_column.public": "Tidak ada apapun disini! Tulis sesuatu, atau ikuti pengguna lain dari server lain untuk mengisi ini", "follow_request.authorize": "Izinkan", "follow_request.reject": "Tolak", - "getting_started.developers": "Developers", - "getting_started.directory": "Profile directory", - "getting_started.documentation": "Documentation", + "getting_started.developers": "Pengembang", + "getting_started.directory": "Direktori profil", + "getting_started.documentation": "Dokumentasi", "getting_started.heading": "Mulai", - "getting_started.invite": "Invite people", + "getting_started.invite": "Undang orang", "getting_started.open_source_notice": "Mastodon adalah perangkat lunak yang bersifat terbuka. Anda dapat berkontribusi atau melaporkan permasalahan/bug di Github {github}.", - "getting_started.security": "Security", - "getting_started.terms": "Terms of service", - "hashtag.column_header.tag_mode.all": "and {additional}", - "hashtag.column_header.tag_mode.any": "or {additional}", - "hashtag.column_header.tag_mode.none": "without {additional}", - "hashtag.column_settings.select.no_options_message": "No suggestions found", - "hashtag.column_settings.select.placeholder": "Enter hashtags…", + "getting_started.security": "Keamanan", + "getting_started.terms": "Ketentuan layanan", + "hashtag.column_header.tag_mode.all": "dan {additional}", + "hashtag.column_header.tag_mode.any": "atau {additional}", + "hashtag.column_header.tag_mode.none": "tanpa {additional}", + "hashtag.column_settings.select.no_options_message": "Tidak ada saran yang ditemukan", + "hashtag.column_settings.select.placeholder": "Masukkan tagar…", "hashtag.column_settings.tag_mode.all": "All of these", "hashtag.column_settings.tag_mode.any": "Any of these", "hashtag.column_settings.tag_mode.none": "None of these", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Dasar", "home.column_settings.show_reblogs": "Tampilkan boost", "home.column_settings.show_replies": "Tampilkan balasan", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Tunggu sebentar...", "media_gallery.toggle_visible": "Tampil/Sembunyikan", "missing_indicator.label": "Tidak ditemukan", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {hasil} other {hasil}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json index c3f8707d1..864d49995 100644 --- a/app/javascript/mastodon/locales/io.json +++ b/app/javascript/mastodon/locales/io.json @@ -64,7 +64,7 @@ "column_header.show_settings": "Show settings", "column_header.unpin": "Unpin", "column_subheading.settings": "Settings", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Media only", "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Simpla", "home.column_settings.show_reblogs": "Montrar repeti", "home.column_settings.show_replies": "Montrar respondi", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Kargante...", "media_gallery.toggle_visible": "Chanjar videbleso", "missing_indicator.label": "Ne trovita", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index f7e2e4353..7925cef8c 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -4,7 +4,7 @@ "account.block": "Blocca @{name}", "account.block_domain": "Nascondi tutto da {domain}", "account.blocked": "Bloccato", - "account.direct": "Invia messaggio diretto a @{name}", + "account.direct": "Invia messaggio privato a @{name}", "account.domain_blocked": "Dominio nascosto", "account.edit_profile": "Modifica profilo", "account.endorse": "Metti in evidenza sul profilo", @@ -121,7 +121,7 @@ "empty_column.account_unavailable": "Profilo non disponibile", "empty_column.blocks": "Non hai ancora bloccato nessun utente.", "empty_column.community": "La timeline locale è vuota. Condividi qualcosa pubblicamente per dare inizio alla festa!", - "empty_column.direct": "Non hai ancora nessun messaggio diretto. Quando ne manderai o riceverai qualcuno, apparirà qui.", + "empty_column.direct": "Non hai ancora nessun messaggio privato. Quando ne manderai o riceverai qualcuno, apparirà qui.", "empty_column.domain_blocks": "Non vi sono domini nascosti.", "empty_column.favourited_statuses": "Non hai ancora segnato nessun toot come apprezzato. Quando lo farai, comparirà qui.", "empty_column.favourites": "Nessuno ha ancora segnato questo toot come apprezzato. Quando qualcuno lo farà, apparirà qui.", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Semplice", "home.column_settings.show_reblogs": "Mostra post condivisi", "home.column_settings.show_replies": "Mostra risposte", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# giorno} other {# giorni}}", "intervals.full.hours": "{number, plural, one {# ora} other {# ore}}", "intervals.full.minutes": "{number, plural, one {# minuto} other {# minuti}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Titolo della nuova lista", "lists.search": "Cerca tra le persone che segui", "lists.subheading": "Le tue liste", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Caricamento...", "media_gallery.toggle_visible": "Imposta visibilità", "missing_indicator.label": "Non trovato", @@ -283,7 +285,7 @@ "poll_button.remove_poll": "Rimuovi sondaggio", "privacy.change": "Modifica privacy del post", "privacy.direct.long": "Invia solo a utenti menzionati", - "privacy.direct.short": "Diretto", + "privacy.direct.short": "Diretto in privato", "privacy.private.long": "Invia solo ai seguaci", "privacy.private.short": "Privato", "privacy.public.long": "Invia alla timeline pubblica", @@ -314,6 +316,7 @@ "search_results.accounts": "Gente", "search_results.hashtags": "Hashtag", "search_results.statuses": "Toot", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}", "status.admin_account": "Apri interfaccia di moderazione per @{name}", "status.admin_status": "Apri questo status nell'interfaccia di moderazione", @@ -323,7 +326,7 @@ "status.copy": "Copia link allo status", "status.delete": "Elimina", "status.detailed_status": "Vista conversazione dettagliata", - "status.direct": "Messaggio diretto @{name}", + "status.direct": "Messaggio privato @{name}", "status.embed": "Incorpora", "status.favourite": "Apprezzato", "status.filtered": "Filtrato", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 6dadf7c60..3c6d71835 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -160,6 +160,7 @@ "home.column_settings.basic": "基本設定", "home.column_settings.show_reblogs": "ブースト表示", "home.column_settings.show_replies": "返信表示", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number}日", "intervals.full.hours": "{number}時間", "intervals.full.minutes": "{number}分", @@ -225,6 +226,7 @@ "lists.new.title_placeholder": "新規リスト名", "lists.search": "フォローしている人の中から検索", "lists.subheading": "あなたのリスト", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "読み込み中...", "media_gallery.toggle_visible": "表示切り替え", "missing_indicator.label": "見つかりません", @@ -319,6 +321,7 @@ "search_results.accounts": "人々", "search_results.hashtags": "ハッシュタグ", "search_results.statuses": "トゥート", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number}件の結果", "status.admin_account": "@{name} のモデレーション画面を開く", "status.admin_status": "このトゥートをモデレーション画面で開く", diff --git a/app/javascript/mastodon/locales/ka.json b/app/javascript/mastodon/locales/ka.json index ff7059aea..a78543476 100644 --- a/app/javascript/mastodon/locales/ka.json +++ b/app/javascript/mastodon/locales/ka.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "ძირითადი", "home.column_settings.show_reblogs": "ბუსტების ჩვენება", "home.column_settings.show_replies": "პასუხების ჩვენება", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "ახალი სიის სათაური", "lists.search": "ძებნა ადამიანებს შორის რომელთაც მიჰყვებით", "lists.subheading": "თქვენი სიები", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "იტვირთება...", "media_gallery.toggle_visible": "ხილვადობის ჩართვა", "missing_indicator.label": "არაა ნაპოვნი", @@ -314,6 +316,7 @@ "search_results.accounts": "ხალხი", "search_results.hashtags": "ჰეშტეგები", "search_results.statuses": "ტუტები", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/kk.json b/app/javascript/mastodon/locales/kk.json index b9bd7cac3..9514d68a9 100644 --- a/app/javascript/mastodon/locales/kk.json +++ b/app/javascript/mastodon/locales/kk.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Негізгі", "home.column_settings.show_reblogs": "Бөлісулерді көрсету", "home.column_settings.show_replies": "Жауаптарды көрсету", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# күн} other {# күн}}", "intervals.full.hours": "{number, plural, one {# сағат} other {# сағат}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Жаңа тізім аты", "lists.search": "Сіз іздеген адамдар арасында іздеу", "lists.subheading": "Тізімдеріңіз", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Жүктеу...", "media_gallery.toggle_visible": "Көрінуді қосу", "missing_indicator.label": "Табылмады", @@ -314,6 +316,7 @@ "search_results.accounts": "Адамдар", "search_results.hashtags": "Хэштегтер", "search_results.statuses": "Жазбалар", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "@{name} үшін модерация интерфейсін аш", "status.admin_status": "Бұл жазбаны модерация интерфейсінде аш", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index 656a36bce..e71631938 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "기본 설정", "home.column_settings.show_reblogs": "부스트 표시", "home.column_settings.show_replies": "답글 표시", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number} 일", "intervals.full.hours": "{number} 시간", "intervals.full.minutes": "{number} 분", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "새 리스트의 이름", "lists.search": "팔로우 중인 사람들 중에서 찾기", "lists.subheading": "당신의 리스트", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "불러오는 중...", "media_gallery.toggle_visible": "표시 전환", "missing_indicator.label": "찾을 수 없습니다", @@ -314,6 +316,7 @@ "search_results.accounts": "사람", "search_results.hashtags": "해시태그", "search_results.statuses": "툿", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number}건의 결과", "status.admin_account": "@{name}에 대한 모더레이션 인터페이스 열기", "status.admin_status": "모더레이션 인터페이스에서 이 게시물 열기", @@ -326,7 +329,7 @@ "status.direct": "@{name}에게 다이렉트 메시지", "status.embed": "공유하기", "status.favourite": "즐겨찾기", - "status.filtered": "필터링 됨", + "status.filtered": "필터로 걸러짐", "status.load_more": "더 보기", "status.media_hidden": "미디어 숨겨짐", "status.mention": "답장", diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json index ac58514d4..919129cc5 100644 --- a/app/javascript/mastodon/locales/lt.json +++ b/app/javascript/mastodon/locales/lt.json @@ -64,7 +64,7 @@ "column_header.show_settings": "Show settings", "column_header.unpin": "Unpin", "column_subheading.settings": "Settings", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Media only", "compose_form.direct_message_warning": "This toot will only be sent to all the mentioned users.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Loading...", "media_gallery.toggle_visible": "Toggle visibility", "missing_indicator.label": "Not found", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", @@ -369,7 +372,7 @@ "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", - "upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)", + "upload_button.label": "Add media ({formats})", "upload_error.limit": "File upload limit exceeded.", "upload_error.poll": "File upload not allowed with polls.", "upload_form.description": "Describe for the visually impaired", diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json index 647e23a69..5328f15c5 100644 --- a/app/javascript/mastodon/locales/lv.json +++ b/app/javascript/mastodon/locales/lv.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Loading...", "media_gallery.toggle_visible": "Toggle visibility", "missing_indicator.label": "Not found", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", @@ -369,7 +372,7 @@ "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", - "upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)", + "upload_button.label": "Add media ({formats})", "upload_error.limit": "File upload limit exceeded.", "upload_error.poll": "File upload not allowed with polls.", "upload_form.description": "Describe for the visually impaired", diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json index d7c509963..ad72b3233 100644 --- a/app/javascript/mastodon/locales/ms.json +++ b/app/javascript/mastodon/locales/ms.json @@ -64,7 +64,7 @@ "column_header.show_settings": "Show settings", "column_header.unpin": "Unpin", "column_subheading.settings": "Settings", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Media only", "compose_form.direct_message_warning": "This toot will only be sent to all the mentioned users.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "New list title", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Loading...", "media_gallery.toggle_visible": "Toggle visibility", "missing_indicator.label": "Not found", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", @@ -369,7 +372,7 @@ "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", - "upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)", + "upload_button.label": "Add media ({formats})", "upload_error.limit": "File upload limit exceeded.", "upload_error.poll": "File upload not allowed with polls.", "upload_form.description": "Describe for the visually impaired", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index f6504f4bb..d7f428193 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Algemeen", "home.column_settings.show_reblogs": "Boosts tonen", "home.column_settings.show_replies": "Reacties tonen", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# dag} other {# dagen}}", "intervals.full.hours": "{number, plural, one {# uur} other {# uur}}", "intervals.full.minutes": "{number, plural, one {# minuut} other {# minuten}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Naam nieuwe lijst", "lists.search": "Zoek naar mensen die je volgt", "lists.subheading": "Jouw lijsten", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Laden…", "media_gallery.toggle_visible": "Media wel/niet tonen", "missing_indicator.label": "Niet gevonden", @@ -314,6 +316,7 @@ "search_results.accounts": "Gebruikers", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}", "status.admin_account": "Moderatie-omgeving van @{name} openen", "status.admin_status": "Deze toot in de moderatie-omgeving openen", diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index 2ba8236e2..ea722a01e 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -64,7 +64,7 @@ "column_header.show_settings": "Vis innstillinger", "column_header.unpin": "Løsne", "column_subheading.settings": "Innstillinger", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Media only", "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "Denne tuten blir ikke listet under noen emneknagger da den er ulistet. Kun offentlige tuter kan søktes etter med emneknagg.", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Enkel", "home.column_settings.show_reblogs": "Vis fremhevinger", "home.column_settings.show_replies": "Vis svar", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Ny listetittel", "lists.search": "Søk blant personer du følger", "lists.subheading": "Dine lister", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Laster...", "media_gallery.toggle_visible": "Veksle synlighet", "missing_indicator.label": "Ikke funnet", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 3178f200d..34804da20 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -77,7 +77,7 @@ "compose_form.poll.remove_option": "Levar aquesta opcion", "compose_form.publish": "Tut", "compose_form.publish_loud": "{publish} !", - "compose_form.sensitive.hide": "Mark media as sensitive", + "compose_form.sensitive.hide": "Marcar coma sensible", "compose_form.sensitive.marked": "Lo mèdia es marcat coma sensible", "compose_form.sensitive.unmarked": "Lo mèdia es pas marcat coma sensible", "compose_form.spoiler.marked": "Lo tèxte es rescondut jos l’avertiment", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Mostrar los partatges", "home.column_settings.show_replies": "Mostrar las responsas", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# jorn} other {# jorns}}", "intervals.full.hours": "{number, plural, one {# ora} other {# oras}}", "intervals.full.minutes": "{number, plural, one {# minuta} other {# minutas}}", @@ -204,14 +205,14 @@ "keyboard_shortcuts.search": "anar a la recèrca", "keyboard_shortcuts.start": "dobrir la colomna « Per començar »", "keyboard_shortcuts.toggle_hidden": "mostrar/amagar lo tèxte dels avertiments", - "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", + "keyboard_shortcuts.toggle_sensitivity": "per mostrar/rescondre los mèdias", "keyboard_shortcuts.toot": "començar un estatut tot novèl", "keyboard_shortcuts.unfocus": "quitar lo camp tèxte/de recèrca", "keyboard_shortcuts.up": "far montar dins la lista", "lightbox.close": "Tampar", "lightbox.next": "Seguent", "lightbox.previous": "Precedent", - "lightbox.view_context": "View context", + "lightbox.view_context": "Veire lo contèxt", "lists.account.add": "Ajustar a la lista", "lists.account.remove": "Levar de la lista", "lists.delete": "Suprimir la lista", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Títol de la nòva lista", "lists.search": "Cercar demest lo monde que seguètz", "lists.subheading": "Vòstras listas", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Cargament…", "media_gallery.toggle_visible": "Modificar la visibilitat", "missing_indicator.label": "Pas trobat", @@ -237,7 +239,7 @@ "navigation_bar.favourites": "Favorits", "navigation_bar.filters": "Mots ignorats", "navigation_bar.follow_requests": "Demandas d’abonament", - "navigation_bar.follows_and_followers": "Follows and followers", + "navigation_bar.follows_and_followers": "Abonament e seguidors", "navigation_bar.info": "Tocant aqueste servidor", "navigation_bar.keyboard_shortcuts": "Acorchis clavièr", "navigation_bar.lists": "Listas", @@ -246,7 +248,7 @@ "navigation_bar.personal": "Personal", "navigation_bar.pins": "Tuts penjats", "navigation_bar.preferences": "Preferéncias", - "navigation_bar.profile_directory": "Profile directory", + "navigation_bar.profile_directory": "Annuari de perfils", "navigation_bar.public_timeline": "Flux public global", "navigation_bar.security": "Seguretat", "notification.favourite": "{name} a ajustat a sos favorits", @@ -314,6 +316,7 @@ "search_results.accounts": "Gents", "search_results.hashtags": "Etiquetas", "search_results.statuses": "Tuts", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}", "status.admin_account": "Dobrir l’interfàcia de moderacion per @{name}", "status.admin_status": "Dobrir aqueste estatut dins l’interfàcia de moderacion", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index d101c21aa..d96ceb064 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -160,6 +160,7 @@ "home.column_settings.basic": "Podstawowe", "home.column_settings.show_reblogs": "Pokazuj podbicia", "home.column_settings.show_replies": "Pokazuj odpowiedzi", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# dzień} few {# dni} many {# dni} other {# dni}}", "intervals.full.hours": "{number, plural, one {# godzina} few {# godziny} many {# godzin} other {# godzin}}", "intervals.full.minutes": "{number, plural, one {# minuta} few {# minuty} many {# minut} other {# minut}}", @@ -225,6 +226,7 @@ "lists.new.title_placeholder": "Wprowadź tytuł listy", "lists.search": "Szukaj wśród osób które śledzisz", "lists.subheading": "Twoje listy", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Ładowanie…", "media_gallery.toggle_visible": "Przełącz widoczność", "missing_indicator.label": "Nie znaleziono", @@ -319,6 +321,7 @@ "search_results.accounts": "Ludzie", "search_results.hashtags": "Hashtagi", "search_results.statuses": "Wpisy", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {wynik} few {wyniki} many {wyników} more {wyników}}", "status.admin_account": "Otwórz interfejs moderacyjny dla @{name}", "status.admin_status": "Otwórz ten wpis w interfejsie moderacyjnym", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index dca087af9..1fb700874 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -36,7 +36,7 @@ "account.unmute": "Não silenciar @{name}", "account.unmute_notifications": "Retirar silêncio das notificações vindas de @{name}", "alert.unexpected.message": "Um erro inesperado ocorreu.", - "alert.unexpected.title": "Oops!", + "alert.unexpected.title": "Eita!", "boost_modal.combo": "Você pode pressionar {combo} para ignorar este diálogo na próxima vez", "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.", "bundle_column_error.retry": "Tente novamente", @@ -77,7 +77,7 @@ "compose_form.poll.remove_option": "Remover essa opção", "compose_form.publish": "Publicar", "compose_form.publish_loud": "{publish}!", - "compose_form.sensitive.hide": "Mark media as sensitive", + "compose_form.sensitive.hide": "Marcar mídia como sensível", "compose_form.sensitive.marked": "Mídia está marcada como sensível", "compose_form.sensitive.unmarked": "Mídia não está marcada como sensível", "compose_form.spoiler.marked": "O texto está escondido por um aviso de conteúdo", @@ -89,7 +89,7 @@ "confirmations.block.message": "Você tem certeza de que quer bloquear {name}?", "confirmations.delete.confirm": "Excluir", "confirmations.delete.message": "Você tem certeza de que quer excluir esta postagem?", - "confirmations.delete_list.confirm": "Delete", + "confirmations.delete_list.confirm": "Excluir", "confirmations.delete_list.message": "Você tem certeza que quer deletar permanentemente a lista?", "confirmations.domain_block.confirm": "Esconder o domínio inteiro", "confirmations.domain_block.message": "Você quer mesmo bloquear {domain} inteiro? Na maioria dos casos, silenciar ou bloquear alguns usuários é o suficiente e o recomendado. Você não vai ver conteúdo desse domínio em nenhuma das timelines públicas ou nas suas notificações. Seus seguidores desse domínio serão removidos.", @@ -156,13 +156,14 @@ "home.column_settings.basic": "Básico", "home.column_settings.show_reblogs": "Mostrar compartilhamentos", "home.column_settings.show_replies": "Mostrar as respostas", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# dia} other {# dias}}", "intervals.full.hours": "{number, plural, one {# hora} other {# horas}}", - "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", + "intervals.full.minutes": "{number, plural, one {# minuto} other {# minutos}}", "introduction.federation.action": "Próximo", - "introduction.federation.federated.headline": "Federated", + "introduction.federation.federated.headline": "Global", "introduction.federation.federated.text": "Posts públicos de outros servidores do fediverso vão aparecer na timeline global.", - "introduction.federation.home.headline": "Home", + "introduction.federation.home.headline": "Início", "introduction.federation.home.text": "Posts de pessoas que você segue vão aparecer na sua página inicial. Você pode seguir pessoas de qualquer servidor!", "introduction.federation.local.headline": "Local", "introduction.federation.local.text": "Posts públicos de pessoas no mesmo servidor que você vão aparecer na timeline local.", @@ -204,23 +205,24 @@ "keyboard_shortcuts.search": "para focar a pesquisa", "keyboard_shortcuts.start": "para abrir a coluna \"primeiros passos\"", "keyboard_shortcuts.toggle_hidden": "mostrar/esconder o texto com aviso de conteúdo", - "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", + "keyboard_shortcuts.toggle_sensitivity": "mostrar/esconder mídia", "keyboard_shortcuts.toot": "para compor um novo toot", "keyboard_shortcuts.unfocus": "para remover o foco da área de composição/pesquisa", "keyboard_shortcuts.up": "para mover para cima na lista", "lightbox.close": "Fechar", "lightbox.next": "Próximo", "lightbox.previous": "Anterior", - "lightbox.view_context": "View context", + "lightbox.view_context": "Ver contexto", "lists.account.add": "Adicionar a listas", "lists.account.remove": "Remover da lista", - "lists.delete": "Delete list", + "lists.delete": "Excluir lista", "lists.edit": "Editar lista", "lists.edit.submit": "Mudar o título", "lists.new.create": "Adicionar lista", "lists.new.title_placeholder": "Novo título da lista", "lists.search": "Procurar entre as pessoas que você segue", "lists.subheading": "Suas listas", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Carregando...", "media_gallery.toggle_visible": "Esconder/Mostrar", "missing_indicator.label": "Não encontrado", @@ -237,7 +239,7 @@ "navigation_bar.favourites": "Favoritos", "navigation_bar.filters": "Palavras silenciadas", "navigation_bar.follow_requests": "Seguidores pendentes", - "navigation_bar.follows_and_followers": "Follows and followers", + "navigation_bar.follows_and_followers": "Seguindo e seguidores", "navigation_bar.info": "Mais informações", "navigation_bar.keyboard_shortcuts": "Atalhos de teclado", "navigation_bar.lists": "Listas", @@ -246,7 +248,7 @@ "navigation_bar.personal": "Pessoal", "navigation_bar.pins": "Postagens fixadas", "navigation_bar.preferences": "Preferências", - "navigation_bar.profile_directory": "Profile directory", + "navigation_bar.profile_directory": "Diretório de perfis", "navigation_bar.public_timeline": "Global", "navigation_bar.security": "Segurança", "notification.favourite": "{name} adicionou a sua postagem aos favoritos", @@ -314,6 +316,7 @@ "search_results.accounts": "Pessoas", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", "status.admin_account": "Abrir interface de moderação para @{name}", "status.admin_status": "Abrir esse status na interface de moderação", diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json index 157090c55..c6ea3f847 100644 --- a/app/javascript/mastodon/locales/pt.json +++ b/app/javascript/mastodon/locales/pt.json @@ -17,7 +17,7 @@ "account.hide_reblogs": "Esconder partilhas de @{name}", "account.link_verified_on": "A posse deste link foi verificada em {date}", "account.locked_info": "O estatuto de privacidade desta conta é fechado. O dono revê manualmente que a pode seguir.", - "account.media": "Media", + "account.media": "Média", "account.mention": "Mencionar @{name}", "account.moved_to": "{name} mudou a sua conta para:", "account.mute": "Silenciar @{name}", @@ -49,50 +49,50 @@ "column.direct": "Mensagens directas", "column.domain_blocks": "Domínios escondidos", "column.favourites": "Favoritos", - "column.follow_requests": "Seguidores Pendentes", + "column.follow_requests": "Seguidores pendentes", "column.home": "Início", "column.lists": "Listas", "column.mutes": "Utilizadores silenciados", "column.notifications": "Notificações", "column.pins": "Publicações fixas", - "column.public": "Cronologia federativa", + "column.public": "Cronologia federada", "column_back_button.label": "Voltar", - "column_header.hide_settings": "Esconder preferências", + "column_header.hide_settings": "Esconder configurações", "column_header.moveLeft_settings": "Mover coluna para a esquerda", "column_header.moveRight_settings": "Mover coluna para a direita", "column_header.pin": "Fixar", - "column_header.show_settings": "Mostrar preferências", + "column_header.show_settings": "Mostrar configurações", "column_header.unpin": "Desafixar", - "column_subheading.settings": "Preferências", - "community.column_settings.media_only": "Somente media", - "compose_form.direct_message_warning": "Esta publicação só será enviada para os utilizadores mencionados.", - "compose_form.direct_message_warning_learn_more": "Aprender mais", - "compose_form.hashtag_warning": "Esta pulbicacção não será listada em nenhuma hashtag por ser não listada. Somente publicações públicas podem ser pesquisadas por hashtag.", + "column_subheading.settings": "Configurações", + "community.column_settings.media_only": "Somente multimédia", + "compose_form.direct_message_warning": "Esta publicação será enviada apenas para os utilizadores mencionados.", + "compose_form.direct_message_warning_learn_more": "Conhecer mais", + "compose_form.hashtag_warning": "Este toot não será listado em nenhuma hashtag por ser não listado. Apenas toots públics podem ser pesquisados por hashtag.", "compose_form.lock_disclaimer": "A tua conta não está {locked}. Qualquer pessoa pode seguir-te e ver as publicações direcionadas apenas a seguidores.", - "compose_form.lock_disclaimer.lock": "fechada", + "compose_form.lock_disclaimer.lock": "bloqueado", "compose_form.placeholder": "Em que estás a pensar?", - "compose_form.poll.add_option": "Add a choice", - "compose_form.poll.duration": "Poll duration", - "compose_form.poll.option_placeholder": "Choice {number}", - "compose_form.poll.remove_option": "Remove this choice", - "compose_form.publish": "Publicar", - "compose_form.publish_loud": "{publicar}!", - "compose_form.sensitive.hide": "Mark media as sensitive", - "compose_form.sensitive.marked": "Media marcado como sensível", - "compose_form.sensitive.unmarked": "Media não está marcado como sensível", + "compose_form.poll.add_option": "Adicionar uma opção", + "compose_form.poll.duration": "Duração da votação", + "compose_form.poll.option_placeholder": "Opção {number}", + "compose_form.poll.remove_option": "Eliminar esta opção", + "compose_form.publish": "Toot", + "compose_form.publish_loud": "{publish}!", + "compose_form.sensitive.hide": "Marcar multimédia como sensível", + "compose_form.sensitive.marked": "Média marcada como sensível", + "compose_form.sensitive.unmarked": "Média não está marcada como sensível", "compose_form.spoiler.marked": "Texto escondido atrás de aviso", "compose_form.spoiler.unmarked": "O texto não está escondido", "compose_form.spoiler_placeholder": "Escreve o teu aviso aqui", "confirmation_modal.cancel": "Cancelar", - "confirmations.block.block_and_report": "Block & Report", + "confirmations.block.block_and_report": "Bloquear e denunciar", "confirmations.block.confirm": "Bloquear", "confirmations.block.message": "De certeza que queres bloquear {name}?", "confirmations.delete.confirm": "Eliminar", "confirmations.delete.message": "De certeza que queres eliminar esta publicação?", - "confirmations.delete_list.confirm": "Apagar", - "confirmations.delete_list.message": "Tens a certeza de que desejas apagar permanentemente esta lista?", + "confirmations.delete_list.confirm": "Eliminar", + "confirmations.delete_list.message": "Tens a certeza de que desejas eliminar permanentemente esta lista?", "confirmations.domain_block.confirm": "Esconder tudo deste domínio", - "confirmations.domain_block.message": "De certeza que queres bloquear completamente o domínio {domain}? Na maioria dos casos, silenciar ou bloquear alguns utilizadores é o suficiente e o recomendado. Não irás ver conteúdo daquele domínio em cronologia alguma, nem nas tuas notificações. Os teus seguidores daquele domínio serão removidos.", + "confirmations.domain_block.message": "De certeza que queres bloquear completamente o domínio {domain}? Na maioria dos casos, silenciar ou bloquear alguns utilizadores é suficiente e é o recomendado. Não irás ver conteúdo daquele domínio em cronologia alguma nem nas tuas notificações. Os teus seguidores daquele domínio serão removidos.", "confirmations.mute.confirm": "Silenciar", "confirmations.mute.message": "De certeza que queres silenciar {name}?", "confirmations.redraft.confirm": "Apagar & redigir", @@ -109,23 +109,23 @@ "emoji_button.food": "Comida & Bebida", "emoji_button.label": "Inserir Emoji", "emoji_button.nature": "Natureza", - "emoji_button.not_found": "Não tem emojos!! (╯°□°)╯︵ ┻━┻", + "emoji_button.not_found": "Não tem emojis!! (╯°□°)╯︵ ┻━┻", "emoji_button.objects": "Objectos", "emoji_button.people": "Pessoas", - "emoji_button.recent": "Regularmente utilizados", - "emoji_button.search": "Procurar...", + "emoji_button.recent": "Utilizados regularmente", + "emoji_button.search": "Pesquisar...", "emoji_button.search_results": "Resultados da pesquisa", "emoji_button.symbols": "Símbolos", "emoji_button.travel": "Viagens & Lugares", - "empty_column.account_timeline": "Sem publicações!", - "empty_column.account_unavailable": "Profile unavailable", + "empty_column.account_timeline": "Sem toots por aqui!", + "empty_column.account_unavailable": "Perfil indisponível", "empty_column.blocks": "Ainda não bloqueaste qualquer utilizador.", - "empty_column.community": "Ainda não existe conteúdo local para mostrar!", + "empty_column.community": "A timeline local está vazia. Escreve algo publicamente para começar!", "empty_column.direct": "Ainda não tens qualquer mensagem directa. Quando enviares ou receberes alguma, ela irá aparecer aqui.", "empty_column.domain_blocks": "Ainda não há qualquer domínio escondido.", - "empty_column.favourited_statuses": "Ainda não tens quaisquer publicações favoritas. Quando tiveres alguma, ela irá aparecer aqui.", - "empty_column.favourites": "Ainda ninguém favorizou esta publicação. Quando alguém o fizer, ela irá aparecer aqui.", - "empty_column.follow_requests": "Ainda não tens pedido de seguimento algum. Quando receberes algum, ele irá aparecer aqui.", + "empty_column.favourited_statuses": "Ainda não tens quaisquer toots favoritos. Quando tiveres algum, ele irá aparecer aqui.", + "empty_column.favourites": "Ainda ninguém marcou este toot como favorito. Quando alguém o fizer, ele irá aparecer aqui.", + "empty_column.follow_requests": "Ainda não tens nenhum pedido de seguimento. Quando receberes algum, ele irá aparecer aqui.", "empty_column.hashtag": "Não foram encontradas publicações com essa hashtag.", "empty_column.home": "Ainda não segues qualquer utilizador. Visita {public} ou utiliza a pesquisa para procurar outros utilizadores.", "empty_column.home.public_timeline": "Cronologia pública", @@ -138,10 +138,10 @@ "follow_request.reject": "Rejeitar", "getting_started.developers": "Responsáveis pelo desenvolvimento", "getting_started.directory": "Directório de perfil", - "getting_started.documentation": "Documentation", + "getting_started.documentation": "Documentação", "getting_started.heading": "Primeiros passos", "getting_started.invite": "Convidar pessoas", - "getting_started.open_source_notice": "Mastodon é software de fonte aberta. Podes contribuir ou repostar problemas no GitHub do projecto: {github}.", + "getting_started.open_source_notice": "Mastodon é software de código aberto (open source). Podes contribuir ou reportar problemas no GitHub do projecto: {github}.", "getting_started.security": "Segurança", "getting_started.terms": "Termos de serviço", "hashtag.column_header.tag_mode.all": "e {additional}", @@ -154,28 +154,29 @@ "hashtag.column_settings.tag_mode.none": "Nenhum destes", "hashtag.column_settings.tag_toggle": "Incluir etiquetas adicionais para esta coluna", "home.column_settings.basic": "Básico", - "home.column_settings.show_reblogs": "Mostrar as partilhas", - "home.column_settings.show_replies": "Mostrar as respostas", - "intervals.full.days": "{number, plural, one {# day} other {# days}}", - "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", - "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", + "home.column_settings.show_reblogs": "Mostrar boosts", + "home.column_settings.show_replies": "Mostrar respostas", + "home.column_settings.update_live": "Update in real-time", + "intervals.full.days": "{number, plural, one {# dia} other {# dias}}", + "intervals.full.hours": "{number, plural, one {# hora} other {# horas}}", + "intervals.full.minutes": "{number, plural, one {# minuto} other {# minutos}}", "introduction.federation.action": "Seguinte", - "introduction.federation.federated.headline": "Federated", + "introduction.federation.federated.headline": "Federada", "introduction.federation.federated.text": "Publicações públicas de outros servidores do fediverse aparecerão na cronologia federativa.", - "introduction.federation.home.headline": "Home", + "introduction.federation.home.headline": "Início", "introduction.federation.home.text": "As publicações das pessoas que tu segues aparecerão na tua coluna inicial. Tu podes seguir qualquer pessoa em qualquer servidor!", "introduction.federation.local.headline": "Local", "introduction.federation.local.text": "Publicações públicas de pessoas que tu segues no teu servidor aparecerão na coluna local.", "introduction.interactions.action": "Terminar o tutorial!", "introduction.interactions.favourite.headline": "Favorito", - "introduction.interactions.favourite.text": "Tu podes guardar um toot para depois e deixar o autor saber que gostaste dele, favoritando-o.", - "introduction.interactions.reblog.headline": "Partilhar", + "introduction.interactions.favourite.text": "Podes guardar um toot para depois e deixar o autor saber que gostaste dele, marcando-o como favorito.", + "introduction.interactions.reblog.headline": "Boost", "introduction.interactions.reblog.text": "Podes partilhar os toots de outras pessoas com os teus seguidores partilhando-os.", "introduction.interactions.reply.headline": "Responder", "introduction.interactions.reply.text": "Tu podes responder a toots de outras pessoas e aos teus, o que os irá juntar numa conversa.", "introduction.welcome.action": "Vamos!", "introduction.welcome.headline": "Primeiros passos", - "introduction.welcome.text": "Bem-vindo ao fediverse! Em pouco tempo poderás enviar mensagens e falar com os teus amigos numa grande variedade de servidores. Mas este servidor, {domain}, é especial—ele alberga o teu perfil. Por isso, lembra-te do seu nome.", + "introduction.welcome.text": "Bem-vindo ao fediverso! Em pouco tempo poderás enviar mensagens e falar com os teus amigos numa grande variedade de servidores. Mas este servidor, {domain}, é especial—ele alberga o teu perfil. Por isso, lembra-te do seu nome.", "keyboard_shortcuts.back": "para voltar", "keyboard_shortcuts.blocked": "para abrir a lista de utilizadores bloqueados", "keyboard_shortcuts.boost": "para partilhar", @@ -184,10 +185,10 @@ "keyboard_shortcuts.description": "Descrição", "keyboard_shortcuts.direct": "para abrir a coluna das mensagens directas", "keyboard_shortcuts.down": "para mover para baixo na lista", - "keyboard_shortcuts.enter": "para expandir uma publicação", + "keyboard_shortcuts.enter": "para expandir um estado", "keyboard_shortcuts.favourite": "para adicionar aos favoritos", "keyboard_shortcuts.favourites": "para abrir a lista dos favoritos", - "keyboard_shortcuts.federated": "para abrir a cronologia federativa", + "keyboard_shortcuts.federated": "para abrir a cronologia federada", "keyboard_shortcuts.heading": "Atalhos do teclado", "keyboard_shortcuts.home": "para abrir a cronologia inicial", "keyboard_shortcuts.hotkey": "Atalho", @@ -204,31 +205,32 @@ "keyboard_shortcuts.search": "para focar na pesquisa", "keyboard_shortcuts.start": "para abrir a coluna dos \"primeiros passos\"", "keyboard_shortcuts.toggle_hidden": "para mostrar/esconder texto atrás de CW", - "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", - "keyboard_shortcuts.toot": "para compor um novo post", - "keyboard_shortcuts.unfocus": "para remover o foco da área de publicação/pesquisa", + "keyboard_shortcuts.toggle_sensitivity": "mostrar/ocultar média", + "keyboard_shortcuts.toot": "para compor um novo toot", + "keyboard_shortcuts.unfocus": "para remover o foco da área de texto/pesquisa", "keyboard_shortcuts.up": "para mover para cima na lista", "lightbox.close": "Fechar", "lightbox.next": "Próximo", "lightbox.previous": "Anterior", - "lightbox.view_context": "View context", + "lightbox.view_context": "Ver contexto", "lists.account.add": "Adicionar à lista", "lists.account.remove": "Remover da lista", - "lists.delete": "Delete list", + "lists.delete": "Remover lista", "lists.edit": "Editar lista", "lists.edit.submit": "Mudar o título", "lists.new.create": "Adicionar lista", - "lists.new.title_placeholder": "Novo título da lista", + "lists.new.title_placeholder": "Título da nova lista", "lists.search": "Pesquisa entre as pessoas que segues", "lists.subheading": "As tuas listas", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "A carregar...", - "media_gallery.toggle_visible": "Esconder/Mostrar", + "media_gallery.toggle_visible": "Mostrar/ocultar", "missing_indicator.label": "Não encontrado", "missing_indicator.sublabel": "Este recurso não foi encontrado", "mute_modal.hide_notifications": "Esconder notificações deste utilizador?", "navigation_bar.apps": "Aplicações móveis", "navigation_bar.blocks": "Utilizadores bloqueados", - "navigation_bar.community_timeline": "Local", + "navigation_bar.community_timeline": "Cronologia local", "navigation_bar.compose": "Escrever novo toot", "navigation_bar.direct": "Mensagens directas", "navigation_bar.discover": "Descobrir", @@ -237,23 +239,23 @@ "navigation_bar.favourites": "Favoritos", "navigation_bar.filters": "Palavras silenciadas", "navigation_bar.follow_requests": "Seguidores pendentes", - "navigation_bar.follows_and_followers": "Follows and followers", + "navigation_bar.follows_and_followers": "Seguindo e seguidores", "navigation_bar.info": "Sobre este servidor", "navigation_bar.keyboard_shortcuts": "Atalhos de teclado", "navigation_bar.lists": "Listas", "navigation_bar.logout": "Sair", "navigation_bar.mutes": "Utilizadores silenciados", - "navigation_bar.personal": "Personal", - "navigation_bar.pins": "Posts fixos", + "navigation_bar.personal": "Pessoal", + "navigation_bar.pins": "Toots afixados", "navigation_bar.preferences": "Preferências", - "navigation_bar.profile_directory": "Profile directory", - "navigation_bar.public_timeline": "Global", + "navigation_bar.profile_directory": "Directório de perfis", + "navigation_bar.public_timeline": "Cronologia federada", "navigation_bar.security": "Segurança", - "notification.favourite": "{name} adicionou o teu post aos favoritos", - "notification.follow": "{name} seguiu-te", + "notification.favourite": "{name} adicionou o teu estado aos favoritos", + "notification.follow": "{name} começou a seguir-te", "notification.mention": "{name} mencionou-te", - "notification.poll": "A poll you have voted in has ended", - "notification.reblog": "{name} partilhou o teu post", + "notification.poll": "Uma votação em participaste chegou ao fim", + "notification.reblog": "{name} fez boost ao teu o teu estado", "notifications.clear": "Limpar notificações", "notifications.clear_confirmation": "Queres mesmo limpar todas as notificações?", "notifications.column_settings.alert": "Notificações no computador", @@ -263,24 +265,24 @@ "notifications.column_settings.filter_bar.show": "Mostrar", "notifications.column_settings.follow": "Novos seguidores:", "notifications.column_settings.mention": "Menções:", - "notifications.column_settings.poll": "Poll results:", + "notifications.column_settings.poll": "Resultados da votação:", "notifications.column_settings.push": "Notificações Push", - "notifications.column_settings.reblog": "Partilhas:", - "notifications.column_settings.show": "Mostrar nas colunas", + "notifications.column_settings.reblog": "Boosts:", + "notifications.column_settings.show": "Mostrar na coluna", "notifications.column_settings.sound": "Reproduzir som", "notifications.filter.all": "Todas", - "notifications.filter.boosts": "Partilhas", - "notifications.filter.favourites": "Favoritas", + "notifications.filter.boosts": "Boosts", + "notifications.filter.favourites": "Favoritos", "notifications.filter.follows": "Seguimento", "notifications.filter.mentions": "Referências", - "notifications.filter.polls": "Poll results", + "notifications.filter.polls": "Resultados da votação", "notifications.group": "{count} notificações", "poll.closed": "Fechado", "poll.refresh": "Recarregar", "poll.total_votes": "{contar, plural, um {# vote} outro {# votes}}", "poll.vote": "Votar", - "poll_button.add_poll": "Add a poll", - "poll_button.remove_poll": "Remove poll", + "poll_button.add_poll": "Adicionar votação", + "poll_button.remove_poll": "Remover votação", "privacy.change": "Ajustar a privacidade da mensagem", "privacy.direct.long": "Apenas para utilizadores mencionados", "privacy.direct.short": "Directo", @@ -300,26 +302,27 @@ "reply_indicator.cancel": "Cancelar", "report.forward": "Reenviar para {target}", "report.forward_hint": "A conta é de outro servidor. Enviar uma cópia anónima do relatório para lá também?", - "report.hint": "O relatório será enviado para os moderadores do teu servidor. Podes fornecer, em baixo, uma explicação do motivo pelo qual estás a relatar esta conta:", + "report.hint": "O relatório será enviado para os moderadores do teu servidor. Podes fornecer, em baixo, uma explicação do motivo pelo qual estás a denunciar esta conta:", "report.placeholder": "Comentários adicionais", "report.submit": "Enviar", "report.target": "Denunciar", "search.placeholder": "Pesquisar", "search_popout.search_format": "Formato avançado de pesquisa", - "search_popout.tips.full_text": "Texto simples devolve publicações que tu escreveste, favoritaste, partilhaste ou em que foste mencionado, tal como nomes de utilizador correspondentes, alcunhas e hashtags.", + "search_popout.tips.full_text": "Texto simples devolve publicações que tu escreveste, marcaste como favorita, partilhaste ou em que foste mencionado, tal como nomes de utilizador correspondentes, alcunhas e hashtags.", "search_popout.tips.hashtag": "hashtag", "search_popout.tips.status": "estado", "search_popout.tips.text": "O texto simples retorna a correspondência de nomes, utilizadores e hashtags", "search_popout.tips.user": "utilizador", "search_results.accounts": "Pessoas", "search_results.hashtags": "Hashtags", - "search_results.statuses": "Publicações", + "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", "status.admin_account": "Abrir a interface de moderação para @{name}", "status.admin_status": "Abrir esta publicação na interface de moderação", - "status.block": "Block @{name}", - "status.cancel_reblog_private": "Não partilhar", - "status.cannot_reblog": "Este post não pode ser partilhado", + "status.block": "Bloquear @{name}", + "status.cancel_reblog_private": "Remover boost", + "status.cannot_reblog": "Não é possível fazer boost a esta publicação", "status.copy": "Copiar o link para a publicação", "status.delete": "Eliminar", "status.detailed_status": "Vista de conversação detalhada", @@ -328,7 +331,7 @@ "status.favourite": "Adicionar aos favoritos", "status.filtered": "Filtrada", "status.load_more": "Carregar mais", - "status.media_hidden": "Media escondida", + "status.media_hidden": "Média escondida", "status.mention": "Mencionar @{name}", "status.more": "Mais", "status.mute": "Silenciar @{name}", @@ -338,15 +341,15 @@ "status.pinned": "Publicação fixa", "status.read_more": "Ler mais", "status.reblog": "Partilhar", - "status.reblog_private": "Partilhar com a audiência original", - "status.reblogged_by": "{name} partilhou", - "status.reblogs.empty": "Ainda ninguém partilhou esta publicação. Quando alguém o fizer, ela irá aparecer aqui.", + "status.reblog_private": "Fazer boost com a audiência original", + "status.reblogged_by": "{name} fez boost", + "status.reblogs.empty": "Ainda ninguém fez boost a este toot. Quando alguém o fizer, ele irá aparecer aqui.", "status.redraft": "Apagar & reescrever", "status.reply": "Responder", "status.replyAll": "Responder à conversa", "status.report": "Denunciar @{name}", "status.sensitive_warning": "Conteúdo sensível", - "status.share": "Compartilhar", + "status.share": "Partilhar", "status.show_less": "Mostrar menos", "status.show_less_all": "Mostrar menos para todas", "status.show_more": "Mostrar mais", @@ -356,22 +359,22 @@ "status.unpin": "Não fixar no perfil", "suggestions.dismiss": "Dispensar a sugestão", "suggestions.header": "Tu podes estar interessado em…", - "tabs_bar.federated_timeline": "Global", - "tabs_bar.home": "Home", + "tabs_bar.federated_timeline": "Federada", + "tabs_bar.home": "Início", "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notificações", "tabs_bar.search": "Pesquisar", "time_remaining.days": "{número, plural, um {# day} outro {# days}} faltam", "time_remaining.hours": "{número, plural, um {# hour} outro {# hours}} faltam", "time_remaining.minutes": "{número, plural, um {# minute} outro {# minutes}} faltam", - "time_remaining.moments": "Momentos em falta", + "time_remaining.moments": "Momentos restantes", "time_remaining.seconds": "{número, plural, um {# second} outro {# seconds}} faltam", "trends.count_by_accounts": "{count} {rawCount, plural, uma {person} outra {people}} a falar", - "ui.beforeunload": "O teu rascunho vai ser perdido se abandonares o Mastodon.", + "ui.beforeunload": "O teu rascunho será perdido se abandonares o Mastodon.", "upload_area.title": "Arraste e solte para enviar", "upload_button.label": "Adicionar media", "upload_error.limit": "Limite máximo do ficheiro a carregar excedido.", - "upload_error.poll": "File upload not allowed with polls.", + "upload_error.poll": "Carregamento de ficheiros não é permitido em votações.", "upload_form.description": "Descrição da imagem para pessoas com dificuldades visuais", "upload_form.focus": "Alterar previsualização", "upload_form.undo": "Apagar", @@ -379,7 +382,7 @@ "video.close": "Fechar vídeo", "video.exit_fullscreen": "Sair de full screen", "video.expand": "Expandir vídeo", - "video.fullscreen": "Full screen", + "video.fullscreen": "Ecrã completo", "video.hide": "Esconder vídeo", "video.mute": "Silenciar", "video.pause": "Pausar", diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json index dcb7a088d..ac10d4678 100644 --- a/app/javascript/mastodon/locales/ro.json +++ b/app/javascript/mastodon/locales/ro.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "De bază", "home.column_settings.show_reblogs": "Arată redistribuirile", "home.column_settings.show_replies": "Arată răspunsurile", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Titlu pentru noua listă", "lists.search": "Caută printre persoanale pe care le urmărești", "lists.subheading": "Listele tale", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Încărcare...", "media_gallery.toggle_visible": "Comutați vizibilitatea", "missing_indicator.label": "Nu a fost găsit", @@ -314,6 +316,7 @@ "search_results.accounts": "Oameni", "search_results.hashtags": "Hashtaguri", "search_results.statuses": "Postări", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index d720b6272..8a7a39a06 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -13,20 +13,20 @@ "account.followers.empty": "Никто не подписан на этого пользователя.", "account.follows": "Подписки", "account.follows.empty": "Этот пользователь ни на кого не подписан.", - "account.follows_you": "Подписан(а) на Вас", + "account.follows_you": "Подписан(а) на вас", "account.hide_reblogs": "Скрыть реблоги от @{name}", "account.link_verified_on": "Владение этой ссылкой было проверено {date}", "account.locked_info": "Это закрытый аккаунт. Его владелец вручную одобряет подписчиков.", "account.media": "Медиа", "account.mention": "Упомянуть", "account.moved_to": "Ищите {name} здесь:", - "account.mute": "Заглушить", + "account.mute": "Скрыть @{name}", "account.mute_notifications": "Скрыть уведомления от @{name}", - "account.muted": "Приглушён", + "account.muted": "Скрыт", "account.posts": "Посты", - "account.posts_with_replies": "Посты и ответы", + "account.posts_with_replies": "Посты с ответами", "account.report": "Пожаловаться", - "account.requested": "Ожидает подтверждения", + "account.requested": "Ожидает подтверждения. Нажмите для отмены", "account.share": "Поделиться профилем @{name}", "account.show_reblogs": "Показывать продвижения от @{name}", "account.unblock": "Разблокировать", @@ -52,7 +52,7 @@ "column.follow_requests": "Запросы на подписку", "column.home": "Главная", "column.lists": "Списки", - "column.mutes": "Список глушения", + "column.mutes": "Список скрытых пользователей", "column.notifications": "Уведомления", "column.pins": "Закреплённый пост", "column.public": "Глобальная лента", @@ -70,12 +70,12 @@ "compose_form.hashtag_warning": "Этот пост не будет показывается в поиске по хэштегу, т.к. он непубличный. Только публичные посты можно найти в поиске по хэштегу.", "compose_form.lock_disclaimer": "Ваш аккаунт не {locked}. Любой человек может подписаться на Вас и просматривать посты для подписчиков.", "compose_form.lock_disclaimer.lock": "закрыт", - "compose_form.placeholder": "О чем Вы думаете?", + "compose_form.placeholder": "О чем вы думаете?", "compose_form.poll.add_option": "Добавить", "compose_form.poll.duration": "Длительность опроса", "compose_form.poll.option_placeholder": "Вариант {number}", "compose_form.poll.remove_option": "Удалить этот вариант", - "compose_form.publish": "Трубить", + "compose_form.publish": "Запостить", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive.hide": "Пометить медиафайл как чувствительный", "compose_form.sensitive.marked": "Медиафайлы не отмечены как чувствительные", @@ -117,31 +117,31 @@ "emoji_button.search_results": "Результаты поиска", "emoji_button.symbols": "Символы", "emoji_button.travel": "Путешествия", - "empty_column.account_timeline": "Статусов нет!", + "empty_column.account_timeline": "Здесь нет постов!", "empty_column.account_unavailable": "Профиль недоступен", "empty_column.blocks": "Вы ещё никого не заблокировали.", "empty_column.community": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!", - "empty_column.direct": "У Вас пока нет личных сообщений. Когда Вы начнёте их отправлять или получать, они появятся здесь.", + "empty_column.direct": "У вас пока нет личных сообщений. Как только вы отправите или получите одно, оно появится здесь.", "empty_column.domain_blocks": "Скрытых доменов пока нет.", - "empty_column.favourited_statuses": "Вы не добавили ни одного статуса в 'Избранное'. Как только Вы это сделаете, они появятся здесь.", - "empty_column.favourites": "Никто ещё не добавил этот статус в 'Избранное'. Как только кто-то это сделает, они появятся здесь.", + "empty_column.favourited_statuses": "Вы не добавили ни один пост в «Избранное». Как только вы это сделаете, он появится здесь.", + "empty_column.favourites": "Никто ещё не добавил этот пост в «Избранное». Как только кто-то это сделает, это отобразится здесь.", "empty_column.follow_requests": "Вам ещё не приходили запросы на подписку. Все новые запросы будут показаны здесь.", "empty_column.hashtag": "Статусов с таким хэштегом еще не существует.", - "empty_column.home": "Пока Вы ни на кого не подписаны. Полистайте {public} или используйте поиск, чтобы освоиться и завести новые знакомства.", + "empty_column.home": "Пока вы ни на кого не подписаны. Полистайте {public} или используйте поиск, чтобы освоиться и завести новые знакомства.", "empty_column.home.public_timeline": "публичные ленты", "empty_column.list": "В этом списке пока ничего нет.", - "empty_column.lists": "У Вас ещё нет списков. Все созданные Вами списки будут показаны здесь.", - "empty_column.mutes": "Вы ещё никого не заглушили.", - "empty_column.notifications": "У Вас еще нет уведомлений. Заведите знакомство с другими пользователями, чтобы начать разговор.", + "empty_column.lists": "У вас ещё нет списков. Созданные вами списки будут показаны здесь.", + "empty_column.mutes": "Вы ещё никого не скрывали.", + "empty_column.notifications": "У вас пока нет уведомлений. Взаимодействуйте с другими, чтобы завести разговор.", "empty_column.public": "Здесь ничего нет! Опубликуйте что-нибудь или подпишитесь на пользователей с других узлов, чтобы заполнить ленту.", "follow_request.authorize": "Авторизовать", "follow_request.reject": "Отказать", - "getting_started.developers": "Для разработчиков", + "getting_started.developers": "Разработчикам", "getting_started.directory": "Каталог профилей", "getting_started.documentation": "Документация", "getting_started.heading": "Добро пожаловать", "getting_started.invite": "Пригласить людей", - "getting_started.open_source_notice": "Mastodon - сервис с открытым исходным кодом. Вы можете помочь проекту или сообщить о проблемах на GitHub по адресу {github}.", + "getting_started.open_source_notice": "Mastodon — сервис с открытым исходным кодом. Вы можете внести вклад или сообщить о проблемах на GitHub: {github}.", "getting_started.security": "Безопасность", "getting_started.terms": "Условия использования", "hashtag.column_header.tag_mode.all": "и {additional}", @@ -156,9 +156,10 @@ "home.column_settings.basic": "Основные", "home.column_settings.show_reblogs": "Показывать продвижения", "home.column_settings.show_replies": "Показывать ответы", - "intervals.full.days": "{number, plural, one {# день} few {# дня} many {# дней} other {# дней}}", - "intervals.full.hours": "{number, plural, one {# час} few {# часа} many {# часов} other {# часов}}", - "intervals.full.minutes": "{number, plural, one {# минута} few {# минуты} many {# минут} other {# минут}}", + "home.column_settings.update_live": "Update in real-time", + "intervals.full.days": "{number, plural, one {# день} few {# дня} other {# дней}}", + "intervals.full.hours": "{number, plural, one {# час} few {# часа} other {# часов}}", + "intervals.full.minutes": "{number, plural, one {# минута} few {# минуты} other {# минут}}", "introduction.federation.action": "Далее", "introduction.federation.federated.headline": "Глобальная лента", "introduction.federation.federated.text": "Публичные статусы с других серверов федеративной сети расположатся в глобальной ленте.", @@ -167,7 +168,7 @@ "introduction.federation.local.headline": "Локальная лента", "introduction.federation.local.text": "Публичные статусы от людей с того же сервера, что и вы, будут отображены в локальной ленте.", "introduction.interactions.action": "Завершить обучение", - "introduction.interactions.favourite.headline": "Отметки \"нравится\"", + "introduction.interactions.favourite.headline": "Отметки «нравится»", "introduction.interactions.favourite.text": "Вы можете отметить статус, чтобы вернуться к нему позже и дать знать автору, что запись вам понравилась, поставив отметку \"нравится\".", "introduction.interactions.reblog.headline": "Продвижения", "introduction.interactions.reblog.text": "Вы можете делиться статусами других людей, продвигая их в своём аккаунте.", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Заголовок списка", "lists.search": "Искать из ваших подписок", "lists.subheading": "Ваши списки", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Загрузка...", "media_gallery.toggle_visible": "Показать/скрыть", "missing_indicator.label": "Не найдено", @@ -242,7 +244,7 @@ "navigation_bar.keyboard_shortcuts": "Сочетания клавиш", "navigation_bar.lists": "Списки", "navigation_bar.logout": "Выйти", - "navigation_bar.mutes": "Список глушения", + "navigation_bar.mutes": "Список скрытых пользователей", "navigation_bar.personal": "Личное", "navigation_bar.pins": "Закреплённые посты", "navigation_bar.preferences": "Опции", @@ -314,6 +316,7 @@ "search_results.accounts": "Люди", "search_results.hashtags": "Хэштеги", "search_results.statuses": "Посты", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}", "status.admin_account": "Открыть интерфейс модератора для @{name}", "status.admin_status": "Открыть этот статус в интерфейсе модератора", diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json index 18993af97..3cc2cbaa7 100644 --- a/app/javascript/mastodon/locales/sk.json +++ b/app/javascript/mastodon/locales/sk.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Základné", "home.column_settings.show_reblogs": "Zobraziť povýšené", "home.column_settings.show_replies": "Ukázať odpovede", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# deň} few {# dní} many {# dní} other {# dni}}", "intervals.full.hours": "{number, plural, one {# hodina} few {# hodín} many {# hodín} other {# hodiny}}", "intervals.full.minutes": "{number, plural, one {# minúta} few {# minút} many {# minút} other {# minúty}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Názov nového zoznamu", "lists.search": "Vyhľadávaj medzi užívateľmi, ktorých sleduješ", "lists.subheading": "Tvoje zoznamy", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Načítam...", "media_gallery.toggle_visible": "Zapni/Vypni viditeľnosť", "missing_indicator.label": "Nenájdené", @@ -254,7 +256,7 @@ "notification.mention": "{name} ťa spomenul/a", "notification.poll": "Anketa v ktorej si hlasoval/a sa skončila", "notification.reblog": "{name} zdieľal/a tvoj príspevok", - "notifications.clear": "Vyčistiť zoznam oboznámení", + "notifications.clear": "Vyčisti oboznámenia", "notifications.clear_confirmation": "Naozaj chceš nenávratne prečistiť všetky tvoje oboznámenia?", "notifications.column_settings.alert": "Oboznámenia na ploche", "notifications.column_settings.favourite": "Obľúbené:", @@ -314,6 +316,7 @@ "search_results.accounts": "Ľudia", "search_results.hashtags": "Haštagy", "search_results.statuses": "Príspevky", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {výsledok} many {výsledkov} other {výsledky}}", "status.admin_account": "Otvor moderovacie rozhranie užívateľa @{name}", "status.admin_status": "Otvor tento príspevok v moderovacom rozhraní", diff --git a/app/javascript/mastodon/locales/sl.json b/app/javascript/mastodon/locales/sl.json index 51794a862..f79a7051a 100644 --- a/app/javascript/mastodon/locales/sl.json +++ b/app/javascript/mastodon/locales/sl.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Osnovno", "home.column_settings.show_reblogs": "Pokaži spodbude", "home.column_settings.show_replies": "Pokaži odgovore", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# dan} two {# dni} few {# dni} other {# dni}}", "intervals.full.hours": "{number, plural, one {# ura} two {# uri} few {# ure} other {# ur}}", "intervals.full.minutes": "{number, plural, one {# minuta} two {# minuti} few {# minute} other {# minut}}", @@ -167,7 +168,7 @@ "introduction.federation.local.headline": "Lokalno", "introduction.federation.local.text": "Javne objave ljudi na istem strežniku, se bodo prikazale na lokalni časovnici.", "introduction.interactions.action": "Zaključi vadnico!", - "introduction.interactions.favourite.headline": "Priljubljeni", + "introduction.interactions.favourite.headline": "Vzljubi", "introduction.interactions.favourite.text": "Tut lahko shranite za pozneje in ga vzljubite ter s tem pokažete avtorju, da vam je ta tut priljubljen.", "introduction.interactions.reblog.headline": "Spodbudi", "introduction.interactions.reblog.text": "Tute drugih ljudi lahko delite z vašimi sledilci, tako da spodbudite tute.", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Nov naslov seznama", "lists.search": "Išči med ljudmi, katerim sledite", "lists.subheading": "Vaši seznami", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Nalaganje...", "media_gallery.toggle_visible": "Preklopi vidljivost", "missing_indicator.label": "Ni najdeno", @@ -314,6 +316,7 @@ "search_results.accounts": "Ljudje", "search_results.hashtags": "Ključniki", "search_results.statuses": "Tuti", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {rezultat} other {rezultatov}}", "status.admin_account": "Odpri vmesnik za moderiranje za @{name}", "status.admin_status": "Odpri status v vmesniku za moderiranje", @@ -351,29 +354,29 @@ "status.show_less_all": "Prikaži manj za vse", "status.show_more": "Prikaži več", "status.show_more_all": "Prikaži več za vse", - "status.show_thread": "Show thread", + "status.show_thread": "Prikaži objavo", "status.unmute_conversation": "Odtišaj pogovor", "status.unpin": "Odpni iz profila", - "suggestions.dismiss": "Dismiss suggestion", - "suggestions.header": "You might be interested in…", + "suggestions.dismiss": "Zavrni predlog", + "suggestions.header": "Morda bi vas zanimalo…", "tabs_bar.federated_timeline": "Združeno", "tabs_bar.home": "Domov", "tabs_bar.local_timeline": "Lokalno", "tabs_bar.notifications": "Obvestila", - "tabs_bar.search": "Poišči", - "time_remaining.days": "{number, plural, one {# day} other {# days}} left", - "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", - "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", - "time_remaining.moments": "Moments remaining", - "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", - "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", + "tabs_bar.search": "Iskanje", + "time_remaining.days": "{number, plural, one {# dan} other {# dni}} je ostalo", + "time_remaining.hours": "{number, plural, one {# ura} other {# ur}} je ostalo", + "time_remaining.minutes": "{number, plural, one {# minuta} other {# minut}} je ostalo", + "time_remaining.moments": "Preostali trenutki", + "time_remaining.seconds": "{number, plural, one {# sekunda} other {# sekund}} je ostalo", + "trends.count_by_accounts": "{count} {rawCount, plural, one {oseba} other {ljudi}} govori", "ui.beforeunload": "Vaš osnutek bo izgubljen, če zapustite Mastodona.", - "upload_area.title": "Povlecite in spustite za pošiljanje", - "upload_button.label": "Dodaj medij", - "upload_error.limit": "File upload limit exceeded.", - "upload_error.poll": "File upload not allowed with polls.", + "upload_area.title": "Za pošiljanje povlecite in spustite", + "upload_button.label": "Dodaj medije ({formats})", + "upload_error.limit": "Omejitev prenosa datoteke je presežena.", + "upload_error.poll": "Prenos datoteke z anketami ni dovoljen.", "upload_form.description": "Opišite za slabovidne", - "upload_form.focus": "Obreži", + "upload_form.focus": "Spremeni predogled", "upload_form.undo": "Izbriši", "upload_progress.label": "Pošiljanje...", "video.close": "Zapri video", diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json index 13ce4e978..21d45f2e8 100644 --- a/app/javascript/mastodon/locales/sq.json +++ b/app/javascript/mastodon/locales/sq.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Bazë", "home.column_settings.show_reblogs": "Shfaq përforcime", "home.column_settings.show_replies": "Shfaq përgjigje", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Titull liste të re", "lists.search": "Kërkoni mes personash që ndiqni", "lists.subheading": "Listat tuaja", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Po ngarkohet…", "media_gallery.toggle_visible": "Ndërroni dukshmërinë", "missing_indicator.label": "S’u gjet", @@ -314,6 +316,7 @@ "search_results.accounts": "Persona", "search_results.hashtags": "Hashtagë", "search_results.statuses": "Mesazhe", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, një {result} {results} të tjera}", "status.admin_account": "Hap ndërfaqe moderimi për @{name}", "status.admin_status": "Hape këtë gjendje te ndërfaqja e moderimit", diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json index 8f8ca7c30..55bae4cdd 100644 --- a/app/javascript/mastodon/locales/sr-Latn.json +++ b/app/javascript/mastodon/locales/sr-Latn.json @@ -64,7 +64,7 @@ "column_header.show_settings": "Prikaži postavke", "column_header.unpin": "Otkači", "column_subheading.settings": "Postavke", - "community.column_settings.media_only": "Media Only", + "community.column_settings.media_only": "Media only", "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", @@ -156,6 +156,7 @@ "home.column_settings.basic": "Osnovno", "home.column_settings.show_reblogs": "Prikaži i podržavanja", "home.column_settings.show_replies": "Prikaži odgovore", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Naslov nove liste", "lists.search": "Pretraži među ljudima koje pratite", "lists.subheading": "Vaše liste", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Učitavam...", "media_gallery.toggle_visible": "Uključi/isključi vidljivost", "missing_indicator.label": "Nije pronađeno", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {rezultat} few {rezultata} other {rezultata}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json index 8ef18a774..a4ae9fcaa 100644 --- a/app/javascript/mastodon/locales/sr.json +++ b/app/javascript/mastodon/locales/sr.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Основно", "home.column_settings.show_reblogs": "Прикажи и подржавања", "home.column_settings.show_replies": "Прикажи одговоре", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Наслов нове листе", "lists.search": "Претражи међу људима које пратите", "lists.subheading": "Ваше листе", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Учитавам...", "media_gallery.toggle_visible": "Укључи/искључи видљивост", "missing_indicator.label": "Није пронађено", @@ -314,6 +316,7 @@ "search_results.accounts": "Људи", "search_results.hashtags": "Тарабе", "search_results.statuses": "Трубе", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {резултат} few {резултата} other {резултата}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json index ab12be885..fda5c4d57 100644 --- a/app/javascript/mastodon/locales/sv.json +++ b/app/javascript/mastodon/locales/sv.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Grundläggande", "home.column_settings.show_reblogs": "Visa knuffar", "home.column_settings.show_replies": "Visa svar", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Ny listrubrik", "lists.search": "Sök bland personer du följer", "lists.subheading": "Dina listor", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Laddar...", "media_gallery.toggle_visible": "Växla synlighet", "missing_indicator.label": "Hittades inte", @@ -314,6 +316,7 @@ "search_results.accounts": "Människor", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, ett {result} andra {results}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/ta.json b/app/javascript/mastodon/locales/ta.json index 637ca884a..87163e660 100644 --- a/app/javascript/mastodon/locales/ta.json +++ b/app/javascript/mastodon/locales/ta.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "அடிப்படையான", "home.column_settings.show_reblogs": "காட்டு boosts", "home.column_settings.show_replies": "பதில்களைக் காண்பி", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} மற்ற {# days}}", "intervals.full.hours": "{number, plural, one {# hour} மற்ற {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} மற்ற {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "புதிய பட்டியல் தலைப்பு", "lists.search": "நீங்கள் பின்தொடரும் நபர்கள் மத்தியில் தேடுதல்", "lists.subheading": "உங்கள் பட்டியல்கள்", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "ஏற்றுதல்...", "media_gallery.toggle_visible": "நிலைமாற்று தெரியும்", "missing_indicator.label": "கிடைக்கவில்லை", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "ஹாஷ்டேக்குகளைச்", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} மற்ற {results}}", "status.admin_account": "மிதமான இடைமுகத்தை திறக்க @{name}", "status.admin_status": "மிதமான இடைமுகத்தில் இந்த நிலையை திறக்கவும்", diff --git a/app/javascript/mastodon/locales/te.json b/app/javascript/mastodon/locales/te.json index 269ea45c3..ccb608812 100644 --- a/app/javascript/mastodon/locales/te.json +++ b/app/javascript/mastodon/locales/te.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "ప్రాథమిక", "home.column_settings.show_reblogs": "బూస్ట్ లను చూపించు", "home.column_settings.show_replies": "ప్రత్యుత్తరాలను చూపించు", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "కొత్త జాబితా శీర్షిక", "lists.search": "మీరు అనుసరించే వ్యక్తులలో శోధించండి", "lists.subheading": "మీ జాబితాలు", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "లోడ్ అవుతోంది...", "media_gallery.toggle_visible": "దృశ్యమానతను టోగుల్ చేయండి", "missing_indicator.label": "దొరకలేదు", @@ -314,6 +316,7 @@ "search_results.accounts": "వ్యక్తులు", "search_results.hashtags": "హాష్ ట్యాగ్లు", "search_results.statuses": "టూట్లు", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.admin_account": "@{name} కొరకు సమన్వయ వినిమయసీమను తెరువు", "status.admin_status": "సమన్వయ వినిమయసీమలో ఈ స్టేటస్ ను తెరవండి", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index 3bcf389c7..e8d7a27ed 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -72,7 +72,7 @@ "compose_form.lock_disclaimer.lock": "ล็อคอยู่", "compose_form.placeholder": "คุณกำลังคิดอะไรอยู่?", "compose_form.poll.add_option": "เพิ่มทางเลือก", - "compose_form.poll.duration": "Poll duration", + "compose_form.poll.duration": "ระยะเวลาการหยั่งเสียง", "compose_form.poll.option_placeholder": "ทางเลือก {number}", "compose_form.poll.remove_option": "เอาทางเลือกนี้ออก", "compose_form.publish": "โพสต์", @@ -156,9 +156,10 @@ "home.column_settings.basic": "พื้นฐาน", "home.column_settings.show_reblogs": "แสดงการดัน", "home.column_settings.show_replies": "แสดงการตอบกลับ", - "intervals.full.days": "{number, plural, one {# day} other {# days}}", - "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", - "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", + "home.column_settings.update_live": "Update in real-time", + "intervals.full.days": "{number, plural, other {# วัน}}", + "intervals.full.hours": "{number, plural, other {# ชั่วโมง}}", + "intervals.full.minutes": "{number, plural, other {# นาที}}", "introduction.federation.action": "ถัดไป", "introduction.federation.federated.headline": "ที่ติดต่อกับภายนอก", "introduction.federation.federated.text": "โพสต์สาธารณะจากเซิร์ฟเวอร์อื่น ๆ ของ Fediverse จะปรากฏในเส้นเวลาที่ติดต่อกับภายนอก", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "ชื่อเรื่องรายการใหม่", "lists.search": "ค้นหาในหมู่ผู้คนที่คุณติดตาม", "lists.subheading": "รายการของคุณ", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "กำลังโหลด...", "media_gallery.toggle_visible": "เปิด/ปิดการมองเห็น", "missing_indicator.label": "ไม่พบ", @@ -252,7 +254,7 @@ "notification.favourite": "{name} ได้ชื่นชอบสถานะของคุณ", "notification.follow": "{name} ได้ติดตามคุณ", "notification.mention": "{name} ได้กล่าวถึงคุณ", - "notification.poll": "A poll you have voted in has ended", + "notification.poll": "การหยั่งเสียงที่คุณได้ลงคะแนนได้สิ้นสุดแล้ว", "notification.reblog": "{name} ได้ดันสถานะของคุณ", "notifications.clear": "ล้างการแจ้งเตือน", "notifications.clear_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการล้างการแจ้งเตือนทั้งหมดของคุณอย่างถาวร?", @@ -263,7 +265,7 @@ "notifications.column_settings.filter_bar.show": "แสดง", "notifications.column_settings.follow": "ผู้ติดตามใหม่:", "notifications.column_settings.mention": "การกล่าวถึง:", - "notifications.column_settings.poll": "Poll results:", + "notifications.column_settings.poll": "ผลลัพธ์การหยั่งเสียง:", "notifications.column_settings.push": "การแจ้งเตือนแบบผลัก", "notifications.column_settings.reblog": "การดัน:", "notifications.column_settings.show": "แสดงในคอลัมน์", @@ -273,14 +275,14 @@ "notifications.filter.favourites": "รายการโปรด", "notifications.filter.follows": "การติดตาม", "notifications.filter.mentions": "การกล่าวถึง", - "notifications.filter.polls": "Poll results", + "notifications.filter.polls": "ผลลัพธ์การหยั่งเสียง", "notifications.group": "{count} การแจ้งเตือน", "poll.closed": "ปิดแล้ว", "poll.refresh": "รีเฟรช", - "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", - "poll.vote": "Vote", - "poll_button.add_poll": "Add a poll", - "poll_button.remove_poll": "Remove poll", + "poll.total_votes": "{count, plural, other {# การลงคะแนน}}", + "poll.vote": "ลงคะแนน", + "poll_button.add_poll": "เพิ่มการหยั่งเสียง", + "poll_button.remove_poll": "เอาการหยั่งเสียงออก", "privacy.change": "ปรับเปลี่ยนความเป็นส่วนตัวของสถานะ", "privacy.direct.long": "โพสต์ไปยังผู้ใช้ที่กล่าวถึงเท่านั้น", "privacy.direct.short": "โดยตรง", @@ -314,7 +316,8 @@ "search_results.accounts": "ผู้คน", "search_results.hashtags": "แฮชแท็ก", "search_results.statuses": "โพสต์", - "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", + "search_results.total": "{count, number} {count, plural, other {ผลลัพธ์}}", "status.admin_account": "เปิดส่วนติดต่อการควบคุมสำหรับ @{name}", "status.admin_status": "เปิดสถานะนี้ในส่วนติดต่อการควบคุม", "status.block": "ปิดกั้น @{name}", @@ -361,11 +364,11 @@ "tabs_bar.local_timeline": "ในเว็บ", "tabs_bar.notifications": "การแจ้งเตือน", "tabs_bar.search": "ค้นหา", - "time_remaining.days": "{number, plural, one {# day} other {# days}} left", - "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", - "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", + "time_remaining.days": "เหลืออีก {number, plural, other {# วัน}}", + "time_remaining.hours": "เหลืออีก {number, plural, other {# ชั่วโมง}}", + "time_remaining.minutes": "เหลืออีก {number, plural, other {# นาที}}", "time_remaining.moments": "ช่วงเวลาที่เหลือ", - "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", + "time_remaining.seconds": "เหลืออีก {number, plural, other {# วินาที}}", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "ui.beforeunload": "แบบร่างของคุณจะหายไปหากคุณออกจาก Mastodon", "upload_area.title": "ลากแล้วปล่อยเพื่ออัปโหลด", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index ec4657b9b..0ea015cc6 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Temel", "home.column_settings.show_reblogs": "Boost edilenleri göster", "home.column_settings.show_replies": "Cevapları göster", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Yeni liste başlığı", "lists.search": "Takip ettiğiniz kişiler arasından arayın", "lists.subheading": "Listeleriniz", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Yükleniyor...", "media_gallery.toggle_visible": "Görünürlüğü değiştir", "missing_indicator.label": "Bulunamadı", @@ -314,6 +316,7 @@ "search_results.accounts": "İnsanlar", "search_results.hashtags": "Hashtagler", "search_results.statuses": "Gönderiler", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}", "status.admin_account": "@{name} için denetim arayüzünü açın", "status.admin_status": "Denetim arayüzünde bu durumu açın", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index 124b9fb07..17e8cb49f 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "Основні", "home.column_settings.show_reblogs": "Показувати передмухи", "home.column_settings.show_replies": "Показувати відповіді", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "Нова назва списку", "lists.search": "Шукати серед людей, на яких ви підписані", "lists.subheading": "Ваші списки", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Завантаження...", "media_gallery.toggle_visible": "Показати/приховати", "missing_indicator.label": "Не знайдено", @@ -314,6 +316,7 @@ "search_results.accounts": "People", "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 865d3a514..bb774f1aa 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -12,7 +12,7 @@ "account.followers": "关注者", "account.followers.empty": "目前无人关注此用户。", "account.follows": "正在关注", - "account.follows.empty": "此用户目前没有关注任何人。", + "account.follows.empty": "此用户目前尚未关注任何人。", "account.follows_you": "关注了你", "account.hide_reblogs": "隐藏来自 @{name} 的转嘟", "account.link_verified_on": "此链接的所有权已在 {date} 检查", @@ -48,7 +48,7 @@ "column.community": "本站时间轴", "column.direct": "私信", "column.domain_blocks": "已屏蔽的网站", - "column.favourites": "收藏过的嘟文", + "column.favourites": "收藏", "column.follow_requests": "关注请求", "column.home": "主页", "column.lists": "列表", @@ -92,11 +92,11 @@ "confirmations.delete_list.confirm": "删除", "confirmations.delete_list.message": "你确定要永久删除这个列表吗?", "confirmations.domain_block.confirm": "隐藏整个网站的内容", - "confirmations.domain_block.message": "你真的确定要隐藏所有来自 {domain} 的内容吗?多数情况下,屏蔽或隐藏几个特定的用户应该就能满足你的需要了。来自该网站的内容将不再出现在你的公共时间轴或通知列表里。来自该网站的关注者将会被移除。", + "confirmations.domain_block.message": "你真的确定要隐藏所有来自 {domain} 的内容吗?多数情况下,屏蔽或隐藏几个特定的用户就已经足够了。来自该网站的内容将不再出现在你的任何公共时间轴或通知列表里。来自该网站的关注者将会被移除。", "confirmations.mute.confirm": "隐藏", "confirmations.mute.message": "你确定要隐藏 {name} 吗?", "confirmations.redraft.confirm": "删除并重新编辑", - "confirmations.redraft.message": "你确定要删除这条嘟文并重新编辑它吗?所有相关的转嘟和收藏都会被清除,回复将会被孤立。", + "confirmations.redraft.message": "你确定要删除这条嘟文并重新编辑它吗?所有相关的转嘟和收藏都会被清除,回复将会失去关联。", "confirmations.reply.confirm": "回复", "confirmations.reply.message": "回复此消息将会覆盖当前正在编辑的信息。确定继续吗?", "confirmations.unfollow.confirm": "取消关注", @@ -120,28 +120,28 @@ "empty_column.account_timeline": "这里没有嘟文!", "empty_column.account_unavailable": "个人资料不可用", "empty_column.blocks": "你目前没有屏蔽任何用户。", - "empty_column.community": "本站时间轴暂时没有内容,快嘟几个来抢头香啊!", + "empty_column.community": "本站时间轴暂时没有内容,快写点什么让它动起来吧!", "empty_column.direct": "你还没有使用过私信。当你发出或者收到私信时,它会在这里显示。", "empty_column.domain_blocks": "目前没有被隐藏的站点。", "empty_column.favourited_statuses": "你还没有收藏过任何嘟文。收藏过的嘟文会显示在这里。", - "empty_column.favourites": "没人收藏过这条嘟文。假如有人收藏了,就会显示在这里。", + "empty_column.favourites": "没有人收藏过这条嘟文。如果有人收藏了,就会显示在这里。", "empty_column.follow_requests": "你没有收到新的关注请求。收到了之后就会显示在这里。", "empty_column.hashtag": "这个话题标签下暂时没有内容。", - "empty_column.home": "你还没有关注任何用户。快看看{public},向其他用户搭讪吧。", + "empty_column.home": "你还没有关注任何用户。快看看{public},向其他人问个好吧。", "empty_column.home.public_timeline": "公共时间轴", "empty_column.list": "这个列表中暂时没有内容。列表中用户所发送的的新嘟文将会在这里显示。", - "empty_column.lists": "你没有创建过列表。你创建的列表会在这里显示。", + "empty_column.lists": "你还没有创建过列表。你创建的列表会在这里显示。", "empty_column.mutes": "你没有隐藏任何用户。", - "empty_column.notifications": "你还没有收到过任何通知,快向其他用户搭讪吧。", + "empty_column.notifications": "你还没有收到过任何通知,快和其他用户互动吧。", "empty_column.public": "这里什么都没有!写一些公开的嘟文,或者关注其他服务器的用户后,这里就会有嘟文出现了", "follow_request.authorize": "同意", "follow_request.reject": "拒绝", "getting_started.developers": "开发", - "getting_started.directory": "用户资料目录", + "getting_started.directory": "用户目录", "getting_started.documentation": "文档", "getting_started.heading": "开始使用", "getting_started.invite": "邀请用户", - "getting_started.open_source_notice": "Mastodon 是一个开源软件。欢迎前往 GitHub({github})贡献代码或反馈问题。", + "getting_started.open_source_notice": "Mastodon 是开源软件。欢迎前往 GitHub({github})贡献代码或反馈问题。", "getting_started.security": "帐户安全", "getting_started.terms": "使用条款", "hashtag.column_header.tag_mode.all": "以及 {additional}", @@ -156,14 +156,15 @@ "home.column_settings.basic": "基本设置", "home.column_settings.show_reblogs": "显示转嘟", "home.column_settings.show_replies": "显示回复", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number} 天", "intervals.full.hours": "{number} 小时", "intervals.full.minutes": "{number} 分钟", "introduction.federation.action": "下一步", "introduction.federation.federated.headline": "跨站", - "introduction.federation.federated.text": "其他跨站服务器的公共动态会显示在跨站时间线中。", + "introduction.federation.federated.text": "联邦宇宙中其他服务器的公开嘟文会显示在跨站时间轴中。", "introduction.federation.home.headline": "主页", - "introduction.federation.home.text": "你所关注的用户的动态会显示在主页里。你可以关注任何服务器上的任何人!", + "introduction.federation.home.text": "你所关注的用户的动态会显示在主页里。你可以关注任何服务器上的任何人!", "introduction.federation.local.headline": "本站", "introduction.federation.local.text": "你所关注的用户的动态会显示在主页里,你可以关注任何服务器上的任何人。", "introduction.interactions.action": "教程结束!", @@ -172,10 +173,10 @@ "introduction.interactions.reblog.headline": "转嘟", "introduction.interactions.reblog.text": "通过转嘟,你可以向你的关注者分享其他人的嘟文。", "introduction.interactions.reply.headline": "回复", - "introduction.interactions.reply.text": "你可以向其他人回复,这些回复会像对话一样串在一起。", + "introduction.interactions.reply.text": "你可以回复其他嘟文,这些回复会像对话一样关联在一起。", "introduction.welcome.action": "让我们开始吧!", "introduction.welcome.headline": "首先", - "introduction.welcome.text": "欢迎来到联邦!稍后,您将可以广播消息并和您的朋友交流,这些消息将穿越于联邦中的各式服务器。但是这台服务器,{domain},是特殊的——它保存了你的个人资料,所以请记住它的名字。", + "introduction.welcome.text": "欢迎来到联邦宇宙!很快,您就可以发布信息并和您的朋友交流,这些消息将发送到联邦中的各个服务器中。但是这台服务器,{domain},是特殊的——它保存了你的个人资料,所以请记住它的名字。", "keyboard_shortcuts.back": "返回上一页", "keyboard_shortcuts.blocked": "打开被屏蔽用户列表", "keyboard_shortcuts.boost": "转嘟", @@ -194,9 +195,9 @@ "keyboard_shortcuts.legend": "显示此列表", "keyboard_shortcuts.local": "打开本站时间轴", "keyboard_shortcuts.mention": "提及嘟文作者", - "keyboard_shortcuts.muted": "打开屏蔽用户列表", + "keyboard_shortcuts.muted": "打开隐藏用户列表", "keyboard_shortcuts.my_profile": "打开你的个人资料", - "keyboard_shortcuts.notifications": "打卡通知栏", + "keyboard_shortcuts.notifications": "打开通知栏", "keyboard_shortcuts.pinned": "打开置顶嘟文列表", "keyboard_shortcuts.profile": "打开作者的个人资料", "keyboard_shortcuts.reply": "回复嘟文", @@ -213,7 +214,7 @@ "lightbox.previous": "上一个", "lightbox.view_context": "查看上下文", "lists.account.add": "添加到列表", - "lists.account.remove": "从列表中删除", + "lists.account.remove": "从列表中移除", "lists.delete": "删除列表", "lists.edit": "编辑列表", "lists.edit.submit": "更改标题", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "新列表的标题", "lists.search": "搜索你关注的人", "lists.subheading": "你的列表", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "加载中……", "media_gallery.toggle_visible": "切换显示/隐藏", "missing_indicator.label": "找不到内容", @@ -235,29 +237,29 @@ "navigation_bar.domain_blocks": "已屏蔽的网站", "navigation_bar.edit_profile": "修改个人资料", "navigation_bar.favourites": "收藏的内容", - "navigation_bar.filters": "被隐藏的词", + "navigation_bar.filters": "屏蔽关键词", "navigation_bar.follow_requests": "关注请求", - "navigation_bar.follows_and_followers": "正在关注以及关注者", + "navigation_bar.follows_and_followers": "关注管理", "navigation_bar.info": "关于本站", "navigation_bar.keyboard_shortcuts": "快捷键列表", "navigation_bar.lists": "列表", - "navigation_bar.logout": "注销", + "navigation_bar.logout": "登出", "navigation_bar.mutes": "已隐藏的用户", "navigation_bar.personal": "个人", "navigation_bar.pins": "置顶嘟文", "navigation_bar.preferences": "首选项", - "navigation_bar.profile_directory": "用户资料目录", + "navigation_bar.profile_directory": "用户目录", "navigation_bar.public_timeline": "跨站公共时间轴", "navigation_bar.security": "安全", "notification.favourite": "{name} 收藏了你的嘟文", "notification.follow": "{name} 开始关注你", - "notification.mention": "{name} 提及你", + "notification.mention": "{name} 提及了你", "notification.poll": "你参与的一个投票已经结束", - "notification.reblog": "{name} 转了你的嘟文", + "notification.reblog": "{name} 转嘟了你的嘟文", "notifications.clear": "清空通知列表", "notifications.clear_confirmation": "你确定要永久清空通知列表吗?", "notifications.column_settings.alert": "桌面通知", - "notifications.column_settings.favourite": "你的嘟文被收藏时:", + "notifications.column_settings.favourite": "当你的嘟文被收藏时:", "notifications.column_settings.filter_bar.advanced": "显示所有类别", "notifications.column_settings.filter_bar.category": "快速过滤栏", "notifications.column_settings.filter_bar.show": "显示", @@ -301,25 +303,26 @@ "report.forward": "发送举报至 {target}", "report.forward_hint": "这名用户来自另一个服务器。是否要向那个服务器发送一条匿名的举报?", "report.hint": "举报将会发送给你所在服务器的监察员。你可以在下面填写举报该用户的理由:", - "report.placeholder": "附言", + "report.placeholder": "备注", "report.submit": "提交", "report.target": "举报 {target}", "search.placeholder": "搜索", "search_popout.search_format": "高级搜索格式", - "search_popout.tips.full_text": "输入其他内容将会返回所有你撰写、收藏、转嘟过或提及到你的嘟文,同时也会在用户名、昵称和话题标签中进行搜索。", + "search_popout.tips.full_text": "输入关键词检索所有你发送、收藏、转嘟过或提及到你的嘟文,以及其他用户公开的用户名、昵称和话题标签。", "search_popout.tips.hashtag": "话题标签", "search_popout.tips.status": "嘟文", - "search_popout.tips.text": "输入其他内容将会返回昵称、用户名和话题标签", + "search_popout.tips.text": "输入关键词检索昵称、用户名和话题标签", "search_popout.tips.user": "用户", "search_results.accounts": "用户", "search_results.hashtags": "话题标签", "search_results.statuses": "嘟文", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "共 {count, number} 个结果", "status.admin_account": "打开 @{name} 的管理界面", "status.admin_status": "打开这条嘟文的管理界面", "status.block": "屏蔽 @{name}", "status.cancel_reblog_private": "取消转嘟", - "status.cannot_reblog": "无法转嘟这条嘟文", + "status.cannot_reblog": "这条嘟文不允许被转嘟", "status.copy": "复制嘟文链接", "status.delete": "删除", "status.detailed_status": "对话详情", @@ -338,9 +341,9 @@ "status.pinned": "置顶嘟文", "status.read_more": "阅读全文", "status.reblog": "转嘟", - "status.reblog_private": "转嘟给原有关注者", + "status.reblog_private": "转嘟(可见者不变)", "status.reblogged_by": "{name} 转嘟了", - "status.reblogs.empty": "无人转嘟此条。如果有人转嘟了,就会显示在这里。", + "status.reblogs.empty": "没有人转嘟过此条嘟文。如果有人转嘟了,就会显示在这里。", "status.redraft": "删除并重新编辑", "status.reply": "回复", "status.replyAll": "回复所有人", @@ -367,15 +370,15 @@ "time_remaining.moments": "即将结束", "time_remaining.seconds": "剩余 {number, plural, one {# 秒} other {# 秒}}", "trends.count_by_accounts": "{count} 人正在讨论", - "ui.beforeunload": "如果你现在离开 Mastodon,你的草稿内容将会被丢弃。", + "ui.beforeunload": "如果你现在离开 Mastodon,你的草稿内容将会丢失。", "upload_area.title": "将文件拖放到此处开始上传", "upload_button.label": "上传媒体文件 (JPEG, PNG, GIF, WebM, MP4, MOV)", - "upload_error.limit": "超过文件上传限制。", + "upload_error.limit": "文件大小超过限制。", "upload_error.poll": "投票中不允许上传文件。", "upload_form.description": "为视觉障碍人士添加文字说明", - "upload_form.focus": "剪裁", + "upload_form.focus": "设置缩略图", "upload_form.undo": "删除", - "upload_progress.label": "上传中…", + "upload_progress.label": "上传中……", "video.close": "关闭视频", "video.exit_fullscreen": "退出全屏", "video.expand": "展开视频", diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index 2cfc11703..b4c8b874a 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "基本", "home.column_settings.show_reblogs": "顯示被轉推的文章", "home.column_settings.show_replies": "顯示回應文章", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "新列表標題", "lists.search": "從你關注的用戶中搜索", "lists.subheading": "列表", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "載入中...", "media_gallery.toggle_visible": "打開或關上", "missing_indicator.label": "找不到內容", @@ -314,6 +316,7 @@ "search_results.accounts": "使用者", "search_results.hashtags": "標籤", "search_results.statuses": "文章", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} 項結果", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 5715ef01a..5f75b38d6 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -156,6 +156,7 @@ "home.column_settings.basic": "基本", "home.column_settings.show_reblogs": "顯示轉推", "home.column_settings.show_replies": "顯示回覆", + "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# 天} other {# 天}}", "intervals.full.hours": "{number, plural, one {# 小時} other {# 小時}}", "intervals.full.minutes": "{number, plural, one {# 分鐘} other {# 分鐘}}", @@ -221,6 +222,7 @@ "lists.new.title_placeholder": "新名單標題", "lists.search": "搜尋您關注的使用者", "lists.subheading": "您的名單", + "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "讀取中...", "media_gallery.toggle_visible": "切換可見性", "missing_indicator.label": "找不到", @@ -314,6 +316,7 @@ "search_results.accounts": "使用者", "search_results.hashtags": "主題標籤", "search_results.statuses": "嘟文", + "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.", "search_results.total": "{count, number} 項結果", "status.admin_account": "開啟 @{name} 的管理介面", "status.admin_status": "在管理介面開啟此嘟文", diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 4d9604de9..e94a4946b 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -6,6 +6,7 @@ import { NOTIFICATIONS_FILTER_SET, NOTIFICATIONS_CLEAR, NOTIFICATIONS_SCROLL_TOP, + NOTIFICATIONS_LOAD_PENDING, } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS, @@ -16,6 +17,7 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import compareId from '../compare_id'; const initialState = ImmutableMap({ + pendingItems: ImmutableList(), items: ImmutableList(), hasMore: true, top: false, @@ -31,7 +33,11 @@ const notificationToMap = notification => ImmutableMap({ status: notification.status ? notification.status.id : null, }); -const normalizeNotification = (state, notification) => { +const normalizeNotification = (state, notification, usePendingItems) => { + if (usePendingItems) { + return state.update('pendingItems', list => list.unshift(notificationToMap(notification))); + } + const top = state.get('top'); if (!top) { @@ -47,7 +53,7 @@ const normalizeNotification = (state, notification) => { }); }; -const expandNormalizedNotifications = (state, notifications, next) => { +const expandNormalizedNotifications = (state, notifications, next, usePendingItems) => { let items = ImmutableList(); notifications.forEach((n, i) => { @@ -56,7 +62,7 @@ const expandNormalizedNotifications = (state, notifications, next) => { return state.withMutations(mutable => { if (!items.isEmpty()) { - mutable.update('items', list => { + mutable.update(usePendingItems ? 'pendingItems' : 'items', list => { const lastIndex = 1 + list.findLastIndex( item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id')) ); @@ -78,7 +84,8 @@ const expandNormalizedNotifications = (state, notifications, next) => { }; const filterNotifications = (state, relationship) => { - return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id)); + const helper = list => list.filterNot(item => item !== null && item.get('account') === relationship.id); + return state.update('items', helper).update('pendingItems', helper); }; const updateTop = (state, top) => { @@ -90,34 +97,37 @@ const updateTop = (state, top) => { }; const deleteByStatus = (state, statusId) => { - return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId)); + const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId); + return state.update('items', helper).update('pendingItems', helper); }; export default function notifications(state = initialState, action) { switch(action.type) { + case NOTIFICATIONS_LOAD_PENDING: + return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); case NOTIFICATIONS_EXPAND_REQUEST: return state.set('isLoading', true); case NOTIFICATIONS_EXPAND_FAIL: return state.set('isLoading', false); case NOTIFICATIONS_FILTER_SET: - return state.set('items', ImmutableList()).set('hasMore', true); + return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true); case NOTIFICATIONS_SCROLL_TOP: return updateTop(state, action.top); case NOTIFICATIONS_UPDATE: - return normalizeNotification(state, action.notification); + return normalizeNotification(state, action.notification, action.usePendingItems); case NOTIFICATIONS_EXPAND_SUCCESS: - return expandNormalizedNotifications(state, action.notifications, action.next); + return expandNormalizedNotifications(state, action.notifications, action.next, action.usePendingItems); case ACCOUNT_BLOCK_SUCCESS: return filterNotifications(state, action.relationship); case ACCOUNT_MUTE_SUCCESS: return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state; case NOTIFICATIONS_CLEAR: - return state.set('items', ImmutableList()).set('hasMore', false); + return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false); case TIMELINE_DELETE: return deleteByStatus(state, action.id); case TIMELINE_DISCONNECT: return action.timeline === 'home' ? - state.update('items', items => items.first() ? items.unshift(null) : items) : + state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : state; default: return state; diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index a0eea137f..033bfc999 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -10,8 +10,6 @@ import uuid from '../uuid'; const initialState = ImmutableMap({ saved: true, - onboarded: false, - skinTone: 1, home: ImmutableMap({ @@ -74,10 +72,6 @@ const initialState = ImmutableMap({ body: '', }), }), - - trends: ImmutableMap({ - show: true, - }), }); const defaultColumns = fromJS([ diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index 309a95a19..0b036f5fe 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -8,6 +8,7 @@ import { TIMELINE_SCROLL_TOP, TIMELINE_CONNECT, TIMELINE_DISCONNECT, + TIMELINE_LOAD_PENDING, } from '../actions/timelines'; import { ACCOUNT_BLOCK_SUCCESS, @@ -25,10 +26,11 @@ const initialTimeline = ImmutableMap({ top: true, isLoading: false, hasMore: true, + pendingItems: ImmutableList(), items: ImmutableList(), }); -const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => { +const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { mMap.set('isLoading', false); mMap.set('isPartial', isPartial); @@ -38,7 +40,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is if (timeline.endsWith(':pinned')) { mMap.set('items', statuses.map(status => status.get('id'))); } else if (!statuses.isEmpty()) { - mMap.update('items', ImmutableList(), oldIds => { + mMap.update(usePendingItems ? 'pendingItems' : 'items', ImmutableList(), oldIds => { const newIds = statuses.map(status => status.get('id')); const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1; @@ -57,7 +59,15 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is })); }; -const updateTimeline = (state, timeline, status) => { +const updateTimeline = (state, timeline, status, usePendingItems) => { + if (usePendingItems) { + if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) { + return state; + } + + return state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id')))); + } + const top = state.getIn([timeline, 'top']); const ids = state.getIn([timeline, 'items'], ImmutableList()); const includesId = ids.includes(status.get('id')); @@ -78,8 +88,10 @@ const updateTimeline = (state, timeline, status) => { const deleteStatus = (state, id, accountId, references, exclude_account = null) => { state.keySeq().forEach(timeline => { - if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) - state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); + if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) { + const helper = list => list.filterNot(item => item === id); + state = state.updateIn([timeline, 'items'], helper).updateIn([timeline, 'pendingItems'], helper); + } }); // Remove reblogs of deleted status @@ -109,11 +121,10 @@ const filterTimelines = (state, relationship, statuses) => { return state; }; -const filterTimeline = (timeline, state, relationship, statuses) => - state.updateIn([timeline, 'items'], ImmutableList(), list => - list.filterNot(statusId => - statuses.getIn([statusId, 'account']) === relationship.id - )); +const filterTimeline = (timeline, state, relationship, statuses) => { + const helper = list => list.filterNot(statusId => statuses.getIn([statusId, 'account']) === relationship.id); + return state.updateIn([timeline, 'items'], ImmutableList(), helper).updateIn([timeline, 'pendingItems'], ImmutableList(), helper); +}; const updateTop = (state, timeline, top) => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { @@ -124,14 +135,17 @@ const updateTop = (state, timeline, top) => { export default function timelines(state = initialState, action) { switch(action.type) { + case TIMELINE_LOAD_PENDING: + return state.update(action.timeline, initialTimeline, map => + map.update('items', list => map.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0)); case TIMELINE_EXPAND_REQUEST: return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); case TIMELINE_EXPAND_FAIL: return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); case TIMELINE_EXPAND_SUCCESS: - return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent); + return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems); case TIMELINE_UPDATE: - return updateTimeline(state, action.timeline, fromJS(action.status)); + return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); case TIMELINE_CLEAR: @@ -149,7 +163,7 @@ export default function timelines(state = initialState, action) { return state.update( action.timeline, initialTimeline, - map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items) + map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) ); default: return state; diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 69e6ba0ec..69441d315 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -91,14 +91,6 @@ function main() { if (parallaxComponents.length > 0 ) { new Rellax('.parallax', { speed: -1 }); } - - if (document.body.classList.contains('with-modals')) { - const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - const scrollbarWidthStyle = document.createElement('style'); - scrollbarWidthStyle.id = 'scrollbar-width'; - document.head.appendChild(scrollbarWidthStyle); - scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0); - } }); } diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss index 6db3bc3dc..8ebc45b62 100644 --- a/app/javascript/styles/application.scss +++ b/app/javascript/styles/application.scss @@ -13,7 +13,7 @@ @import 'mastodon/widgets'; @import 'mastodon/forms'; @import 'mastodon/accounts'; -@import 'mastodon/stream_entries'; +@import 'mastodon/statuses'; @import 'mastodon/boost'; @import 'mastodon/components'; @import 'mastodon/polls'; diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss index b5a77ce94..7df76bdff 100644 --- a/app/javascript/styles/mastodon/basics.scss +++ b/app/javascript/styles/mastodon/basics.scss @@ -8,7 +8,7 @@ body { font-family: $font-sans-serif, sans-serif; - background: darken($ui-base-color, 8%); + background: darken($ui-base-color, 7%); font-size: 13px; line-height: 18px; font-weight: 400; @@ -35,11 +35,19 @@ body { } &.app-body { - position: absolute; - width: 100%; - height: 100%; padding: 0; - background: $ui-base-color; + + &.layout-single-column { + height: auto; + min-height: 100%; + overflow-y: scroll; + } + + &.layout-multiple-columns { + position: absolute; + width: 100%; + height: 100%; + } &.with-modals--active { overflow-y: hidden; @@ -56,7 +64,6 @@ body { &--active { overflow-y: hidden; - margin-right: 13px; } } @@ -134,9 +141,22 @@ button { & > div { display: flex; width: 100%; - height: 100%; align-items: center; justify-content: center; outline: 0 !important; } } + +.layout-single-column .app-holder { + &, + & > div { + min-height: 100%; + } +} + +.layout-multiple-columns .app-holder { + &, + & > div { + height: 100%; + } +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 1063d1836..1ff0b234e 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1804,6 +1804,7 @@ a.account__display-name { justify-content: center; width: 100%; height: 100%; + min-height: 100vh; &__pane { height: 100%; @@ -1817,6 +1818,7 @@ a.account__display-name { } &__inner { + position: fixed; width: 285px; pointer-events: auto; height: 100%; @@ -1871,7 +1873,6 @@ a.account__display-name { flex-direction: column; width: 100%; height: 100%; - background: darken($ui-base-color, 7%); } .drawer { @@ -2012,6 +2013,10 @@ a.account__display-name { top: 15px; } + .scrollable { + overflow: visible; + } + @media screen and (min-width: $no-gap-breakpoint) { padding: 10px 0; } diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss index 3564bf07b..2b6794ee2 100644 --- a/app/javascript/styles/mastodon/containers.scss +++ b/app/javascript/styles/mastodon/containers.scss @@ -145,6 +145,10 @@ min-height: 100%; } + .flash-message { + margin-bottom: 10px; + } + @media screen and (max-width: 738px) { grid-template-columns: minmax(0, 50%) minmax(0, 50%); diff --git a/app/javascript/styles/mastodon/stream_entries.scss b/app/javascript/styles/mastodon/statuses.scss index 19ce0ab8f..19ce0ab8f 100644 --- a/app/javascript/styles/mastodon/stream_entries.scss +++ b/app/javascript/styles/mastodon/statuses.scss diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 1aa6ee9ec..34c646668 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -40,7 +40,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity end def announceable?(status) - status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility? + status.account_id == @account.id || status.distributable? end def related_to_local_activity? diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 00f0dd42d..56c24680a 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -41,8 +41,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity resolve_thread(@status) fetch_replies(@status) + check_for_spam distribute(@status) - forward_for_reply if @status.public_visibility? || @status.unlisted_visibility? + forward_for_reply if @status.distributable? end def find_existing_status @@ -406,6 +407,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity Account.local.where(username: local_usernames).exists? end + def check_for_spam + spam_check = SpamCheck.new(@status) + + return if spam_check.skip? + + if spam_check.spam? + spam_check.flag! + else + spam_check.remember! + end + end + def forward_for_reply return unless @json['signature'].present? && reply_to_local? ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url]) diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 0eb14b89c..1f2b40c15 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -31,7 +31,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity return if @status.nil? - if @status.public_visibility? || @status.unlisted_visibility? + if @status.distributable? forward_for_reply forward_for_reblogs end diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb index 3eb88339a..28f1da19f 100644 --- a/app/lib/activitypub/activity/follow.rb +++ b/app/lib/activitypub/activity/follow.rb @@ -8,7 +8,7 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account) - if target_account.blocking?(@account) || target_account.domain_blocking?(@account.domain) || target_account.moved? + if target_account.blocking?(@account) || target_account.domain_blocking?(@account.domain) || target_account.moved? || target_account.instance_actor? reject_follow_request!(target_account) return end diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index c259c96f4..a1d84de2f 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -33,6 +33,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base def serializable_hash(options = nil) options = serialization_options(options) serialized_hash = serializer.serializable_hash(options) + serialized_hash = serialized_hash.select { |k, _| options[:fields].include?(k) } if options[:fields] serialized_hash = self.class.transform_key_casing!(serialized_hash, instance_options) { '@context' => serialized_context }.merge(serialized_hash) diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 595291342..512272dbe 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -17,7 +17,7 @@ class ActivityPub::TagManager case target.object_type when :person - short_account_url(target) + target.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(target) when :note, :comment, :activity return activity_account_status_url(target.account, target) if target.reblog? short_account_status_url(target.account, target) @@ -29,7 +29,7 @@ class ActivityPub::TagManager case target.object_type when :person - account_url(target) + target.instance_actor? ? instance_actor_url : account_url(target) when :note, :comment, :activity return activity_account_status_url(target.account, target) if target.reblog? account_status_url(target.account, target) @@ -51,7 +51,7 @@ class ActivityPub::TagManager def replies_uri_for(target, page_params = nil) raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local? - replies_account_status_url(target.account, target, page_params) + account_status_replies_url(target.account, target, page_params) end # Primary audience of a status @@ -119,6 +119,7 @@ class ActivityPub::TagManager def uri_to_local_id(uri, param = :id) path_params = Rails.application.routes.recognize_path(uri) + path_params[:username] = Rails.configuration.x.local_domain if path_params[:controller] == 'instance_actors' path_params[param] end diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 4c11ca291..85bc8eb1f 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -314,7 +314,7 @@ class Formatter gaps = [] total_offset = 0 - escaped = html.gsub(/<[^>]*>/) do |match| + escaped = html.gsub(/<[^>]*>|&#[0-9]+;/) do |match| total_offset += match.length - 1 end_offset = Regexp.last_match.end(0) gaps << [end_offset - total_offset, total_offset] @@ -381,6 +381,6 @@ class Formatter end def mention_html(account) - "<span class=\"h-card\"><a href=\"#{encode(TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(account.username)}</span></a></span>" + "<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 end diff --git a/app/lib/language_detector.rb b/app/lib/language_detector.rb index 1e90af42d..6f9511a54 100644 --- a/app/lib/language_detector.rb +++ b/app/lib/language_detector.rb @@ -69,7 +69,7 @@ class LanguageDetector new_text = remove_html(text) new_text.gsub!(FetchLinkCardService::URL_PATTERN, '') new_text.gsub!(Account::MENTION_RE, '') - new_text.gsub!(Tag::HASHTAG_RE, '') + new_text.gsub!(Tag::HASHTAG_RE) { |string| string.gsub(/[#_]/, '#' => '', '_' => ' ').gsub(/[a-z][A-Z]|[a-zA-Z][\d]/) { |s| s.insert(1, ' ') }.downcase } new_text.gsub!(/:#{CustomEmoji::SHORTCODE_RE_FRAGMENT}:/, '') new_text.gsub!(/\s+/, ' ') new_text diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb deleted file mode 100644 index db70f1998..000000000 --- a/app/lib/ostatus/activity/base.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -class OStatus::Activity::Base - include Redisable - - def initialize(xml, account = nil, **options) - @xml = xml - @account = account - @options = options - end - - def status? - [:activity, :note, :comment].include?(type) - end - - def verb - raw = @xml.at_xpath('./activity:verb', activity: OStatus::TagManager::AS_XMLNS).content - OStatus::TagManager::VERBS.key(raw) - rescue - :post - end - - def type - raw = @xml.at_xpath('./activity:object-type', activity: OStatus::TagManager::AS_XMLNS).content - OStatus::TagManager::TYPES.key(raw) - rescue - :activity - end - - def id - @xml.at_xpath('./xmlns:id', xmlns: OStatus::TagManager::XMLNS).content - end - - def url - link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: OStatus::TagManager::XMLNS).find { |link_candidate| link_candidate['type'] == 'text/html' } - link.nil? ? nil : link['href'] - end - - def activitypub_uri - link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: OStatus::TagManager::XMLNS).find { |link_candidate| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link_candidate['type']) } - link.nil? ? nil : link['href'] - end - - def activitypub_uri? - activitypub_uri.present? - end - - private - - def find_status(uri) - if OStatus::TagManager.instance.local_id?(uri) - local_id = OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Status') - return Status.find_by(id: local_id) - elsif ActivityPub::TagManager.instance.local_uri?(uri) - local_id = ActivityPub::TagManager.instance.uri_to_local_id(uri) - return Status.find_by(id: local_id) - end - - Status.find_by(uri: uri) - end - - def find_activitypub_status(uri, href) - tag_matches = /tag:([^,:]+)[^:]*:objectId=([\d]+)/.match(uri) - href_matches = %r{/users/([^/]+)}.match(href) - - unless tag_matches.nil? || href_matches.nil? - uri = "https://#{tag_matches[1]}/users/#{href_matches[1]}/statuses/#{tag_matches[2]}" - Status.find_by(uri: uri) - end - end -end diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb deleted file mode 100644 index 60de712db..000000000 --- a/app/lib/ostatus/activity/creation.rb +++ /dev/null @@ -1,219 +0,0 @@ -# frozen_string_literal: true - -class OStatus::Activity::Creation < OStatus::Activity::Base - def perform - if redis.exists("delete_upon_arrival:#{@account.id}:#{id}") - Rails.logger.debug "Delete for status #{id} was queued, ignoring" - return [nil, false] - end - - return [nil, false] if @account.suspended? || invalid_origin? - - RedisLock.acquire(lock_options) do |lock| - if lock.acquired? - # Return early if status already exists in db - @status = find_status(id) - return [@status, false] unless @status.nil? - @status = process_status - else - raise Mastodon::RaceConditionError - end - end - - [@status, true] - end - - def process_status - Rails.logger.debug "Creating remote status #{id}" - cached_reblog = reblog - status = nil - - # Skip if the reblogged status is not public - return if cached_reblog && !(cached_reblog.public_visibility? || cached_reblog.unlisted_visibility?) - - media_attachments = save_media.take(4) - - ApplicationRecord.transaction do - status = Status.create!( - uri: id, - url: url, - account: @account, - reblog: cached_reblog, - text: content, - spoiler_text: content_warning, - created_at: published, - override_timestamps: @options[:override_timestamps], - reply: thread?, - language: content_language, - visibility: visibility_scope, - conversation: find_or_create_conversation, - thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil, - media_attachment_ids: media_attachments.map(&:id), - sensitive: sensitive? - ) - - save_mentions(status) - save_hashtags(status) - save_emojis(status) - end - - if thread? && status.thread.nil? && Request.valid_url?(thread.second) - Rails.logger.debug "Trying to attach #{status.id} (#{id}) to #{thread.first}" - ThreadResolveWorker.perform_async(status.id, thread.second) - end - - Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution" - - LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text? - - # Only continue if the status is supposed to have arrived in real-time. - # Note that if @options[:override_timestamps] isn't set, the status - # may have a lower snowflake id than other existing statuses, potentially - # "hiding" it from paginated API calls - return status unless @options[:override_timestamps] || status.within_realtime_window? - - DistributionWorker.perform_async(status.id) - - status - end - - def content - @xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS).content - end - - def content_language - @xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS)['xml:lang']&.presence || 'en' - end - - def content_warning - @xml.at_xpath('./xmlns:summary', xmlns: OStatus::TagManager::XMLNS)&.content || '' - end - - def visibility_scope - @xml.at_xpath('./mastodon:scope', mastodon: OStatus::TagManager::MTDN_XMLNS)&.content&.to_sym || :public - end - - def published - @xml.at_xpath('./xmlns:published', xmlns: OStatus::TagManager::XMLNS).content - end - - def thread? - !@xml.at_xpath('./thr:in-reply-to', thr: OStatus::TagManager::THR_XMLNS).nil? - end - - def thread - thr = @xml.at_xpath('./thr:in-reply-to', thr: OStatus::TagManager::THR_XMLNS) - [thr['ref'], thr['href']] - end - - private - - def sensitive? - # OStatus-specific convention (not standard) - @xml.xpath('./xmlns:category', xmlns: OStatus::TagManager::XMLNS).any? { |category| category['term'] == 'nsfw' } - end - - def find_or_create_conversation - uri = @xml.at_xpath('./ostatus:conversation', ostatus: OStatus::TagManager::OS_XMLNS)&.attribute('ref')&.content - return if uri.nil? - - if OStatus::TagManager.instance.local_id?(uri) - local_id = OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation') - return Conversation.find_by(id: local_id) - end - - Conversation.find_by(uri: uri) || Conversation.create!(uri: uri) - end - - def save_mentions(parent) - processed_account_ids = [] - - @xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: OStatus::TagManager::XMLNS).each do |link| - next if [OStatus::TagManager::TYPES[:group], OStatus::TagManager::TYPES[:collection]].include? link['ostatus:object-type'] - - mentioned_account = account_from_href(link['href']) - - next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id) - - mentioned_account.mentions.where(status: parent).first_or_create(status: parent) - - # So we can skip duplicate mentions - processed_account_ids << mentioned_account.id - end - end - - def save_hashtags(parent) - tags = @xml.xpath('./xmlns:category', xmlns: OStatus::TagManager::XMLNS).map { |category| category['term'] }.select(&:present?) - ProcessHashtagsService.new.call(parent, tags) - end - - def save_media - do_not_download = DomainBlock.reject_media?(@account.domain) - media_attachments = [] - - @xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: OStatus::TagManager::XMLNS).each do |link| - next unless link['href'] - - media = MediaAttachment.where(status: nil, remote_url: link['href']).first_or_initialize(account: @account, status: nil, remote_url: link['href']) - parsed_url = Addressable::URI.parse(link['href']).normalize - - next if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? - - media.save - media_attachments << media - - next if do_not_download - - begin - media.file_remote_url = link['href'] - media.save! - rescue ActiveRecord::RecordInvalid - next - end - end - - media_attachments - end - - def save_emojis(parent) - do_not_download = DomainBlock.reject_media?(parent.account.domain) - - return if do_not_download - - @xml.xpath('./xmlns:link[@rel="emoji"]', xmlns: OStatus::TagManager::XMLNS).each do |link| - next unless link['href'] && link['name'] - - shortcode = link['name'].delete(':') - emoji = CustomEmoji.find_by(shortcode: shortcode, domain: parent.account.domain) - - next unless emoji.nil? - - emoji = CustomEmoji.new(shortcode: shortcode, domain: parent.account.domain) - emoji.image_remote_url = link['href'] - emoji.save - end - end - - def account_from_href(href) - url = Addressable::URI.parse(href).normalize - - if TagManager.instance.web_domain?(url.host) - Account.find_local(url.path.gsub('/users/', '')) - else - Account.where(uri: href).or(Account.where(url: href)).first || FetchRemoteAccountService.new.call(href) - end - end - - def invalid_origin? - return false unless id.start_with?('http') # Legacy IDs cannot be checked - - needle = Addressable::URI.parse(id).normalized_host - - !(needle.casecmp(@account.domain).zero? || - needle.casecmp(Addressable::URI.parse(@account.remote_url.presence || @account.uri).normalized_host).zero?) - end - - def lock_options - { redis: Redis.current, key: "create:#{id}" } - end -end diff --git a/app/lib/ostatus/activity/deletion.rb b/app/lib/ostatus/activity/deletion.rb deleted file mode 100644 index c98f5ee0a..000000000 --- a/app/lib/ostatus/activity/deletion.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class OStatus::Activity::Deletion < OStatus::Activity::Base - def perform - Rails.logger.debug "Deleting remote status #{id}" - - status = Status.find_by(uri: id, account: @account) - status ||= Status.find_by(uri: activitypub_uri, account: @account) if activitypub_uri? - - if status.nil? - redis.setex("delete_upon_arrival:#{@account.id}:#{id}", 6 * 3_600, id) - else - RemoveStatusService.new.call(status) - end - end -end diff --git a/app/lib/ostatus/activity/general.rb b/app/lib/ostatus/activity/general.rb deleted file mode 100644 index 8a6aabc33..000000000 --- a/app/lib/ostatus/activity/general.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class OStatus::Activity::General < OStatus::Activity::Base - def specialize - special_class&.new(@xml, @account, @options) - end - - private - - def special_class - case verb - when :post - OStatus::Activity::Post - when :share - OStatus::Activity::Share - when :delete - OStatus::Activity::Deletion - end - end -end diff --git a/app/lib/ostatus/activity/post.rb b/app/lib/ostatus/activity/post.rb deleted file mode 100644 index 755ed8656..000000000 --- a/app/lib/ostatus/activity/post.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class OStatus::Activity::Post < OStatus::Activity::Creation - def perform - status, just_created = super - - if just_created - status.mentions.includes(:account).each do |mention| - mentioned_account = mention.account - next unless mentioned_account.local? - NotifyService.new.call(mentioned_account, mention) - end - end - - status - end - - private - - def reblog - nil - end -end diff --git a/app/lib/ostatus/activity/remote.rb b/app/lib/ostatus/activity/remote.rb deleted file mode 100644 index 5b204b6d8..000000000 --- a/app/lib/ostatus/activity/remote.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class OStatus::Activity::Remote < OStatus::Activity::Base - def perform - if activitypub_uri? - find_status(activitypub_uri) || FetchRemoteStatusService.new.call(url) - else - find_status(id) || FetchRemoteStatusService.new.call(url) - end - end -end diff --git a/app/lib/ostatus/activity/share.rb b/app/lib/ostatus/activity/share.rb deleted file mode 100644 index 5ca601415..000000000 --- a/app/lib/ostatus/activity/share.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -class OStatus::Activity::Share < OStatus::Activity::Creation - def perform - return if reblog.nil? - - status, just_created = super - NotifyService.new.call(reblog.account, status) if reblog.account.local? && just_created - status - end - - def object - @xml.at_xpath('.//activity:object', activity: OStatus::TagManager::AS_XMLNS) - end - - private - - def reblog - return @reblog if defined? @reblog - - original_status = OStatus::Activity::Remote.new(object).perform - return if original_status.nil? - - @reblog = original_status.reblog? ? original_status.reblog : original_status - end -end diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb deleted file mode 100644 index 9a05d96cf..000000000 --- a/app/lib/ostatus/atom_serializer.rb +++ /dev/null @@ -1,378 +0,0 @@ -# frozen_string_literal: true - -class OStatus::AtomSerializer - include RoutingHelper - include ActionView::Helpers::SanitizeHelper - - class << self - def render(element) - document = Ox::Document.new(version: '1.0') - document << element - ('<?xml version="1.0"?>' + Ox.dump(element, effort: :tolerant)).force_encoding('UTF-8') - end - end - - def author(account) - author = Ox::Element.new('author') - - uri = OStatus::TagManager.instance.uri_for(account) - - append_element(author, 'id', uri) - append_element(author, 'activity:object-type', OStatus::TagManager::TYPES[:person]) - append_element(author, 'uri', uri) - append_element(author, 'name', account.username) - append_element(author, 'email', account.local? ? account.local_username_and_domain : account.acct) - append_element(author, 'summary', Formatter.instance.simplified_format(account).to_str, type: :html) if account.note? - append_element(author, 'link', nil, rel: :alternate, type: 'text/html', href: ::TagManager.instance.url_for(account)) - append_element(author, 'link', nil, rel: :avatar, type: account.avatar_content_type, 'media:width': 120, 'media:height': 120, href: full_asset_url(account.avatar.url(:original))) if account.avatar? - append_element(author, 'link', nil, rel: :header, type: account.header_content_type, 'media:width': 700, 'media:height': 335, href: full_asset_url(account.header.url(:original))) if account.header? - account.emojis.each do |emoji| - append_element(author, 'link', nil, rel: :emoji, href: full_asset_url(emoji.image.url), name: emoji.shortcode) - end - append_element(author, 'poco:preferredUsername', account.username) - append_element(author, 'poco:displayName', account.display_name) if account.display_name? - append_element(author, 'poco:note', account.local? ? account.note : strip_tags(account.note)) if account.note? - append_element(author, 'mastodon:scope', account.locked? ? :private : :public) - - author - end - - def feed(account, stream_entries) - feed = Ox::Element.new('feed') - - add_namespaces(feed) - - append_element(feed, 'id', account_url(account, format: 'atom')) - append_element(feed, 'title', account.display_name.presence || account.username) - append_element(feed, 'subtitle', account.note) - append_element(feed, 'updated', account.updated_at.iso8601) - append_element(feed, 'logo', full_asset_url(account.avatar.url(:original))) - - feed << author(account) - - append_element(feed, 'link', nil, rel: :alternate, type: 'text/html', href: ::TagManager.instance.url_for(account)) - append_element(feed, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_url(account, format: 'atom')) - append_element(feed, 'link', nil, rel: :next, type: 'application/atom+xml', href: account_url(account, format: 'atom', max_id: stream_entries.last.id)) if stream_entries.size == 20 - append_element(feed, 'link', nil, rel: :hub, href: api_push_url) - append_element(feed, 'link', nil, rel: :salmon, href: api_salmon_url(account.id)) - - stream_entries.each do |stream_entry| - feed << entry(stream_entry) - end - - feed - end - - def entry(stream_entry, root = false) - entry = Ox::Element.new('entry') - - add_namespaces(entry) if root - - append_element(entry, 'id', OStatus::TagManager.instance.uri_for(stream_entry.status)) - append_element(entry, 'published', stream_entry.created_at.iso8601) - append_element(entry, 'updated', stream_entry.updated_at.iso8601) - append_element(entry, 'title', stream_entry&.status&.title || "#{stream_entry.account.acct} deleted status") - - entry << author(stream_entry.account) if root - - append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[stream_entry.object_type]) - append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[stream_entry.verb]) - - entry << object(stream_entry.target) if stream_entry.targeted? - - if stream_entry.status.nil? - append_element(entry, 'content', 'Deleted status') - elsif stream_entry.status.destroyed? - append_element(entry, 'content', 'Deleted status') - append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(stream_entry.status)) if stream_entry.account.local? - else - serialize_status_attributes(entry, stream_entry.status) - end - - append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: ::TagManager.instance.url_for(stream_entry.status)) - append_element(entry, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom')) - append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(stream_entry.thread), href: ::TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded? - append_element(entry, 'ostatus:conversation', nil, ref: conversation_uri(stream_entry.status.conversation)) unless stream_entry&.status&.conversation_id.nil? - - entry - end - - def object(status) - object = Ox::Element.new('activity:object') - - append_element(object, 'id', OStatus::TagManager.instance.uri_for(status)) - append_element(object, 'published', status.created_at.iso8601) - append_element(object, 'updated', status.updated_at.iso8601) - append_element(object, 'title', status.title) - - object << author(status.account) - - append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[status.object_type]) - append_element(object, 'activity:verb', OStatus::TagManager::VERBS[status.verb]) - - serialize_status_attributes(object, status) - - append_element(object, 'link', nil, rel: :alternate, type: 'text/html', href: ::TagManager.instance.url_for(status)) - append_element(object, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(status.thread), href: ::TagManager.instance.url_for(status.thread)) unless status.thread.nil? - append_element(object, 'ostatus:conversation', nil, ref: conversation_uri(status.conversation)) unless status.conversation_id.nil? - - object - end - - def follow_salmon(follow) - entry = Ox::Element.new('entry') - add_namespaces(entry) - - description = "#{follow.account.acct} started following #{follow.target_account.acct}" - - append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(follow.created_at, follow.id, 'Follow')) - append_element(entry, 'title', description) - append_element(entry, 'content', description, type: :html) - - entry << author(follow.account) - - append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:follow]) - - object = author(follow.target_account) - object.value = 'activity:object' - - entry << object - entry - end - - def follow_request_salmon(follow_request) - entry = Ox::Element.new('entry') - add_namespaces(entry) - - append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(follow_request.created_at, follow_request.id, 'FollowRequest')) - append_element(entry, 'title', "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}") - - entry << author(follow_request.account) - - append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:request_friend]) - - object = author(follow_request.target_account) - object.value = 'activity:object' - - entry << object - entry - end - - def authorize_follow_request_salmon(follow_request) - entry = Ox::Element.new('entry') - add_namespaces(entry) - - append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest')) - append_element(entry, 'title', "#{follow_request.target_account.acct} authorizes follow request by #{follow_request.account.acct}") - - entry << author(follow_request.target_account) - - append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:authorize]) - - object = Ox::Element.new('activity:object') - object << author(follow_request.account) - - append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(object, 'activity:verb', OStatus::TagManager::VERBS[:request_friend]) - - inner_object = author(follow_request.target_account) - inner_object.value = 'activity:object' - - object << inner_object - entry << object - entry - end - - def reject_follow_request_salmon(follow_request) - entry = Ox::Element.new('entry') - add_namespaces(entry) - - append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest')) - append_element(entry, 'title', "#{follow_request.target_account.acct} rejects follow request by #{follow_request.account.acct}") - - entry << author(follow_request.target_account) - - append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:reject]) - - object = Ox::Element.new('activity:object') - object << author(follow_request.account) - - append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(object, 'activity:verb', OStatus::TagManager::VERBS[:request_friend]) - - inner_object = author(follow_request.target_account) - inner_object.value = 'activity:object' - - object << inner_object - entry << object - entry - end - - def unfollow_salmon(follow) - entry = Ox::Element.new('entry') - add_namespaces(entry) - - description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}" - - append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow.id, 'Follow')) - append_element(entry, 'title', description) - append_element(entry, 'content', description, type: :html) - - entry << author(follow.account) - - append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unfollow]) - - object = author(follow.target_account) - object.value = 'activity:object' - - entry << object - entry - end - - def block_salmon(block) - entry = Ox::Element.new('entry') - add_namespaces(entry) - - description = "#{block.account.acct} no longer wishes to interact with #{block.target_account.acct}" - - append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block')) - append_element(entry, 'title', description) - - entry << author(block.account) - - append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:block]) - - object = author(block.target_account) - object.value = 'activity:object' - - entry << object - entry - end - - def unblock_salmon(block) - entry = Ox::Element.new('entry') - add_namespaces(entry) - - description = "#{block.account.acct} no longer blocks #{block.target_account.acct}" - - append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block')) - append_element(entry, 'title', description) - - entry << author(block.account) - - append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unblock]) - - object = author(block.target_account) - object.value = 'activity:object' - - entry << object - entry - end - - def favourite_salmon(favourite) - entry = Ox::Element.new('entry') - add_namespaces(entry) - - description = "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}" - - append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(favourite.created_at, favourite.id, 'Favourite')) - append_element(entry, 'title', description) - append_element(entry, 'content', description, type: :html) - - entry << author(favourite.account) - - append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:favorite]) - - entry << object(favourite.status) - - append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(favourite.status), href: ::TagManager.instance.url_for(favourite.status)) - - entry - end - - def unfavourite_salmon(favourite) - entry = Ox::Element.new('entry') - add_namespaces(entry) - - description = "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}" - - append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, favourite.id, 'Favourite')) - append_element(entry, 'title', description) - append_element(entry, 'content', description, type: :html) - - entry << author(favourite.account) - - append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity]) - append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unfavorite]) - - entry << object(favourite.status) - - append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(favourite.status), href: ::TagManager.instance.url_for(favourite.status)) - - entry - end - - private - - def append_element(parent, name, content = nil, **attributes) - element = Ox::Element.new(name) - attributes.each { |k, v| element[k] = sanitize_str(v) } - element << sanitize_str(content) unless content.nil? - parent << element - end - - def sanitize_str(raw_str) - raw_str.to_s - end - - def conversation_uri(conversation) - return conversation.uri if conversation.uri? - OStatus::TagManager.instance.unique_tag(conversation.created_at, conversation.id, 'Conversation') - end - - def add_namespaces(parent) - parent['xmlns'] = OStatus::TagManager::XMLNS - parent['xmlns:thr'] = OStatus::TagManager::THR_XMLNS - parent['xmlns:activity'] = OStatus::TagManager::AS_XMLNS - parent['xmlns:poco'] = OStatus::TagManager::POCO_XMLNS - parent['xmlns:media'] = OStatus::TagManager::MEDIA_XMLNS - parent['xmlns:ostatus'] = OStatus::TagManager::OS_XMLNS - parent['xmlns:mastodon'] = OStatus::TagManager::MTDN_XMLNS - end - - def serialize_status_attributes(entry, status) - append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(status)) if status.account.local? - - append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text? - append_element(entry, 'content', Formatter.instance.format(status, inline_poll_options: true).to_str || '.', type: 'html', 'xml:lang': status.language) - - status.active_mentions.sort_by(&:id).each do |mentioned| - append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account)) - end - - append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:collection], href: OStatus::TagManager::COLLECTIONS[:public]) if status.public_visibility? - - status.tags.each do |tag| - append_element(entry, 'category', nil, term: tag.name) - end - - status.media_attachments.each do |media| - append_element(entry, 'link', nil, rel: :enclosure, type: media.file_content_type, length: media.file_file_size, href: full_asset_url(media.file.url(:original, false))) - end - - append_element(entry, 'category', nil, term: 'nsfw') if status.sensitive? && status.media_attachments.any? - append_element(entry, 'mastodon:scope', status.visibility) - - status.emojis.each do |emoji| - append_element(entry, 'link', nil, rel: :emoji, href: full_asset_url(emoji.image.url), name: emoji.shortcode) - end - end -end diff --git a/app/lib/request.rb b/app/lib/request.rb index 5f7075a3c..9d874fe2c 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -40,8 +40,8 @@ class Request set_digest! if options.key?(:body) end - def on_behalf_of(account, key_id_format = :acct, sign_with: nil) - raise ArgumentError unless account.local? + def on_behalf_of(account, key_id_format = :uri, sign_with: nil) + raise ArgumentError, 'account must not be nil' if account.nil? @account = account @keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : @account.keypair @@ -59,7 +59,7 @@ class Request begin response = http_client.public_send(@verb, @url.to_s, @options.merge(headers: headers)) rescue => e - raise e.class, "#{e.message} on #{@url}", e.backtrace[0] + raise e.class, "#{e.message} on #{@url}", e.backtrace end begin diff --git a/app/lib/spam_check.rb b/app/lib/spam_check.rb new file mode 100644 index 000000000..0cf1b8790 --- /dev/null +++ b/app/lib/spam_check.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +class SpamCheck + include Redisable + include ActionView::Helpers::TextHelper + + NILSIMSA_COMPARE_THRESHOLD = 95 + NILSIMSA_MIN_SIZE = 10 + EXPIRE_SET_AFTER = 1.week.seconds + + def initialize(status) + @account = status.account + @status = status + end + + def skip? + disabled? || already_flagged? || trusted? || no_unsolicited_mentions? || solicited_reply? + end + + def spam? + if insufficient_data? + false + elsif nilsimsa? + any_other_digest?('nilsimsa') { |_, other_digest| nilsimsa_compare_value(digest, other_digest) >= NILSIMSA_COMPARE_THRESHOLD } + else + any_other_digest?('md5') { |_, other_digest| other_digest == digest } + end + end + + def flag! + auto_silence_account! + auto_report_status! + end + + def remember! + # The scores in sorted sets don't actually have enough bits to hold an exact + # value of our snowflake IDs, so we use it only for its ordering property. To + # get the correct status ID back, we have to save it in the string value + + redis.zadd(redis_key, @status.id, digest_with_algorithm) + redis.zremrangebyrank(redis_key, '0', '-10') + redis.expire(redis_key, EXPIRE_SET_AFTER) + end + + def reset! + redis.del(redis_key) + end + + def hashable_text + return @hashable_text if defined?(@hashable_text) + + @hashable_text = @status.text + @hashable_text = remove_mentions(@hashable_text) + @hashable_text = strip_tags(@hashable_text) unless @status.local? + @hashable_text = normalize_unicode(@status.spoiler_text + ' ' + @hashable_text) + @hashable_text = remove_whitespace(@hashable_text) + end + + def insufficient_data? + hashable_text.blank? + end + + def digest + @digest ||= begin + if nilsimsa? + Nilsimsa.new(hashable_text).hexdigest + else + Digest::MD5.hexdigest(hashable_text) + end + end + end + + def digest_with_algorithm + if nilsimsa? + ['nilsimsa', digest, @status.id].join(':') + else + ['md5', digest, @status.id].join(':') + end + end + + private + + def disabled? + !Setting.spam_check_enabled + end + + def remove_mentions(text) + return text.gsub(Account::MENTION_RE, '') if @status.local? + + Nokogiri::HTML.fragment(text).tap do |html| + mentions = @status.mentions.map { |mention| ActivityPub::TagManager.instance.url_for(mention.account) } + + html.traverse do |element| + element.unlink if element.name == 'a' && mentions.include?(element['href']) + end + end.to_s + end + + def normalize_unicode(text) + text.unicode_normalize(:nfkc).downcase + end + + def remove_whitespace(text) + text.gsub(/\s+/, ' ').strip + end + + def auto_silence_account! + @account.silence! + end + + def auto_report_status! + status_ids = Status.where(visibility: %i(public unlisted)).where(id: matching_status_ids).pluck(:id) + [@status.id] if @status.distributable? + ReportService.new.call(Account.representative, @account, status_ids: status_ids, comment: I18n.t('spam_check.spam_detected_and_silenced')) + end + + def already_flagged? + @account.silenced? + end + + def trusted? + @account.trust_level > Account::TRUST_LEVELS[:untrusted] + end + + def no_unsolicited_mentions? + @status.mentions.all? { |mention| mention.silent? || (!@account.local? && !mention.account.local?) || mention.account.following?(@account) } + end + + def solicited_reply? + !@status.thread.nil? && @status.thread.mentions.where(account: @account).exists? + end + + def nilsimsa_compare_value(first, second) + first = [first].pack('H*') + second = [second].pack('H*') + bits = 0 + + 0.upto(31) do |i| + bits += Nilsimsa::POPC[255 & (first[i].ord ^ second[i].ord)].ord + end + + 128 - bits # -128 <= Nilsimsa Compare Value <= 128 + end + + def nilsimsa? + hashable_text.size > NILSIMSA_MIN_SIZE + end + + def other_digests + redis.zrange(redis_key, 0, -1) + end + + def any_other_digest?(filter_algorithm) + other_digests.any? do |record| + algorithm, other_digest, status_id = record.split(':') + + next unless algorithm == filter_algorithm + + yield algorithm, other_digest, status_id + end + end + + def matching_status_ids + if nilsimsa? + other_digests.select { |record| record.start_with?('nilsimsa') && nilsimsa_compare_value(digest, record.split(':')[1]) >= NILSIMSA_COMPARE_THRESHOLD }.map { |record| record.split(':')[2] }.compact + else + other_digests.select { |record| record.start_with?('md5') && record.split(':')[1] == digest }.map { |record| record.split(':')[2] }.compact + end + end + + def redis_key + @redis_key ||= "spam_check:#{@account.id}" + end +end diff --git a/app/lib/status_finder.rb b/app/lib/status_finder.rb index 4d1aed297..22ced8bf8 100644 --- a/app/lib/status_finder.rb +++ b/app/lib/status_finder.rb @@ -13,8 +13,6 @@ class StatusFinder raise ActiveRecord::RecordNotFound unless TagManager.instance.local_url?(url) case recognized_params[:controller] - when 'stream_entries' - StreamEntry.find(recognized_params[:id]).status when 'statuses' Status.find(recognized_params[:id]) else diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb index fb364cb98..c88cf4994 100644 --- a/app/lib/tag_manager.rb +++ b/app/lib/tag_manager.rb @@ -24,24 +24,16 @@ class TagManager def same_acct?(canonical, needle) return true if canonical.casecmp(needle).zero? + username, domain = needle.split('@') + local_domain?(domain) && canonical.casecmp(username).zero? end def local_url?(url) uri = Addressable::URI.parse(url).normalize domain = uri.host + (uri.port ? ":#{uri.port}" : '') - TagManager.instance.web_domain?(domain) - end - - def url_for(target) - return target.url if target.respond_to?(:local?) && !target.local? - case target.object_type - when :person - short_account_url(target) - when :note, :comment, :activity - short_account_status_url(target.account, target) - end + TagManager.instance.web_domain?(domain) end end diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index ac35fd005..51d8c0970 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -39,6 +39,7 @@ class UserSettingsDecorator 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['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') end def merged_notification_emails @@ -137,6 +138,10 @@ class UserSettingsDecorator boolean_cast_setting 'setting_use_blurhash' end + def use_pending_items_preference + boolean_cast_setting 'setting_use_pending_items' + end + def boolean_cast_setting(key) ActiveModel::Type::Boolean.new.cast(settings[key]) end diff --git a/app/lib/webfinger_resource.rb b/app/lib/webfinger_resource.rb index a54a702a2..22d78874a 100644 --- a/app/lib/webfinger_resource.rb +++ b/app/lib/webfinger_resource.rb @@ -23,11 +23,17 @@ class WebfingerResource def username_from_url if account_show_page? path_params[:username] + elsif instance_actor_page? + Rails.configuration.x.local_domain else raise ActiveRecord::RecordNotFound end end + def instance_actor_page? + path_params[:controller] == 'instance_actors' + end + def account_show_page? path_params[:controller] == 'accounts' && path_params[:action] == 'show' end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index db154cad5..9ab3e2bbd 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -3,7 +3,7 @@ class AdminMailer < ApplicationMailer layout 'plain_mailer' - helper :stream_entries + helper :statuses def new_report(recipient, report) @report = report diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 66fa337c1..723d901fc 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class NotificationMailer < ApplicationMailer - helper :stream_entries + helper :statuses add_template_helper RoutingHelper diff --git a/app/models/account.rb b/app/models/account.rb index 3d7b0dda3..3370fbc5e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -45,6 +45,7 @@ # also_known_as :string is an Array # silenced_at :datetime # suspended_at :datetime +# trust_level :integer # class Account < ApplicationRecord @@ -66,6 +67,11 @@ class Account < ApplicationRecord MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i MAX_FIELDS = (ENV['MAX_PROFILE_FIELDS'] || 4).to_i + TRUST_LEVELS = { + untrusted: 0, + trusted: 1, + }.freeze + enum protocol: [:ostatus, :activitypub] validates :username, presence: true @@ -75,7 +81,7 @@ class Account < ApplicationRecord validates :username, format: { with: /\A#{USERNAME_RE}\z/i }, if: -> { !local? && will_save_change_to_username? } # Local user validations - validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? } + validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' } validates_with UniqueUsernameValidator, if: -> { local? && will_save_change_to_username? } validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? } validates :display_name, length: { maximum: MAX_DISPLAY_NAME_LENGTH }, if: -> { local? && will_save_change_to_display_name? } @@ -137,6 +143,10 @@ class Account < ApplicationRecord %w(Application Service).include? actor_type end + def instance_actor? + id == -99 + end + alias bot bot? def bot=(val) @@ -167,30 +177,31 @@ class Account < ApplicationRecord last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago end + def trust_level + self[:trust_level] || 0 + end + def refresh! - return if local? - ResolveAccountService.new.call(acct) + ResolveAccountService.new.call(acct) unless local? end def silenced? silenced_at.present? end - def silence!(date = nil) - date ||= Time.now.utc + def silence!(date = Time.now.utc) update!(silenced_at: date) end def unsilence! - update!(silenced_at: nil) + update!(silenced_at: nil, trust_level: trust_level == TRUST_LEVELS[:untrusted] ? TRUST_LEVELS[:trusted] : trust_level) end def suspended? suspended_at.present? end - def suspend!(date = nil) - date ||= Time.now.utc + def suspend!(date = Time.now.utc) transaction do user&.disable! if local? update!(suspended_at: date) @@ -296,21 +307,6 @@ class Account < ApplicationRecord self.fields = tmp end - def magic_key - modulus, exponent = [keypair.public_key.n, keypair.public_key.e].map do |component| - result = [] - - until component.zero? - result << [component % 256].pack('C') - component >>= 8 - end - - result.reverse.join - end - - (['RSA'] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.') - end - def subscription(webhook_url) @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url) end @@ -508,7 +504,7 @@ class Account < ApplicationRecord end def generate_keys - return unless local? && !Rails.env.test? + return unless local? && private_key.blank? && public_key.blank? keypair = OpenSSL::PKey::RSA.new(2048) self.private_key = keypair.to_pem diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index ecccaf35e..2877b9c25 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -11,7 +11,6 @@ module AccountAssociations has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account # Timelines - has_many :stream_entries, inverse_of: :account, dependent: :destroy has_many :statuses, inverse_of: :account, dependent: :destroy has_many :favourites, inverse_of: :account, dependent: :destroy has_many :bookmarks, inverse_of: :account, dependent: :destroy diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb index ccd7bfa12..a54c2174d 100644 --- a/app/models/concerns/account_finder_concern.rb +++ b/app/models/concerns/account_finder_concern.rb @@ -13,7 +13,7 @@ module AccountFinderConcern end def representative - find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) || Account.local.without_suspended.first + Account.find(-99) end def find_local(username) diff --git a/app/models/concerns/streamable.rb b/app/models/concerns/streamable.rb deleted file mode 100644 index 7c9edb8ef..000000000 --- a/app/models/concerns/streamable.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module Streamable - extend ActiveSupport::Concern - - included do - has_one :stream_entry, as: :activity - - after_create do - account.stream_entries.create!(activity: self, hidden: hidden?) if needs_stream_entry? - end - end - - def title - super - end - - def content - title - end - - def target - super - end - - def object_type - :activity - end - - def thread - super - end - - def hidden? - false - end - - private - - def needs_stream_entry? - account.local? - end -end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 0e9bfb265..ecaed44f6 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -34,6 +34,7 @@ class Form::AdminSettings mascot show_reblogs_in_public_timelines show_replies_in_public_timelines + spam_check_enabled ).freeze BOOLEAN_KEYS = %i( @@ -49,6 +50,7 @@ class Form::AdminSettings enable_keybase show_reblogs_in_public_timelines show_replies_in_public_timelines + spam_check_enabled ).freeze UPLOAD_KEYS = %i( diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 815ac0258..189d80e77 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -26,14 +26,14 @@ class MediaAttachment < ApplicationRecord enum type: [:image, :gifv, :video, :unknown, :audio] - IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].freeze - VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v', '.mov'].freeze - AUDIO_FILE_EXTENSIONS = ['.ogg', '.oga', '.mp3', '.m4a', '.wav', '.flac', '.opus'].freeze - - IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze - VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime', 'video/ogg'].freeze - VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze - AUDIO_MIME_TYPES = ['audio/wave', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/vdn.wav', 'audio/x-pn-wave', 'audio/ogg', 'audio/mpeg', 'audio/mp3', 'audio/mp4', 'audio/webm', 'audio/flac'].freeze + IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp).freeze + VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze + AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp).freeze + + IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif image/webp).freeze + VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze + VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze + AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/3gpp).freeze BLURHASH_OPTIONS = { x_comp: 4, diff --git a/app/models/remote_profile.rb b/app/models/remote_profile.rb deleted file mode 100644 index 742d2b56f..000000000 --- a/app/models/remote_profile.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -class RemoteProfile - include ActiveModel::Model - - attr_reader :document - - def initialize(body) - @document = Nokogiri::XML.parse(body, nil, 'utf-8') - end - - def root - @root ||= document.at_xpath('/atom:feed|/atom:entry', atom: OStatus::TagManager::XMLNS) - end - - def author - @author ||= root.at_xpath('./atom:author|./dfrn:owner', atom: OStatus::TagManager::XMLNS, dfrn: OStatus::TagManager::DFRN_XMLNS) - end - - def hub_link - @hub_link ||= link_href_from_xml(root, 'hub') - end - - def display_name - @display_name ||= author.at_xpath('./poco:displayName', poco: OStatus::TagManager::POCO_XMLNS)&.content - end - - def note - @note ||= author.at_xpath('./atom:summary|./poco:note', atom: OStatus::TagManager::XMLNS, poco: OStatus::TagManager::POCO_XMLNS)&.content - end - - def scope - @scope ||= author.at_xpath('./mastodon:scope', mastodon: OStatus::TagManager::MTDN_XMLNS)&.content - end - - def avatar - @avatar ||= link_href_from_xml(author, 'avatar') - end - - def header - @header ||= link_href_from_xml(author, 'header') - end - - def emojis - @emojis ||= author.xpath('./xmlns:link[@rel="emoji"]', xmlns: OStatus::TagManager::XMLNS) - end - - def locked? - scope == 'private' - end - - private - - def link_href_from_xml(xml, type) - xml.at_xpath(%(./atom:link[@rel="#{type}"]/@href), atom: OStatus::TagManager::XMLNS)&.content - end -end diff --git a/app/models/status.rb b/app/models/status.rb index 5adccb722..642d3cf5e 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -31,7 +31,6 @@ class Status < ApplicationRecord before_destroy :unlink_from_conversations include Paginable - include Streamable include Cacheable include StatusThreadingConcern @@ -65,7 +64,6 @@ class Status < ApplicationRecord has_and_belongs_to_many :preview_cards has_one :notification, as: :activity, dependent: :destroy - has_one :stream_entry, as: :activity, inverse_of: :status has_one :status_stat, inverse_of: :status has_one :poll, inverse_of: :status, dependent: :destroy @@ -113,13 +111,11 @@ class Status < ApplicationRecord :status_stat, :tags, :preview_cards, - :stream_entry, :preloadable_poll, account: :account_stat, active_mentions: { account: :account_stat }, reblog: [ :application, - :stream_entry, :tags, :preview_cards, :media_attachments, @@ -204,7 +200,7 @@ class Status < ApplicationRecord end def hidden? - private_visibility? || direct_visibility? || limited_visibility? + !distributable? end def distributable? @@ -523,7 +519,8 @@ class Status < ApplicationRecord end def update_statistics - return unless public_visibility? || unlisted_visibility? + return unless distributable? + ActivityTracker.increment('activity:statuses:local') end @@ -532,7 +529,7 @@ class Status < ApplicationRecord account&.increment_count!(:statuses_count) reblog&.increment_count!(:reblogs_count) if reblog? - thread&.increment_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?) + thread&.increment_count!(:replies_count) if in_reply_to_id.present? && distributable? end def decrement_counter_caches @@ -540,7 +537,7 @@ class Status < ApplicationRecord account&.decrement_count!(:statuses_count) reblog&.decrement_count!(:reblogs_count) if reblog? - thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?) + thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && distributable? end def unlink_from_conversations diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb deleted file mode 100644 index edd30487e..000000000 --- a/app/models/stream_entry.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true -# == Schema Information -# -# Table name: stream_entries -# -# id :bigint(8) not null, primary key -# activity_id :bigint(8) -# activity_type :string -# created_at :datetime not null -# updated_at :datetime not null -# hidden :boolean default(FALSE), not null -# account_id :bigint(8) -# - -class StreamEntry < ApplicationRecord - include Paginable - - belongs_to :account, inverse_of: :stream_entries - belongs_to :activity, polymorphic: true - belongs_to :status, foreign_type: 'Status', foreign_key: 'activity_id', inverse_of: :stream_entry - - validates :account, :activity, presence: true - - STATUS_INCLUDES = [:account, :stream_entry, :conversation, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, :conversation, :media_attachments, :tags, mentions: :account], thread: [:stream_entry, :account]].freeze - - default_scope { where(activity_type: 'Status') } - scope :recent, -> { reorder(id: :desc) } - scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) } - - delegate :target, :title, :content, :thread, :local_only?, - to: :status, - allow_nil: true - - def object_type - orphaned? || targeted? ? :activity : status.object_type - end - - def verb - orphaned? ? :delete : status.verb - end - - def targeted? - [:follow, :request_friend, :authorize, :reject, :unfollow, :block, :unblock, :share, :favorite].include? verb - end - - def threaded? - (verb == :favorite || object_type == :comment) && !thread.nil? - end - - def mentions - orphaned? ? [] : status.active_mentions.map(&:account) - end - - private - - def orphaned? - status.nil? - end -end diff --git a/app/models/tag.rb b/app/models/tag.rb index 7db76d157..b371d59c1 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -17,10 +17,10 @@ class Tag < ApplicationRecord has_many :featured_tags, dependent: :destroy, inverse_of: :tag has_one :account_tag_stat, dependent: :destroy - HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_·][[:word:]_]*' + HASHTAG_NAME_RE = '([[:word:]_][[:word:]_·]*[[:alpha:]_·][[:word:]_·]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)' HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i - validates :name, presence: true, uniqueness: true, format: { with: /\A#{HASHTAG_NAME_RE}\z/i } + validates :name, presence: true, uniqueness: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i } scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) } scope :hidden, -> { where(account_tag_stats: { hidden: true }) } diff --git a/app/models/user.rb b/app/models/user.rb index 9bc3dd608..72fc92195 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -106,7 +106,7 @@ 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, - :advanced_layout, :default_content_type, :use_blurhash, to: :settings, prefix: :setting, allow_nil: false + :advanced_layout, :default_content_type, :use_blurhash, :use_pending_items, :use_pending_items, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code attr_writer :external diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 5e3282681..fa5c0dd9c 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -19,7 +19,7 @@ class StatusPolicy < ApplicationPolicy elsif private? owned? || following_author? || mention_exists? else - current_account.nil? || !author_blocking? + current_account.nil? || (!author_blocking? && !author_blocking_domain?) end end @@ -65,6 +65,12 @@ class StatusPolicy < ApplicationPolicy end end + def author_blocking_domain? + return false if current_account.nil? || current_account.domain.nil? + + author.domain_blocking?(current_account.domain) + end + def blocking_author? return false if current_account.nil? diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index c06d5c87c..d0edad786 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -4,6 +4,7 @@ class ActivityPub::ActivitySerializer < ActivityPub::Serializer attributes :id, :type, :actor, :published, :to, :cc has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, if: :serialize_object? + attribute :proper_uri, key: :object, unless: :serialize_object? attribute :atom_uri, if: :announce? diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 0644219fb..0bd7aed2e 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -39,11 +39,17 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer delegate :moved?, to: :object def id - account_url(object) + object.instance_actor? ? instance_actor_url : account_url(object) end def type - object.bot? ? 'Service' : 'Person' + if object.instance_actor? + 'Application' + elsif object.bot? + 'Service' + else + 'Person' + end end def following @@ -55,7 +61,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer end def inbox - account_inbox_url(object) + object.instance_actor? ? instance_actor_inbox_url : account_inbox_url(object) end def outbox @@ -95,7 +101,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer end def url - short_account_url(object) + object.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(object) end def avatar_exists? diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index e3e2775fb..e22059182 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -37,18 +37,19 @@ class InitialStateSerializer < ActiveModel::Serializer } if object.current_account - store[:me] = object.current_account.id.to_s - store[:unfollow_modal] = object.current_account.user.setting_unfollow_modal - store[:boost_modal] = object.current_account.user.setting_boost_modal - store[:favourite_modal] = object.current_account.user.setting_favourite_modal - store[:delete_modal] = object.current_account.user.setting_delete_modal - store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif - store[:display_media] = object.current_account.user.setting_display_media - store[:expand_spoilers] = object.current_account.user.setting_expand_spoilers - store[:reduce_motion] = object.current_account.user.setting_reduce_motion - store[:advanced_layout] = object.current_account.user.setting_advanced_layout - store[:use_blurhash] = object.current_account.user.setting_use_blurhash - store[:is_staff] = object.current_account.user.staff? + store[:me] = object.current_account.id.to_s + store[:unfollow_modal] = object.current_account.user.setting_unfollow_modal + store[:boost_modal] = object.current_account.user.setting_boost_modal + store[:favourite_modal] = object.current_account.user.setting_favourite_modal + store[:delete_modal] = object.current_account.user.setting_delete_modal + store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif + store[:display_media] = object.current_account.user.setting_display_media + store[:expand_spoilers] = object.current_account.user.setting_expand_spoilers + store[:reduce_motion] = object.current_account.user.setting_reduce_motion + store[:advanced_layout] = object.current_account.user.setting_advanced_layout + store[:use_blurhash] = object.current_account.user.setting_use_blurhash + store[:use_pending_items] = object.current_account.user.setting_use_pending_items + store[:is_staff] = object.current_account.user.staff? store[:default_content_type] = object.current_account.user.setting_default_content_type end diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index c34d23452..3ecce8f0a 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -29,7 +29,7 @@ class REST::AccountSerializer < ActiveModel::Serializer end def url - TagManager.instance.url_for(object) + ActivityPub::TagManager.instance.url_for(object) end def avatar diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index b07937014..e73992899 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -61,7 +61,7 @@ class REST::StatusSerializer < ActiveModel::Serializer end def uri - OStatus::TagManager.instance.uri_for(object) + ActivityPub::TagManager.instance.uri_for(object) end def content @@ -69,7 +69,7 @@ class REST::StatusSerializer < ActiveModel::Serializer end def url - TagManager.instance.url_for(object) + ActivityPub::TagManager.instance.url_for(object) end def favourited @@ -143,7 +143,7 @@ class REST::StatusSerializer < ActiveModel::Serializer end def url - TagManager.instance.url_for(object.account) + ActivityPub::TagManager.instance.url_for(object.account) end def acct diff --git a/app/serializers/rss/account_serializer.rb b/app/serializers/rss/account_serializer.rb index 88eca79ed..278affe13 100644 --- a/app/serializers/rss/account_serializer.rb +++ b/app/serializers/rss/account_serializer.rb @@ -2,7 +2,7 @@ class RSS::AccountSerializer include ActionView::Helpers::NumberHelper - include StreamEntriesHelper + include StatusesHelper include RoutingHelper def render(account, statuses) @@ -10,7 +10,7 @@ class RSS::AccountSerializer builder.title("#{display_name(account)} (@#{account.local_username_and_domain})") .description(account_description(account)) - .link(TagManager.instance.url_for(account)) + .link(ActivityPub::TagManager.instance.url_for(account)) .logo(full_pack_url('media/images/logo.svg')) .accent_color('2b90d9') @@ -20,7 +20,7 @@ class RSS::AccountSerializer statuses.each do |status| builder.item do |item| item.title(status.title) - .link(TagManager.instance.url_for(status)) + .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) diff --git a/app/serializers/rss/tag_serializer.rb b/app/serializers/rss/tag_serializer.rb index 644380149..e8562ee87 100644 --- a/app/serializers/rss/tag_serializer.rb +++ b/app/serializers/rss/tag_serializer.rb @@ -3,7 +3,7 @@ class RSS::TagSerializer include ActionView::Helpers::NumberHelper include ActionView::Helpers::SanitizeHelper - include StreamEntriesHelper + include StatusesHelper include RoutingHelper def render(tag, statuses) @@ -18,7 +18,7 @@ class RSS::TagSerializer statuses.each do |status| builder.item do |item| item.title(status.title) - .link(TagManager.instance.url_for(status)) + .link(ActivityPub::TagManager.instance.url_for(status)) .pub_date(status.created_at) .description(status.spoiler_text.presence || Formatter.instance.format(status).to_str) diff --git a/app/serializers/webfinger_serializer.rb b/app/serializers/webfinger_serializer.rb index 8c0b07702..008d0c182 100644 --- a/app/serializers/webfinger_serializer.rb +++ b/app/serializers/webfinger_serializer.rb @@ -10,17 +10,26 @@ class WebfingerSerializer < ActiveModel::Serializer end def aliases - [short_account_url(object), account_url(object)] + if object.instance_actor? + [instance_actor_url] + else + [short_account_url(object), account_url(object)] + end end def links - [ - { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) }, - { rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(object, format: 'atom') }, - { rel: 'self', type: 'application/activity+json', href: account_url(object) }, - { rel: 'salmon', href: api_salmon_url(object.id) }, - { rel: 'magic-public-key', href: "data:application/magic-public-key,#{object.magic_key}" }, - { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" }, - ] + if object.instance_actor? + [ + { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: about_more_url(instance_actor: true) }, + { rel: 'self', type: 'application/activity+json', href: instance_actor_url }, + ] + else + [ + { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) }, + { rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(object, format: 'atom') }, + { rel: 'self', type: 'application/activity+json', href: account_url(object) }, + { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" }, + ] + end end end diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb index 6a137b520..2c2770466 100644 --- a/app/services/activitypub/fetch_featured_collection_service.rb +++ b/app/services/activitypub/fetch_featured_collection_service.rb @@ -4,13 +4,12 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService include JsonLdHelper def call(account) - return if account.featured_collection_url.blank? + return if account.featured_collection_url.blank? || account.suspended? || account.local? @account = account @json = fetch_resource(@account.featured_collection_url, true) return unless supported_context? - return if @account.suspended? || @account.local? case @json['type'] when 'Collection', 'CollectionPage' diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index 3c2044941..d65c8f951 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -2,18 +2,22 @@ class ActivityPub::FetchRemoteAccountService < BaseService include JsonLdHelper + include DomainControlHelper SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze # Does a WebFinger roundtrip on each call, unless `only_key` is true def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false) + return if domain_not_allowed?(uri) return ActivityPub::TagManager.instance.uri_to_resource(uri, Account) if ActivityPub::TagManager.instance.local_uri?(uri) - @json = if prefetched_body.nil? - fetch_resource(uri, id) - else - body_to_json(prefetched_body, compare_id: id ? uri : nil) - end + @json = begin + if prefetched_body.nil? + fetch_resource(uri, id) + else + body_to_json(prefetched_body, compare_id: id ? uri : nil) + end + end return if !supported_context? || !expected_type? || (break_on_redirect && @json['movedTo'].present?) diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb index 854a32d05..1c79ecf11 100644 --- a/app/services/activitypub/fetch_remote_poll_service.rb +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -5,7 +5,9 @@ class ActivityPub::FetchRemotePollService < BaseService def call(poll, on_behalf_of = nil) json = fetch_resource(poll.status.uri, true, on_behalf_of) + return unless supported_context?(json) + ActivityPub::ProcessPollService.new.call(poll, json) end end diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index 469821032..cf4f62899 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -5,18 +5,18 @@ class ActivityPub::FetchRemoteStatusService < BaseService # Should be called when uri has already been checked for locality def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil) - @json = if prefetched_body.nil? - fetch_resource(uri, id, on_behalf_of) - else - body_to_json(prefetched_body, compare_id: id ? uri : nil) - end + @json = begin + if prefetched_body.nil? + fetch_resource(uri, id, on_behalf_of) + else + body_to_json(prefetched_body, compare_id: id ? uri : nil) + end + end - return unless supported_context? && expected_type? - - return if actor_id.nil? || !trustworthy_attribution?(@json['id'], actor_id) + return if !(supported_context? && expected_type?) || actor_id.nil? || !trustworthy_attribution?(@json['id'], actor_id) actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account) - actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil? || needs_update(actor) + actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil? || needs_update?(actor) return if actor.nil? || actor.suspended? @@ -46,7 +46,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService equals_or_includes_any?(@json['type'], ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES) end - def needs_update(actor) + def needs_update?(actor) actor.possibly_stale? end end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 3857e7c16..603e27ed9 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -2,11 +2,12 @@ class ActivityPub::ProcessAccountService < BaseService include JsonLdHelper + include DomainControlHelper # Should be called with confirmed valid JSON # and WebFinger-resolved username and domain def call(username, domain, json, options = {}) - return if json['inbox'].blank? || unsupported_uri_scheme?(json['id']) + return if json['inbox'].blank? || unsupported_uri_scheme?(json['id']) || domain_not_allowed?(domain) @options = options @json = json @@ -15,8 +16,6 @@ class ActivityPub::ProcessAccountService < BaseService @domain = domain @collections = {} - return if auto_suspend? - RedisLock.acquire(lock_options) do |lock| if lock.acquired? @account = Account.find_remote(@username, @domain) diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb index 881df478b..a2a2e7071 100644 --- a/app/services/activitypub/process_collection_service.rb +++ b/app/services/activitypub/process_collection_service.rb @@ -8,9 +8,7 @@ class ActivityPub::ProcessCollectionService < BaseService @json = Oj.load(body, mode: :strict) @options = options - return unless supported_context? - return if different_actor? && verify_account!.nil? - return if @account.suspended? || @account.local? + return if !supported_context? || (different_actor? && verify_account!.nil?) || @account.suspended? || @account.local? case @json['type'] when 'Collection', 'CollectionPage' diff --git a/app/services/activitypub/process_poll_service.rb b/app/services/activitypub/process_poll_service.rb index 61357abd3..2fbce65b9 100644 --- a/app/services/activitypub/process_poll_service.rb +++ b/app/services/activitypub/process_poll_service.rb @@ -5,6 +5,7 @@ class ActivityPub::ProcessPollService < BaseService def call(poll, json) @json = json + return unless expected_type? previous_expires_at = poll.expires_at diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb index 29b8700c7..49bef727e 100644 --- a/app/services/authorize_follow_service.rb +++ b/app/services/authorize_follow_service.rb @@ -11,25 +11,17 @@ class AuthorizeFollowService < BaseService follow_request.authorize! end - create_notification(follow_request) unless source_account.local? + create_notification(follow_request) if !source_account.local? && source_account.activitypub? follow_request end private def create_notification(follow_request) - if follow_request.account.ostatus? - NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id) - elsif follow_request.account.activitypub? - ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url) - end + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url) end def build_json(follow_request) Oj.dump(serialize_payload(follow_request, ActivityPub::AcceptFollowSerializer)) end - - def build_xml(follow_request) - OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request)) - end end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 2fe009c91..bbee47cb7 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class BatchedRemoveStatusService < BaseService - include StreamEntryRenderer include Redisable # Delete given statuses and reblogs of them @@ -13,15 +12,12 @@ class BatchedRemoveStatusService < BaseService # @param [Hash] options # @option [Boolean] :skip_side_effects def call(statuses, **options) - statuses = Status.where(id: statuses.map(&:id)).includes(:account, :stream_entry).flat_map { |status| [status] + status.reblogs.includes(:account, :stream_entry).to_a } + statuses = Status.where(id: statuses.map(&:id)).includes(:account).flat_map { |status| [status] + status.reblogs.includes(:account).to_a } @mentions = statuses.each_with_object({}) { |s, h| h[s.id] = s.active_mentions.includes(:account).to_a } @tags = statuses.each_with_object({}) { |s, h| h[s.id] = s.tags.pluck(:name) } - @stream_entry_batches = [] - @salmon_batches = [] - @json_payloads = statuses.each_with_object({}) { |s, h| h[s.id] = Oj.dump(event: :delete, payload: s.id.to_s) } - @activity_xml = {} + @json_payloads = statuses.each_with_object({}) { |s, h| h[s.id] = Oj.dump(event: :delete, payload: s.id.to_s) } # Ensure that rendered XML reflects destroyed state statuses.each do |status| @@ -39,29 +35,17 @@ class BatchedRemoveStatusService < BaseService unpush_from_home_timelines(account, account_statuses) unpush_from_list_timelines(account, account_statuses) - - batch_stream_entries(account, account_statuses) if account.local? end # Cannot be batched statuses.each do |status| unpush_from_public_timelines(status) unpush_from_direct_timelines(status) if status.direct_visibility? - batch_salmon_slaps(status) if status.local? end - - Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch } - NotificationWorker.push_bulk(@salmon_batches) { |batch| batch } end private - def batch_stream_entries(account, statuses) - statuses.each do |status| - @stream_entry_batches << [build_xml(status.stream_entry), account.id] - end - end - def unpush_from_home_timelines(account, statuses) recipients = account.followers_for_local_distribution.to_a @@ -112,20 +96,4 @@ class BatchedRemoveStatusService < BaseService FeedManager.instance.unpush_from_direct(status.account, status) if status.account.local? end end - - def batch_salmon_slaps(status) - return if @mentions[status.id].empty? - - recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id) - - recipients.each do |recipient_id| - @salmon_batches << [build_xml(status.stream_entry), status.account_id, recipient_id] - end - end - - def build_xml(stream_entry) - return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id) - - @activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry) - end end diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index c6eef04d4..c5e5e5761 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -44,7 +44,6 @@ class BlockDomainService < BaseService def suspend_accounts! blocked_domain_accounts.without_suspended.reorder(nil).find_each do |account| - UnsubscribeService.new.call(account) if account.subscribed? SuspendAccountService.new.call(account, suspended_at: @domain_block.created_at) end end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index 0d9a6eccd..266a0f4b9 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -13,25 +13,17 @@ class BlockService < BaseService block = account.block!(target_account) BlockWorker.perform_async(account.id, target_account.id) - create_notification(block) unless target_account.local? + create_notification(block) if !target_account.local? && target_account.activitypub? block end private def create_notification(block) - if block.target_account.ostatus? - NotificationWorker.perform_async(build_xml(block), block.account_id, block.target_account_id) - elsif block.target_account.activitypub? - ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url) - end + ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url) end def build_json(block) Oj.dump(serialize_payload(block, ActivityPub::BlockSerializer)) end - - def build_xml(block) - OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.block_salmon(block)) - end end diff --git a/app/services/concerns/author_extractor.rb b/app/services/concerns/author_extractor.rb deleted file mode 100644 index c2419e9ec..000000000 --- a/app/services/concerns/author_extractor.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module AuthorExtractor - def author_from_xml(xml, update_profile = true) - return nil if xml.nil? - - # Try <email> for acct - acct = xml.at_xpath('./xmlns:author/xmlns:email', xmlns: OStatus::TagManager::XMLNS)&.content - - # Try <name> + <uri> - if acct.blank? - username = xml.at_xpath('./xmlns:author/xmlns:name', xmlns: OStatus::TagManager::XMLNS)&.content - uri = xml.at_xpath('./xmlns:author/xmlns:uri', xmlns: OStatus::TagManager::XMLNS)&.content - - return nil if username.blank? || uri.blank? - - domain = Addressable::URI.parse(uri).normalized_host - acct = "#{username}@#{domain}" - end - - ResolveAccountService.new.call(acct, update_profile: update_profile) - end -end diff --git a/app/services/concerns/payloadable.rb b/app/services/concerns/payloadable.rb index 13d9c3548..953740faa 100644 --- a/app/services/concerns/payloadable.rb +++ b/app/services/concerns/payloadable.rb @@ -14,6 +14,6 @@ module Payloadable end def signing_enabled? - true + ENV['AUTHORIZED_FETCH'] != 'true' end end diff --git a/app/services/concerns/stream_entry_renderer.rb b/app/services/concerns/stream_entry_renderer.rb deleted file mode 100644 index 9f6c8a082..000000000 --- a/app/services/concerns/stream_entry_renderer.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module StreamEntryRenderer - def stream_entry_to_xml(stream_entry) - OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(stream_entry, true)) - end -end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 128a24ad6..02b26458a 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -30,8 +30,6 @@ class FavouriteService < BaseService if status.account.local? NotifyService.new.call(status.account, favourite) - elsif status.account.ostatus? - NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id) elsif status.account.activitypub? ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) end @@ -46,8 +44,4 @@ class FavouriteService < BaseService def build_json(favourite) Oj.dump(serialize_payload(favourite, ActivityPub::LikeSerializer)) end - - def build_xml(favourite) - OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.favourite_salmon(favourite)) - end end diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb deleted file mode 100644 index d6508a988..000000000 --- a/app/services/fetch_atom_service.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -class FetchAtomService < BaseService - include JsonLdHelper - - def call(url) - return if url.blank? - - result = process(url) - - # retry without ActivityPub - result ||= process(url) if @unsupported_activity - - result - rescue OpenSSL::SSL::SSLError => e - Rails.logger.debug "SSL error: #{e}" - nil - rescue HTTP::ConnectionError => e - Rails.logger.debug "HTTP ConnectionError: #{e}" - nil - end - - private - - def process(url, terminal = false) - @url = url - perform_request { |response| process_response(response, terminal) } - end - - def perform_request(&block) - accept = 'text/html' - accept = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/atom+xml, ' + accept unless @unsupported_activity - - Request.new(:get, @url).add_headers('Accept' => accept).perform(&block) - end - - def process_response(response, terminal = false) - return nil if response.code != 200 - - if response.mime_type == 'application/atom+xml' - [@url, { prefetched_body: response.body_with_limit }, :ostatus] - elsif ['application/activity+json', 'application/ld+json'].include?(response.mime_type) - body = response.body_with_limit - json = body_to_json(body) - if supported_context?(json) && equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && json['inbox'].present? - [json['id'], { prefetched_body: body, id: true }, :activitypub] - elsif supported_context?(json) && expected_type?(json) - [json['id'], { prefetched_body: body, id: true }, :activitypub] - else - @unsupported_activity = true - nil - end - elsif !terminal - link_header = response['Link'] && parse_link_header(response) - - if link_header&.find_link(%w(rel alternate)) - process_link_headers(link_header) - elsif response.mime_type == 'text/html' - process_html(response) - end - end - end - - def expected_type?(json) - equals_or_includes_any?(json['type'], ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES) - end - - def process_html(response) - 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']) } - atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } - - result ||= process(json_link['href'], terminal: true) unless json_link.nil? || @unsupported_activity - result ||= process(atom_link['href'], terminal: true) unless atom_link.nil? - - result - 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"']) - atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) - - result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity - result ||= process(atom_link.href, terminal: true) unless atom_link.nil? - - result - end - - def parse_link_header(response) - LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link']) - end -end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 494aaed75..4e75c370f 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -29,7 +29,7 @@ class FetchLinkCardService < BaseService end attach_card if @card&.persisted? - rescue HTTP::Error, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e + rescue HTTP::Error, OpenSSL::SSL::SSLError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e Rails.logger.debug "Error fetching link #{@url}: #{e}" nil end @@ -84,7 +84,7 @@ class FetchLinkCardService < BaseService def mention_link?(a) @status.mentions.any? do |mention| - a['href'] == TagManager.instance.url_for(mention.account) + a['href'] == ActivityPub::TagManager.instance.url_for(mention.account) end end diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb index cfc560022..3cd06e30f 100644 --- a/app/services/fetch_remote_account_service.rb +++ b/app/services/fetch_remote_account_service.rb @@ -1,45 +1,17 @@ # frozen_string_literal: true class FetchRemoteAccountService < BaseService - include AuthorExtractor - def call(url, prefetched_body = nil, protocol = :ostatus) if prefetched_body.nil? - resource_url, resource_options, protocol = FetchAtomService.new.call(url) + resource_url, resource_options, protocol = FetchResourceService.new.call(url) else resource_url = url resource_options = { prefetched_body: prefetched_body } end case protocol - when :ostatus - process_atom(resource_url, **resource_options) when :activitypub ActivityPub::FetchRemoteAccountService.new.call(resource_url, **resource_options) end end - - private - - def process_atom(url, prefetched_body:) - xml = Nokogiri::XML(prefetched_body) - xml.encoding = 'utf-8' - - account = author_from_xml(xml.at_xpath('/xmlns:feed', xmlns: OStatus::TagManager::XMLNS), false) - - UpdateRemoteProfileService.new.call(xml, account) if account.present? && trusted_domain?(url, account) - - account - rescue TypeError - Rails.logger.debug "Unparseable URL given: #{url}" - nil - rescue Nokogiri::XML::XPath::SyntaxError - Rails.logger.debug 'Invalid XML or missing namespace' - nil - end - - def trusted_domain?(url, account) - domain = Addressable::URI.parse(url).normalized_host - domain.casecmp(account.domain).zero? || domain.casecmp(Addressable::URI.parse(account.remote_url.presence || account.uri).normalized_host).zero? - end end diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb index 9c3008035..208dc7809 100644 --- a/app/services/fetch_remote_status_service.rb +++ b/app/services/fetch_remote_status_service.rb @@ -1,45 +1,17 @@ # frozen_string_literal: true class FetchRemoteStatusService < BaseService - include AuthorExtractor - def call(url, prefetched_body = nil, protocol = :ostatus) if prefetched_body.nil? - resource_url, resource_options, protocol = FetchAtomService.new.call(url) + resource_url, resource_options, protocol = FetchResourceService.new.call(url) else resource_url = url resource_options = { prefetched_body: prefetched_body } end case protocol - when :ostatus - process_atom(resource_url, **resource_options) when :activitypub ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options) end end - - private - - def process_atom(url, prefetched_body:) - Rails.logger.debug "Processing Atom for remote status at #{url}" - - xml = Nokogiri::XML(prefetched_body) - xml.encoding = 'utf-8' - - account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS)) - domain = Addressable::URI.parse(url).normalized_host - - return nil unless !account.nil? && confirmed_domain?(domain, account) - - statuses = ProcessFeedService.new.call(prefetched_body, account) - statuses.first - rescue Nokogiri::XML::XPath::SyntaxError - Rails.logger.debug 'Invalid XML or missing namespace' - nil - end - - def confirmed_domain?(domain, account) - account.domain.nil? || domain.casecmp(account.domain).zero? || domain.casecmp(Addressable::URI.parse(account.remote_url.presence || account.uri).normalized_host).zero? - end end diff --git a/app/services/fetch_resource_service.rb b/app/services/fetch_resource_service.rb new file mode 100644 index 000000000..3676d899d --- /dev/null +++ b/app/services/fetch_resource_service.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class FetchResourceService < BaseService + include JsonLdHelper + + ACCEPT_HEADER = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", text/html' + + def call(url) + return if url.blank? + + process(url) + rescue HTTP::Error, OpenSSL::SSL::SSLError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e + Rails.logger.debug "Error fetching resource #{@url}: #{e}" + nil + end + + private + + def process(url, terminal = false) + @url = url + + perform_request { |response| process_response(response, terminal) } + end + + def perform_request(&block) + Request.new(:get, @url).add_headers('Accept' => ACCEPT_HEADER).on_behalf_of(Account.representative).perform(&block) + end + + def process_response(response, terminal = false) + return nil if response.code != 200 + + if ['application/activity+json', 'application/ld+json'].include?(response.mime_type) + body = response.body_with_limit + json = body_to_json(body) + + [json['id'], { prefetched_body: body, id: true }, :activitypub] if supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) || expected_type?(json)) + elsif !terminal + link_header = response['Link'] && parse_link_header(response) + + if link_header&.find_link(%w(rel alternate)) + process_link_headers(link_header) + elsif response.mime_type == 'text/html' + process_html(response) + end + end + end + + def expected_type?(json) + equals_or_includes_any?(json['type'], ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES) + end + + def process_html(response) + 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? + 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? + end + + def parse_link_header(response) + LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link']) + end +end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 0305e2d62..8e118f5d3 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -13,7 +13,7 @@ class FollowService < BaseService target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true) raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended? - raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved? + raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved? || (!target_account.local? && target_account.ostatus?) if source_account.following?(target_account) # We're already following this account, but we'll call follow! again to @@ -32,7 +32,7 @@ class FollowService < BaseService if target_account.locked? || target_account.activitypub? request_follow(source_account, target_account, reblogs: reblogs) - else + elsif target_account.local? direct_follow(source_account, target_account, reblogs: reblogs) end end @@ -44,9 +44,6 @@ class FollowService < BaseService if target_account.local? LocalNotificationWorker.perform_async(target_account.id, follow_request.id, follow_request.class.name) - elsif target_account.ostatus? - NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id) - AfterRemoteFollowRequestWorker.perform_async(follow_request.id) elsif target_account.activitypub? ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url) end @@ -57,27 +54,12 @@ class FollowService < BaseService def direct_follow(source_account, target_account, reblogs: true) follow = source_account.follow!(target_account, reblogs: reblogs) - if target_account.local? - LocalNotificationWorker.perform_async(target_account.id, follow.id, follow.class.name) - else - Pubsubhubbub::SubscribeWorker.perform_async(target_account.id) unless target_account.subscribed? - NotificationWorker.perform_async(build_follow_xml(follow), source_account.id, target_account.id) - AfterRemoteFollowWorker.perform_async(follow.id) - end - + LocalNotificationWorker.perform_async(target_account.id, follow.id, follow.class.name) MergeWorker.perform_async(target_account.id, source_account.id) follow end - def build_follow_request_xml(follow_request) - OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_request_salmon(follow_request)) - end - - def build_follow_xml(follow) - OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_salmon(follow)) - end - def build_json(follow_request) Oj.dump(serialize_payload(follow_request, ActivityPub::FollowSerializer)) end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 6d7c44913..b36471339 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -91,12 +91,7 @@ class PostStatusService < BaseService def postprocess_status! LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text? DistributionWorker.perform_async(@status.id) - - unless @status.local_only? - Pubsubhubbub::DistributionWorker.perform_async(@status.stream_entry.id) - ActivityPub::DistributionWorker.perform_async(@status.id) - end - + ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.local_only? PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll end diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb deleted file mode 100644 index 30a9dd85e..000000000 --- a/app/services/process_feed_service.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -class ProcessFeedService < BaseService - def call(body, account, **options) - @options = options - - xml = Nokogiri::XML(body) - xml.encoding = 'utf-8' - - update_author(body, account) - process_entries(xml, account) - end - - private - - def update_author(body, account) - RemoteProfileUpdateWorker.perform_async(account.id, body.force_encoding('UTF-8'), true) - end - - def process_entries(xml, account) - xml.xpath('//xmlns:entry', xmlns: OStatus::TagManager::XMLNS).reverse_each.map { |entry| process_entry(entry, account) }.compact - end - - def process_entry(xml, account) - activity = OStatus::Activity::General.new(xml, account, @options) - activity.specialize&.perform if activity.status? - rescue ActiveRecord::RecordInvalid => e - Rails.logger.debug "Nothing was saved for #{activity.id} because: #{e}" - nil - end -end diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb index d5ec076a8..b6974e598 100644 --- a/app/services/process_hashtags_service.rb +++ b/app/services/process_hashtags_service.rb @@ -14,7 +14,7 @@ class ProcessHashtagsService < BaseService TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility? end - return unless status.public_visibility? || status.unlisted_visibility? + return unless status.distributable? status.account.featured_tags.where(tag_id: records.map(&:id)).each do |featured_tag| featured_tag.increment(status.created_at) diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb deleted file mode 100644 index 1fca3832b..000000000 --- a/app/services/process_interaction_service.rb +++ /dev/null @@ -1,151 +0,0 @@ -# frozen_string_literal: true - -class ProcessInteractionService < BaseService - include AuthorExtractor - include Authorization - - # Record locally the remote interaction with our user - # @param [String] envelope Salmon envelope - # @param [Account] target_account Account the Salmon was addressed to - def call(envelope, target_account) - body = salmon.unpack(envelope) - - xml = Nokogiri::XML(body) - xml.encoding = 'utf-8' - - account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS)) - - return if account.nil? || account.suspended? - - if salmon.verify(envelope, account.keypair) - RemoteProfileUpdateWorker.perform_async(account.id, body.force_encoding('UTF-8'), true) - - case verb(xml) - when :follow - follow!(account, target_account) unless target_account.locked? || target_account.blocking?(account) || target_account.domain_blocking?(account.domain) - when :request_friend - follow_request!(account, target_account) unless !target_account.locked? || target_account.blocking?(account) || target_account.domain_blocking?(account.domain) - when :authorize - authorize_follow_request!(account, target_account) - when :reject - reject_follow_request!(account, target_account) - when :unfollow - unfollow!(account, target_account) - when :favorite - favourite!(xml, account) - when :unfavorite - unfavourite!(xml, account) - when :post - add_post!(body, account) if mentions_account?(xml, target_account) - when :share - add_post!(body, account) unless status(xml).nil? - when :delete - delete_post!(xml, account) - when :block - reflect_block!(account, target_account) - when :unblock - reflect_unblock!(account, target_account) - end - end - rescue HTTP::Error, OStatus2::BadSalmonError, Mastodon::NotPermittedError - nil - end - - private - - def mentions_account?(xml, account) - xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: OStatus::TagManager::XMLNS).each { |mention_link| return true if [OStatus::TagManager.instance.uri_for(account), OStatus::TagManager.instance.url_for(account)].include?(mention_link.attribute('href').value) } - false - end - - def verb(xml) - raw = xml.at_xpath('//activity:verb', activity: OStatus::TagManager::AS_XMLNS).content - OStatus::TagManager::VERBS.key(raw) - rescue - :post - end - - def follow!(account, target_account) - follow = account.follow!(target_account) - FollowRequest.find_by(account: account, target_account: target_account)&.destroy - NotifyService.new.call(target_account, follow) - end - - def follow_request!(account, target_account) - return if account.requested?(target_account) - - follow_request = FollowRequest.create!(account: account, target_account: target_account) - NotifyService.new.call(target_account, follow_request) - end - - def authorize_follow_request!(account, target_account) - follow_request = FollowRequest.find_by(account: target_account, target_account: account) - follow_request&.authorize! - Pubsubhubbub::SubscribeWorker.perform_async(account.id) unless account.subscribed? - end - - def reject_follow_request!(account, target_account) - follow_request = FollowRequest.find_by(account: target_account, target_account: account) - follow_request&.reject! - end - - def unfollow!(account, target_account) - account.unfollow!(target_account) - FollowRequest.find_by(account: account, target_account: target_account)&.destroy - end - - def reflect_block!(account, target_account) - UnfollowService.new.call(target_account, account) if target_account.following?(account) - account.block!(target_account) - end - - def reflect_unblock!(account, target_account) - UnblockService.new.call(account, target_account) - end - - def delete_post!(xml, account) - status = Status.find(xml.at_xpath('//xmlns:id', xmlns: OStatus::TagManager::XMLNS).content) - - return if status.nil? - - authorize_with account, status, :destroy? - - RemovalWorker.perform_async(status.id) - end - - def favourite!(xml, from_account) - current_status = status(xml) - - return if current_status.nil? - - favourite = current_status.favourites.where(account: from_account).first_or_create!(account: from_account) - NotifyService.new.call(current_status.account, favourite) - end - - def unfavourite!(xml, from_account) - current_status = status(xml) - - return if current_status.nil? - - favourite = current_status.favourites.where(account: from_account).first - favourite&.destroy - end - - def add_post!(body, account) - ProcessingWorker.perform_async(account.id, body.force_encoding('UTF-8')) - end - - def status(xml) - uri = activity_id(xml) - return nil unless OStatus::TagManager.instance.local_id?(uri) - Status.find(OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Status')) - end - - def activity_id(xml) - xml.at_xpath('//activity:object', activity: OStatus::TagManager::AS_XMLNS).at_xpath('./xmlns:id', xmlns: OStatus::TagManager::XMLNS).content - end - - def salmon - @salmon ||= OStatus2::Salmon.new - end -end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 1804e0c93..a374206eb 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class ProcessMentionsService < BaseService - include StreamEntryRenderer include Payloadable # Scan status for mentions and fetch remote mentioned users, create @@ -41,7 +40,7 @@ class ProcessMentionsService < BaseService private def mention_undeliverable?(mentioned_account) - mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus? && @status.stream_entry.hidden?) + mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?) end def create_notification(mention) @@ -49,17 +48,11 @@ class ProcessMentionsService < BaseService if mentioned_account.local? LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name) - elsif mentioned_account.ostatus? && !@status.stream_entry.hidden? && !@status.local_only? - NotificationWorker.perform_async(ostatus_xml, @status.account_id, mentioned_account.id) elsif mentioned_account.activitypub? && !@status.local_only? ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url) end end - def ostatus_xml - @ostatus_xml ||= stream_entry_to_xml(@status.stream_entry) - end - def activitypub_json return @activitypub_json if defined?(@activitypub_json) @activitypub_json = Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @status.account)) diff --git a/app/services/pubsubhubbub/subscribe_service.rb b/app/services/pubsubhubbub/subscribe_service.rb deleted file mode 100644 index 550da6328..000000000 --- a/app/services/pubsubhubbub/subscribe_service.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -class Pubsubhubbub::SubscribeService < BaseService - URL_PATTERN = /\A#{URI.regexp(%w(http https))}\z/ - - attr_reader :account, :callback, :secret, - :lease_seconds, :domain - - def call(account, callback, secret, lease_seconds, verified_domain = nil) - @account = account - @callback = Addressable::URI.parse(callback).normalize.to_s - @secret = secret - @lease_seconds = lease_seconds - @domain = verified_domain - - process_subscribe - end - - private - - def process_subscribe - if account.nil? - ['Invalid topic URL', 422] - elsif !valid_callback? - ['Invalid callback URL', 422] - elsif blocked_domain? - ['Callback URL not allowed', 403] - else - confirm_subscription - ['', 202] - end - end - - def confirm_subscription - subscription = locate_subscription - Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'subscribe', secret, lease_seconds) - end - - def valid_callback? - callback.present? && callback =~ URL_PATTERN - end - - def blocked_domain? - DomainBlock.blocked? Addressable::URI.parse(callback).host - end - - def locate_subscription - subscription = Subscription.find_or_initialize_by(account: account, callback_url: callback) - subscription.domain = domain - subscription.save! - subscription - end -end diff --git a/app/services/pubsubhubbub/unsubscribe_service.rb b/app/services/pubsubhubbub/unsubscribe_service.rb deleted file mode 100644 index 646150f7b..000000000 --- a/app/services/pubsubhubbub/unsubscribe_service.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -class Pubsubhubbub::UnsubscribeService < BaseService - attr_reader :account, :callback - - def call(account, callback) - @account = account - @callback = Addressable::URI.parse(callback).normalize.to_s - - process_unsubscribe - end - - private - - def process_unsubscribe - if account.nil? - ['Invalid topic URL', 422] - else - confirm_unsubscribe unless subscription.nil? - ['', 202] - end - end - - def confirm_unsubscribe - Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'unsubscribe') - end - - def subscription - @_subscription ||= Subscription.find_by(account: account, callback_url: callback) - end -end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 09403bae0..0b12f143c 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -2,7 +2,6 @@ class ReblogService < BaseService include Authorization - include StreamEntryRenderer include Payloadable # Reblog a status and notify its remote author @@ -24,11 +23,7 @@ class ReblogService < BaseService reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility) DistributionWorker.perform_async(reblog.id) - - unless reblogged_status.local_only? - Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) - ActivityPub::DistributionWorker.perform_async(reblog.id) - end + ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only? create_notification(reblog) bump_potential_friendship(account, reblog) @@ -43,8 +38,6 @@ class ReblogService < BaseService if reblogged_status.account.local? LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name) - elsif reblogged_status.account.ostatus? - NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), reblog.account_id, reblogged_status.account_id) elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account) ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url) end diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb index f87d0ba91..bc0000c8c 100644 --- a/app/services/reject_follow_service.rb +++ b/app/services/reject_follow_service.rb @@ -6,25 +6,17 @@ class RejectFollowService < BaseService def call(source_account, target_account) follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) follow_request.reject! - create_notification(follow_request) unless source_account.local? + create_notification(follow_request) if !source_account.local? && source_account.activitypub? follow_request end private def create_notification(follow_request) - if follow_request.account.ostatus? - NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id) - elsif follow_request.account.activitypub? - ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url) - end + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url) end def build_json(follow_request) Oj.dump(serialize_payload(follow_request, ActivityPub::RejectFollowSerializer)) end - - def build_xml(follow_request) - OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request)) - end end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 9d5d0fc14..958a67e8f 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -1,19 +1,17 @@ # frozen_string_literal: true class RemoveStatusService < BaseService - include StreamEntryRenderer include Redisable include Payloadable def call(status, **options) - @payload = Oj.dump(event: :delete, payload: status.id.to_s) - @status = status - @account = status.account - @tags = status.tags.pluck(:name).to_a - @mentions = status.active_mentions.includes(:account).to_a - @reblogs = status.reblogs.includes(:account).to_a - @stream_entry = status.stream_entry - @options = options + @payload = Oj.dump(event: :delete, payload: status.id.to_s) + @status = status + @account = status.account + @tags = status.tags.pluck(:name).to_a + @mentions = status.active_mentions.includes(:account).to_a + @reblogs = status.reblogs.includes(:account).to_a + @options = options RedisLock.acquire(lock_options) do |lock| if lock.acquired? @@ -26,6 +24,7 @@ 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 @status.destroy! else @@ -80,11 +79,6 @@ class RemoveStatusService < BaseService target_accounts << @status.reblog.account if @status.reblog? && !@status.reblog.account.local? target_accounts.uniq!(&:id) - # Ostatus - NotificationWorker.push_bulk(target_accounts.select(&:ostatus?).uniq(&:domain)) do |target_account| - [salmon_xml, @account.id, target_account.id] - end - # ActivityPub ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:preferred_inbox_url)) do |target_account| [signed_activity_json, @account.id, target_account.preferred_inbox_url] @@ -92,9 +86,6 @@ class RemoveStatusService < BaseService end def remove_from_remote_followers - # OStatus - Pubsubhubbub::RawDistributionWorker.perform_async(salmon_xml, @account.id) - # ActivityPub ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url| [signed_activity_json, @account.id, inbox_url] @@ -113,10 +104,6 @@ class RemoveStatusService < BaseService end end - def salmon_xml - @salmon_xml ||= stream_entry_to_xml(@stream_entry) - end - def signed_activity_json @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account)) end @@ -164,6 +151,10 @@ class RemoveStatusService < BaseService end end + def remove_from_spam_check + redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id) + end + def lock_options { redis: Redis.current, key: "distribute:#{@status.id}" } end diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index e557706da..7864c4bcd 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -1,89 +1,107 @@ # frozen_string_literal: true class ResolveAccountService < BaseService - include OStatus2::MagicKey include JsonLdHelper + include DomainControlHelper - DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0' + class WebfingerRedirectError < StandardError; end - # Find or create a local account for a remote user. - # When creating, look up the user's webfinger and fetch all - # important information from their feed - # @param [String, Account] uri User URI in the form of username@domain + # Find or create an account record for a remote user. When creating, + # look up the user's webfinger and fetch ActivityPub data + # @param [String, Account] uri URI in the username@domain format or account record # @param [Hash] options + # @option options [Boolean] :redirected Do not follow further Webfinger redirects + # @option options [Boolean] :skip_webfinger Do not attempt to refresh account data # @return [Account] def call(uri, options = {}) + return if uri.blank? + + process_options!(uri, options) + + # First of all we want to check if we've got the account + # record with the URI already, and if so, we can exit early + + return if domain_not_allowed?(@domain) + + @account ||= Account.find_remote(@username, @domain) + + return @account if @account&.local? || !webfinger_update_due? + + # At this point we are in need of a Webfinger query, which may + # yield us a different username/domain through a redirect + + process_webfinger!(@uri) + + # Because the username/domain pair may be different than what + # we already checked, we need to check if we've already got + # the record with that URI, again + + return if domain_not_allowed?(@domain) + + @account ||= Account.find_remote(@username, @domain) + + return @account if @account&.local? || !webfinger_update_due? + + # Now it is certain, it is definitely a remote account, and it + # either needs to be created, or updated from fresh data + + process_account! + rescue Goldfinger::Error, WebfingerRedirectError, Oj::ParseError => e + Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}" + nil + end + + private + + def process_options!(uri, options) @options = options if uri.is_a?(Account) @account = uri @username = @account.username @domain = @account.domain - uri = "#{@username}@#{@domain}" - - return @account if @account.local? || !webfinger_update_due? + @uri = [@username, @domain].compact.join('@') else + @uri = uri @username, @domain = uri.split('@') - - return Account.find_local(@username) if TagManager.instance.local_domain?(@domain) - - @account = Account.find_remote(@username, @domain) - - return @account unless webfinger_update_due? end - Rails.logger.debug "Looking up webfinger for #{uri}" - - @webfinger = Goldfinger.finger("acct:#{uri}") + @domain = nil if TagManager.instance.local_domain?(@domain) + end + def process_webfinger!(uri, redirected = false) + @webfinger = Goldfinger.finger("acct:#{@uri}") confirmed_username, confirmed_domain = @webfinger.subject.gsub(/\Aacct:/, '').split('@') if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero? @username = confirmed_username @domain = confirmed_domain - elsif options[:redirected].nil? - return call("#{confirmed_username}@#{confirmed_domain}", options.merge(redirected: true)) + @uri = uri + elsif !redirected + return process_webfinger!("#{confirmed_username}@#{confirmed_domain}", true) else - Rails.logger.debug 'Requested and returned acct URIs do not match' - return + raise WebfingerRedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}" end - return if links_missing? || auto_suspend? - return Account.find_local(@username) if TagManager.instance.local_domain?(@domain) + @domain = nil if TagManager.instance.local_domain?(@domain) + end + + def process_account! + return unless activitypub_ready? RedisLock.acquire(lock_options) do |lock| if lock.acquired? @account = Account.find_remote(@username, @domain) - if activitypub_ready? || @account&.activitypub? - handle_activitypub - else - handle_ostatus - end + next if (@account.present? && !@account.activitypub?) || actor_json.nil? + + @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, actor_json) else raise Mastodon::RaceConditionError end end @account - rescue Goldfinger::Error => e - Rails.logger.debug "Webfinger query for #{uri} unsuccessful: #{e}" - nil - end - - private - - def links_missing? - !(activitypub_ready? || ostatus_ready?) - end - - def ostatus_ready? - !(@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? || - @webfinger.link('salmon').nil? || - @webfinger.link('http://webfinger.net/rel/profile-page').nil? || - @webfinger.link('magic-public-key').nil? || - canonical_uri.nil? || - hub_url.nil?) end def webfinger_update_due? @@ -91,113 +109,13 @@ class ResolveAccountService < BaseService end def activitypub_ready? - !@webfinger.link('self').nil? && - ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) && - !actor_json.nil? && - actor_json['inbox'].present? - end - - def handle_ostatus - create_account if @account.nil? - update_account - update_account_profile if update_profile? - end - - def update_profile? - @options[:update_profile] - end - - def handle_activitypub - return if actor_json.nil? - - @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, actor_json) - rescue Oj::ParseError - nil - end - - def create_account - Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}" - - @account = Account.new(username: @username, domain: @domain) - @account.suspended_at = domain_block.created_at if auto_suspend? - @account.silenced_at = domain_block.created_at if auto_silence? - @account.private_key = nil - end - - def update_account - @account.last_webfingered_at = Time.now.utc - @account.protocol = :ostatus - @account.remote_url = atom_url - @account.salmon_url = salmon_url - @account.url = url - @account.public_key = public_key - @account.uri = canonical_uri - @account.hub_url = hub_url - @account.save! - end - - def auto_suspend? - domain_block&.suspend? - end - - def auto_silence? - domain_block&.silence? - end - - def domain_block - return @domain_block if defined?(@domain_block) - @domain_block = DomainBlock.rule_for(@domain) - end - - def atom_url - @atom_url ||= @webfinger.link('http://schemas.google.com/g/2010#updates-from').href - end - - def salmon_url - @salmon_url ||= @webfinger.link('salmon').href + !@webfinger.link('self').nil? && ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) end def actor_url @actor_url ||= @webfinger.link('self').href end - def url - @url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href - end - - def public_key - @public_key ||= magic_key_to_pem(@webfinger.link('magic-public-key').href) - end - - def canonical_uri - return @canonical_uri if defined?(@canonical_uri) - - author_uri = atom.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri') - - if author_uri.nil? - owner = atom.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS) - author_uri = owner.at_xpath('./xmlns:uri') unless owner.nil? - end - - @canonical_uri = author_uri.nil? ? nil : author_uri.content - end - - def hub_url - return @hub_url if defined?(@hub_url) - - hubs = atom.xpath('//xmlns:link[@rel="hub"]') - @hub_url = hubs.empty? || hubs.first['href'].nil? ? nil : hubs.first['href'] - end - - def atom_body - return @atom_body if defined?(@atom_body) - - @atom_body = Request.new(:get, atom_url).perform do |response| - raise Mastodon::UnexpectedResponseError, response unless response.code == 200 - response.body_with_limit - end - end - def actor_json return @actor_json if defined?(@actor_json) @@ -205,15 +123,6 @@ class ResolveAccountService < BaseService @actor_json = supported_context?(json) && equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) ? json : nil end - def atom - return @atom if defined?(@atom) - @atom = Nokogiri::XML(atom_body) - end - - def update_account_profile - RemoteProfileUpdateWorker.perform_async(@account.id, atom_body.force_encoding('UTF-8'), false) - end - def lock_options { redis: Redis.current, key: "resolve:#{@username}@#{@domain}" } end diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index bbdc0a595..aa883597a 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -4,64 +4,51 @@ class ResolveURLService < BaseService include JsonLdHelper include Authorization - attr_reader :url - def call(url, on_behalf_of: nil) - @url = url + @url = url @on_behalf_of = on_behalf_of - return process_local_url if local_url? - - process_url unless fetched_atom_feed.nil? + if local_url? + process_local_url + elsif !fetched_resource.nil? + process_url + end end private def process_url if equals_or_includes_any?(type, ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) - FetchRemoteAccountService.new.call(atom_url, body, protocol) + FetchRemoteAccountService.new.call(resource_url, body, protocol) elsif equals_or_includes_any?(type, ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES) - FetchRemoteStatusService.new.call(atom_url, body, protocol) + status = FetchRemoteStatusService.new.call(resource_url, body, protocol) + authorize_with @on_behalf_of, status, :show? unless status.nil? + status end end - def fetched_atom_feed - @_fetched_atom_feed ||= FetchAtomService.new.call(url) + def fetched_resource + @fetched_resource ||= FetchResourceService.new.call(@url) end - def atom_url - fetched_atom_feed.first + def resource_url + fetched_resource.first end def body - fetched_atom_feed.second[:prefetched_body] + fetched_resource.second[:prefetched_body] end def protocol - fetched_atom_feed.third + fetched_resource.third end def type return json_data['type'] if protocol == :activitypub - - case xml_root - when 'feed' - 'Person' - when 'entry' - 'Note' - end end def json_data - @_json_data ||= body_to_json(body) - end - - def xml_root - xml_data.root.name - end - - def xml_data - @_xml_data ||= Nokogiri::XML(body, nil, 'utf-8') + @json_data ||= body_to_json(body) end def local_url? @@ -73,10 +60,7 @@ class ResolveURLService < BaseService return unless recognized_params[:action] == 'show' - if recognized_params[:controller] == 'stream_entries' - status = StreamEntry.find_by(id: recognized_params[:id])&.status - check_local_status(status) - elsif recognized_params[:controller] == 'statuses' + if recognized_params[:controller] == 'statuses' status = Status.find_by(id: recognized_params[:id]) check_local_status(status) elsif recognized_params[:controller] == 'accounts' @@ -86,10 +70,10 @@ class ResolveURLService < BaseService def check_local_status(status) return if status.nil? + authorize_with @on_behalf_of, status, :show? status rescue Mastodon::NotPermittedError - # Do not disclose the existence of status the user is not authorized to see nil end end diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb deleted file mode 100644 index 3419043e5..000000000 --- a/app/services/send_interaction_service.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -class SendInteractionService < BaseService - # Send an Atom representation of an interaction to a remote Salmon endpoint - # @param [String] Entry XML - # @param [Account] source_account - # @param [Account] target_account - def call(xml, source_account, target_account) - @xml = xml - @source_account = source_account - @target_account = target_account - - return if !target_account.ostatus? || block_notification? - - build_request.perform do |delivery| - raise Mastodon::UnexpectedResponseError, delivery unless delivery.code > 199 && delivery.code < 300 - end - end - - private - - def build_request - request = Request.new(:post, @target_account.salmon_url, body: envelope) - request.add_headers('Content-Type' => 'application/magic-envelope+xml') - request - end - - def envelope - salmon.pack(@xml, @source_account.keypair) - end - - def block_notification? - DomainBlock.blocked?(@target_account.domain) - end - - def salmon - @salmon ||= OStatus2::Salmon.new - end -end diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb deleted file mode 100644 index 83fd64396..000000000 --- a/app/services/subscribe_service.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -class SubscribeService < BaseService - def call(account) - return if account.hub_url.blank? - - @account = account - @account.secret = SecureRandom.hex - - build_request.perform do |response| - if response_failed_permanently? response - # We're not allowed to subscribe. Fail and move on. - @account.secret = '' - @account.save! - elsif response_successful? response - # The subscription will be confirmed asynchronously. - @account.save! - else - # The response was either a 429 rate limit, or a 5xx error. - # We need to retry at a later time. Fail loudly! - raise Mastodon::UnexpectedResponseError, response - end - end - end - - private - - def build_request - request = Request.new(:post, @account.hub_url, form: subscription_params) - request.on_behalf_of(some_local_account) if some_local_account - request - end - - def subscription_params - { - 'hub.topic': @account.remote_url, - 'hub.mode': 'subscribe', - 'hub.callback': api_subscription_url(@account.id), - 'hub.verify': 'async', - 'hub.secret': @account.secret, - 'hub.lease_seconds': 7.days.seconds, - } - end - - def some_local_account - @some_local_account ||= Account.local.without_suspended.first - end - - # Any response in the 3xx or 4xx range, except for 429 (rate limit) - def response_failed_permanently?(response) - (response.status.redirect? || response.status.client_error?) && !response.status.too_many_requests? - end - - # Any response in the 2xx range - def response_successful?(response) - response.status.success? - end -end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index a5ce3dbd9..0ebe0b562 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -24,7 +24,6 @@ class SuspendAccountService < BaseService report_notes scheduled_statuses status_pins - stream_entries subscriptions ).freeze diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb index 95a858e9f..c263ac8af 100644 --- a/app/services/unblock_service.rb +++ b/app/services/unblock_service.rb @@ -7,25 +7,17 @@ class UnblockService < BaseService return unless account.blocking?(target_account) unblock = account.unblock!(target_account) - create_notification(unblock) unless target_account.local? + create_notification(unblock) if !target_account.local? && target_account.activitypub? unblock end private def create_notification(unblock) - if unblock.target_account.ostatus? - NotificationWorker.perform_async(build_xml(unblock), unblock.account_id, unblock.target_account_id) - elsif unblock.target_account.activitypub? - ActivityPub::DeliveryWorker.perform_async(build_json(unblock), unblock.account_id, unblock.target_account.inbox_url) - end + ActivityPub::DeliveryWorker.perform_async(build_json(unblock), unblock.account_id, unblock.target_account.inbox_url) end def build_json(unblock) Oj.dump(serialize_payload(unblock, ActivityPub::UndoBlockSerializer)) end - - def build_xml(block) - OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unblock_salmon(block)) - end end diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb index dcc890b7d..37917a64f 100644 --- a/app/services/unfavourite_service.rb +++ b/app/services/unfavourite_service.rb @@ -6,7 +6,7 @@ class UnfavouriteService < BaseService def call(account, status) favourite = Favourite.find_by!(account: account, status: status) favourite.destroy! - create_notification(favourite) unless status.local? + create_notification(favourite) if !status.account.local? && status.account.activitypub? favourite end @@ -14,19 +14,10 @@ class UnfavouriteService < BaseService def create_notification(favourite) status = favourite.status - - if status.account.ostatus? - NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id) - elsif status.account.activitypub? - ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) - end + ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) end def build_json(favourite) Oj.dump(serialize_payload(favourite, ActivityPub::UndoLikeSerializer)) end - - def build_xml(favourite) - OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfavourite_salmon(favourite)) - end end diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 17dc29735..b7033d7eb 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -21,8 +21,8 @@ class UnfollowService < BaseService return unless follow follow.destroy! - create_notification(follow) unless @target_account.local? - create_reject_notification(follow) if @target_account.local? && !@source_account.local? + create_notification(follow) if !@target_account.local? && @target_account.activitypub? + create_reject_notification(follow) if @target_account.local? && !@source_account.local? && @source_account.activitypub? UnmergeWorker.perform_async(@target_account.id, @source_account.id) follow end @@ -38,16 +38,10 @@ class UnfollowService < BaseService end def create_notification(follow) - if follow.target_account.ostatus? - NotificationWorker.perform_async(build_xml(follow), follow.account_id, follow.target_account_id) - elsif follow.target_account.activitypub? - ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url) - end + ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url) end def create_reject_notification(follow) - # Rejecting an already-existing follow request - return unless follow.account.activitypub? ActivityPub::DeliveryWorker.perform_async(build_reject_json(follow), follow.target_account_id, follow.account.inbox_url) end @@ -58,8 +52,4 @@ class UnfollowService < BaseService def build_reject_json(follow) Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)) end - - def build_xml(follow) - OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow)) - end end diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb deleted file mode 100644 index 95c1fb4fc..000000000 --- a/app/services/unsubscribe_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class UnsubscribeService < BaseService - def call(account) - return if account.hub_url.blank? - - @account = account - - begin - build_request.perform do |response| - Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{response.status}" unless response.status.success? - end - rescue HTTP::Error, OpenSSL::SSL::SSLError => e - Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{e}" - end - - @account.secret = '' - @account.subscription_expires_at = nil - @account.save! - end - - private - - def build_request - Request.new(:post, @account.hub_url, form: subscription_params) - end - - def subscription_params - { - 'hub.topic': @account.remote_url, - 'hub.mode': 'unsubscribe', - 'hub.callback': api_subscription_url(@account.id), - 'hub.verify': 'async', - } - end -end diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb deleted file mode 100644 index 403395a0d..000000000 --- a/app/services/update_remote_profile_service.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -class UpdateRemoteProfileService < BaseService - attr_reader :account, :remote_profile - - def call(body, account, resubscribe = false) - @account = account - @remote_profile = RemoteProfile.new(body) - - return if remote_profile.root.nil? - - update_account unless remote_profile.author.nil? - - old_hub_url = account.hub_url - account.hub_url = remote_profile.hub_link if remote_profile.hub_link.present? && remote_profile.hub_link != old_hub_url - - account.save_with_optional_media! - - Pubsubhubbub::SubscribeWorker.perform_async(account.id) if resubscribe && account.hub_url != old_hub_url - end - - private - - def update_account - account.display_name = remote_profile.display_name || '' - account.note = remote_profile.note || '' - account.locked = remote_profile.locked? - - if !account.suspended? && !DomainBlock.reject_media?(account.domain) - if remote_profile.avatar.present? - account.avatar_remote_url = remote_profile.avatar - else - account.avatar_remote_url = '' - account.avatar.destroy - end - - if remote_profile.header.present? - account.header_remote_url = remote_profile.header - else - account.header_remote_url = '' - account.header.destroy - end - - save_emojis if remote_profile.emojis.present? - end - end - - def save_emojis - do_not_download = DomainBlock.reject_media?(account.domain) - - return if do_not_download - - remote_profile.emojis.each do |link| - next unless link['href'] && link['name'] - - shortcode = link['name'].delete(':') - emoji = CustomEmoji.find_by(shortcode: shortcode, domain: account.domain) - - next unless emoji.nil? - - emoji = CustomEmoji.new(shortcode: shortcode, domain: account.domain) - emoji.image_remote_url = link['href'] - emoji.save - end - end -end diff --git a/app/services/verify_salmon_service.rb b/app/services/verify_salmon_service.rb deleted file mode 100644 index 205b35d8b..000000000 --- a/app/services/verify_salmon_service.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -class VerifySalmonService < BaseService - include AuthorExtractor - - def call(payload) - body = salmon.unpack(payload) - - xml = Nokogiri::XML(body) - xml.encoding = 'utf-8' - - account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS)) - - if account.nil? - false - else - salmon.verify(payload, account.keypair) - end - end - - private - - def salmon - @salmon ||= OStatus2::Salmon.new - end -end diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index f02a7906a..4922f0f54 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -42,5 +42,7 @@ = mail_to @instance_presenter.site_contact_email, nil, title: @instance_presenter.site_contact_email .column-3 + = render 'application/flashes' + .box-widget .rich-formatting= @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html') diff --git a/app/views/accounts/_moved.html.haml b/app/views/accounts/_moved.html.haml index 7a777bfea..02fd7bf42 100644 --- a/app/views/accounts/_moved.html.haml +++ b/app/views/accounts/_moved.html.haml @@ -3,10 +3,10 @@ .moved-account-widget .moved-account-widget__message = fa_icon 'suitcase' - = t('accounts.moved_html', name: content_tag(:bdi, content_tag(:strong, display_name(account, custom_emojify: true), class: :emojify)), new_profile_link: link_to(content_tag(:strong, safe_join(['@', content_tag(:span, moved_to_account.acct)])), TagManager.instance.url_for(moved_to_account), class: 'mention')) + = t('accounts.moved_html', name: content_tag(:bdi, content_tag(:strong, display_name(account, custom_emojify: true), class: :emojify)), new_profile_link: link_to(content_tag(:strong, safe_join(['@', content_tag(:span, moved_to_account.acct)])), ActivityPub::TagManager.instance.url_for(moved_to_account), class: 'mention')) .moved-account-widget__card - = link_to TagManager.instance.url_for(moved_to_account), class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'me noopener' do + = link_to ActivityPub::TagManager.instance.url_for(moved_to_account), class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'me noopener' do .detailed-status__display-avatar .account__avatar-overlay .account__avatar-overlay-base{ style: "background-image: url('#{moved_to_account.avatar.url(:original)}')" } diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index 950e61847..0dc984dcc 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -7,7 +7,6 @@ - if @account.user&.setting_noindex %meta{ name: 'robots', content: 'noindex' }/ - %link{ rel: 'salmon', href: api_salmon_url(@account.id) }/ %link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/ %link{ rel: 'alternate', type: 'application/rss+xml', href: account_url(@account, format: 'rss') }/ %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/ @@ -40,12 +39,12 @@ - else .activity-stream.activity-stream--under-tabs - if params[:page].to_i.zero? - = render partial: 'stream_entries/status', collection: @pinned_statuses, as: :status, locals: { pinned: true } + = render partial: 'statuses/status', collection: @pinned_statuses, as: :status, locals: { pinned: true } - if @newer_url .entry= link_to_more @newer_url - = render partial: 'stream_entries/status', collection: @statuses, as: :status + = render partial: 'statuses/status', collection: @statuses, as: :status - if @older_url .entry= link_to_more @older_url diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml index eba3ad804..b057d3e42 100644 --- a/app/views/admin/accounts/_account.html.haml +++ b/app/views/admin/accounts/_account.html.haml @@ -19,4 +19,4 @@ = table_link_to 'times', t('admin.accounts.reject'), reject_admin_account_path(account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:reject, account.user) - else = table_link_to 'circle', t('admin.accounts.web'), web_path("accounts/#{account.id}") - = table_link_to 'globe', t('admin.accounts.public'), TagManager.instance.url_for(account) + = table_link_to 'globe', t('admin.accounts.public'), ActivityPub::TagManager.instance.url_for(account) diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 76dbf4388..54cf9af5d 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -53,6 +53,8 @@ = feature_hint(link_to(t('admin.dashboard.keybase'), edit_admin_settings_path), @keybase_integration) %li = feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled) + %li + = feature_hint(link_to(t('admin.dashboard.feature_spam_check'), edit_admin_settings_path), @spam_check_enabled) .dashboard__widgets__versions %div diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml index b3c145120..9376db7ff 100644 --- a/app/views/admin/reports/_status.html.haml +++ b/app/views/admin/reports/_status.html.haml @@ -19,7 +19,7 @@ = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } .detailed-status__meta - = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do + = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) · - if status.reblog? diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index a8c9f6a58..854f4cf87 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -78,6 +78,9 @@ .fields-group = f.input :show_replies_in_public_timelines, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_replies_in_public_timelines.title'), hint: t('admin.settings.show_replies_in_public_timelines.desc_html') + .fields-group + = f.input :spam_check_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.spam_check_enabled.title'), hint: t('admin.settings.spam_check_enabled.desc_html') + %hr.spacer/ .fields-group diff --git a/app/views/admin/subscriptions/_subscription.html.haml b/app/views/admin/subscriptions/_subscription.html.haml deleted file mode 100644 index 1dec8e396..000000000 --- a/app/views/admin/subscriptions/_subscription.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -%tr - %td - %samp= subscription.account.acct - %td - %samp= subscription.callback_url - %td - - if subscription.confirmed? - %i.fa.fa-check - %td{ style: "color: #{subscription.expired? ? 'red' : 'inherit'};" } - %time.time-ago{ datetime: subscription.expires_at.iso8601, title: l(subscription.expires_at) } - = precede subscription.expired? ? '-' : '' do - = time_ago_in_words(subscription.expires_at) - %td - - if subscription.last_successful_delivery_at? - %time.formatted{ datetime: subscription.last_successful_delivery_at.iso8601, title: l(subscription.last_successful_delivery_at) } - = l subscription.last_successful_delivery_at - - else - %i.fa.fa-times diff --git a/app/views/admin/subscriptions/index.html.haml b/app/views/admin/subscriptions/index.html.haml deleted file mode 100644 index 83704c8ee..000000000 --- a/app/views/admin/subscriptions/index.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -- content_for :page_title do - = t('admin.subscriptions.title') - -.table-wrapper - %table.table - %thead - %tr - %th= t('admin.subscriptions.topic') - %th= t('admin.subscriptions.callback_url') - %th= t('admin.subscriptions.confirmed') - %th= t('admin.subscriptions.expires_in') - %th= t('admin.subscriptions.last_delivery') - %tbody - = render @subscriptions - -= paginate @subscriptions diff --git a/app/views/application/_card.html.haml b/app/views/application/_card.html.haml index e6059b035..00254c40c 100644 --- a/app/views/application/_card.html.haml +++ b/app/views/application/_card.html.haml @@ -1,4 +1,4 @@ -- account_url = local_assigns[:admin] ? admin_account_path(account.id) : TagManager.instance.url_for(account) +- account_url = local_assigns[:admin] ? admin_account_path(account.id) : ActivityPub::TagManager.instance.url_for(account) .card.h-card = link_to account_url, target: '_blank', rel: 'noopener' do diff --git a/app/views/authorize_interactions/_post_follow_actions.html.haml b/app/views/authorize_interactions/_post_follow_actions.html.haml index 561c60137..dd71160e2 100644 --- a/app/views/authorize_interactions/_post_follow_actions.html.haml +++ b/app/views/authorize_interactions/_post_follow_actions.html.haml @@ -1,4 +1,4 @@ .post-follow-actions %div= link_to t('authorize_follow.post_follow.web'), web_url("accounts/#{@resource.id}"), class: 'button button--block' - %div= link_to t('authorize_follow.post_follow.return'), TagManager.instance.url_for(@resource), class: 'button button--block' + %div= link_to t('authorize_follow.post_follow.return'), ActivityPub::TagManager.instance.url_for(@resource), class: 'button button--block' %div= t('authorize_follow.post_follow.close') diff --git a/app/views/remote_interaction/new.html.haml b/app/views/remote_interaction/new.html.haml index c8c08991f..2cc0fcb93 100644 --- a/app/views/remote_interaction/new.html.haml +++ b/app/views/remote_interaction/new.html.haml @@ -7,7 +7,7 @@ .public-layout .activity-stream.activity-stream--highlighted - = render 'stream_entries/status', status: @status + = render 'statuses/status', status: @status = simple_form_for @remote_follow, as: :remote_follow, url: remote_interaction_path(@status) do |f| = render 'shared/error_messages', object: @remote_follow diff --git a/app/views/remote_unfollows/_card.html.haml b/app/views/remote_unfollows/_card.html.haml deleted file mode 100644 index 9abcfd37e..000000000 --- a/app/views/remote_unfollows/_card.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -.account-card - .detailed-status__display-name - %div - = image_tag account.avatar.url(:original), alt: '', width: 48, height: 48, class: 'avatar' - - %span.display-name - - account_url = local_assigns[:admin] ? admin_account_path(account.id) : TagManager.instance.url_for(account) - = link_to account_url, class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'noopener' do - %strong.emojify= display_name(account, custom_emojify: true) - %span @#{account.acct} - - - if account.note? - .account__header__content.emojify= Formatter.instance.simplified_format(account) diff --git a/app/views/remote_unfollows/_post_follow_actions.html.haml b/app/views/remote_unfollows/_post_follow_actions.html.haml deleted file mode 100644 index 2a9c062e9..000000000 --- a/app/views/remote_unfollows/_post_follow_actions.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -.post-follow-actions - %div= link_to t('authorize_follow.post_follow.web'), web_url("accounts/#{@account.id}"), class: 'button button--block' - %div= link_to t('authorize_follow.post_follow.return'), TagManager.instance.url_for(@account), class: 'button button--block' - %div= t('authorize_follow.post_follow.close') diff --git a/app/views/remote_unfollows/error.html.haml b/app/views/remote_unfollows/error.html.haml deleted file mode 100644 index cb63f02be..000000000 --- a/app/views/remote_unfollows/error.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -.form-container - .flash-message#error_explanation - = t('remote_unfollow.error') diff --git a/app/views/remote_unfollows/success.html.haml b/app/views/remote_unfollows/success.html.haml deleted file mode 100644 index b007eedc7..000000000 --- a/app/views/remote_unfollows/success.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -- content_for :page_title do - = t('remote_unfollow.title', acct: @account.acct) - -.form-container - .follow-prompt - %h2= t('remote_unfollow.unfollowed') - - = render 'application/card', account: @account - - = render 'post_follow_actions' diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index 1709c9c84..447958253 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -15,6 +15,9 @@ %h4= t 'appearance.animations_and_accessibility' .fields-group + = f.input :setting_use_pending_items, as: :boolean, wrapper: :with_label + + .fields-group = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label, recommended: true = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label diff --git a/app/views/stream_entries/_attachment_list.html.haml b/app/views/statuses/_attachment_list.html.haml index d9706f47b..d9706f47b 100644 --- a/app/views/stream_entries/_attachment_list.html.haml +++ b/app/views/statuses/_attachment_list.html.haml diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index 069d0053f..8686c2033 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -1,6 +1,6 @@ .detailed-status.detailed-status--flex .p-author.h-card - = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name u-url', target: stream_link_target, rel: 'noopener' do + = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'detailed-status__display-name u-url', target: stream_link_target, rel: 'noopener' do .detailed-status__display-avatar - if current_account&.user&.setting_auto_play_gif || autoplay = image_tag status.account.avatar_original_url, width: 48, height: 48, alt: '', class: 'account__avatar u-photo' @@ -24,23 +24,23 @@ = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) - 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: 'stream_entries/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } + = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } - if !status.media_attachments.empty? - if status.media_attachments.first.audio_or_video? - video = status.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do - = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments } + = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - else = react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do - = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments } + = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.preview_card = react_component :card, '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 } - = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: stream_link_target, rel: 'noopener' do + = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: stream_link_target, rel: 'noopener' do %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) · - if status.application && @account.user&.setting_show_application diff --git a/app/views/stream_entries/_og_description.html.haml b/app/views/statuses/_og_description.html.haml index a7b18424d..a7b18424d 100644 --- a/app/views/stream_entries/_og_description.html.haml +++ b/app/views/statuses/_og_description.html.haml diff --git a/app/views/stream_entries/_og_image.html.haml b/app/views/statuses/_og_image.html.haml index 67f9274b6..67f9274b6 100644 --- a/app/views/stream_entries/_og_image.html.haml +++ b/app/views/statuses/_og_image.html.haml diff --git a/app/views/stream_entries/_poll.html.haml b/app/views/statuses/_poll.html.haml index ba34890df..ba34890df 100644 --- a/app/views/stream_entries/_poll.html.haml +++ b/app/views/statuses/_poll.html.haml diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index dcb4ce0b9..27f6fc227 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -1,11 +1,11 @@ .status .status__info - = link_to TagManager.instance.url_for(status), class: 'status__relative-time u-url u-uid', target: stream_link_target, rel: 'noopener' do + = link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__relative-time u-url u-uid', target: stream_link_target, rel: 'noopener' do %time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) %data.dt-published{ value: status.created_at.to_time.iso8601 } .p-author.h-card - = link_to TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener' do + = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener' do .status__avatar %div - if current_account&.user&.setting_auto_play_gif || autoplay @@ -28,16 +28,16 @@ = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) - 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: 'stream_entries/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } + = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } - if !status.media_attachments.empty? - if status.media_attachments.first.audio_or_video? - video = status.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do - = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments } + = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - else = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do - = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments } + = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.preview_card = react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json @@ -50,9 +50,9 @@ = fa_icon 'reply-all fw' .status__action-bar__counter__label= obscured_counter status.replies_count = link_to remote_interaction_path(status, type: :reblog), class: 'status__action-bar-button icon-button modal-button', style: 'font-size: 18px; width: 23.1429px; height: 23.1429px; line-height: 23.15px;' do - - if status.public_visibility? || status.unlisted_visibility? + - if status.distributable? = fa_icon 'retweet fw' - - elsif status.private_visibility? + - elsif status.private_visibility? || status.limited_visibility? = fa_icon 'lock fw' - else = fa_icon 'envelope fw' diff --git a/app/views/stream_entries/_status.html.haml b/app/views/statuses/_status.html.haml index 83887cd87..0e3652503 100644 --- a/app/views/stream_entries/_status.html.haml +++ b/app/views/statuses/_status.html.haml @@ -17,9 +17,9 @@ - if status.reply? && include_threads - if @next_ancestor .entry{ class: entry_classes } - = link_to_more TagManager.instance.url_for(@next_ancestor) + = link_to_more ActivityPub::TagManager.instance.url_for(@next_ancestor) - = render partial: 'stream_entries/status', collection: @ancestors, as: :status, locals: { is_predecessor: true, direct_reply_id: status.in_reply_to_id }, autoplay: autoplay + = render partial: 'statuses/status', collection: @ancestors, as: :status, locals: { is_predecessor: true, direct_reply_id: status.in_reply_to_id }, autoplay: autoplay .entry{ class: entry_classes } @@ -28,7 +28,7 @@ .status__prepend-icon-wrapper %i.status__prepend-icon.fa.fa-fw.fa-retweet %span - = link_to TagManager.instance.url_for(status.account), class: 'status__display-name muted' do + = 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') @@ -39,18 +39,18 @@ %span = t('stream_entries.pinned') - = render (centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status'), status: status.proper, autoplay: autoplay + = render (centered ? 'statuses/detailed_status' : 'statuses/simple_status'), status: status.proper, autoplay: autoplay - if include_threads - if @since_descendant_thread_id .entry{ class: entry_classes } = link_to_more short_account_status_url(status.account.username, status, max_descendant_thread_id: @since_descendant_thread_id + 1) - @descendant_threads.each do |thread| - = render partial: 'stream_entries/status', collection: thread[:statuses], as: :status, locals: { is_successor: true, parent_id: status.id }, autoplay: autoplay + = render partial: 'statuses/status', collection: thread[:statuses], as: :status, locals: { is_successor: true, parent_id: status.id }, autoplay: autoplay - if thread[:next_status] .entry{ class: entry_classes } - = link_to_more TagManager.instance.url_for(thread[:next_status]) + = link_to_more ActivityPub::TagManager.instance.url_for(thread[:next_status]) - if @next_descendant_thread .entry{ class: entry_classes } = link_to_more short_account_status_url(status.account.username, status, since_descendant_thread_id: @max_descendant_thread_id - 1) diff --git a/app/views/statuses/embed.html.haml b/app/views/statuses/embed.html.haml new file mode 100644 index 000000000..6f2ec646f --- /dev/null +++ b/app/views/statuses/embed.html.haml @@ -0,0 +1,3 @@ +- cache @status do + .activity-stream.activity-stream--headless + = render 'status', status: @status, centered: true, autoplay: @autoplay diff --git a/app/views/statuses/show.html.haml b/app/views/statuses/show.html.haml new file mode 100644 index 000000000..704e37a3d --- /dev/null +++ b/app/views/statuses/show.html.haml @@ -0,0 +1,24 @@ +- content_for :page_title do + = t('statuses.title', name: display_name(@account), quote: truncate(@status.spoiler_text.presence || @status.text, length: 50, omission: '…', escape: false)) + +- content_for :header_tags do + - if @account.user&.setting_noindex + %meta{ name: 'robots', content: 'noindex' }/ + + %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: short_account_status_url(@account, @status), format: 'json') }/ + %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@status) }/ + + = opengraph 'og:site_name', site_title + = opengraph 'og:type', 'article' + = opengraph 'og:title', "#{display_name(@account)} (@#{@account.local_username_and_domain})" + = opengraph 'og:url', short_account_status_url(@account, @status) + + = render 'og_description', activity: @status + = render 'og_image', activity: @status, account: @account + +.grid + .column-0 + .activity-stream.h-entry + = render partial: 'status', locals: { status: @status, include_threads: true } + .column-1 + = render 'application/sidebar' diff --git a/app/views/stream_entries/embed.html.haml b/app/views/stream_entries/embed.html.haml deleted file mode 100644 index 4871c101e..000000000 --- a/app/views/stream_entries/embed.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -- cache @stream_entry.activity do - .activity-stream.activity-stream--headless - = render "stream_entries/#{@type}", @type.to_sym => @stream_entry.activity, centered: true, autoplay: @autoplay diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml deleted file mode 100644 index 0e81c4f68..000000000 --- a/app/views/stream_entries/show.html.haml +++ /dev/null @@ -1,25 +0,0 @@ -- content_for :page_title do - = t('statuses.title', name: display_name(@account), quote: truncate(@stream_entry.activity.spoiler_text.presence || @stream_entry.activity.text, length: 50, omission: '…', escape: false)) - -- content_for :header_tags do - - if @account.user&.setting_noindex - %meta{ name: 'robots', content: 'noindex' }/ - - %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/ - %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/ - %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@stream_entry.activity) }/ - - = opengraph 'og:site_name', site_title - = opengraph 'og:type', 'article' - = opengraph 'og:title', "#{display_name(@account)} (@#{@account.local_username_and_domain})" - = opengraph 'og:url', short_account_status_url(@account, @stream_entry.activity) - - = render 'stream_entries/og_description', activity: @stream_entry.activity - = render 'stream_entries/og_image', activity: @stream_entry.activity, account: @account - -.grid - .column-0 - .activity-stream.h-entry - = render partial: "stream_entries/#{@type}", locals: { @type.to_sym => @stream_entry.activity, include_threads: true } - .column-1 - = render 'application/sidebar' diff --git a/app/views/well_known/webfinger/show.xml.ruby b/app/views/well_known/webfinger/show.xml.ruby index 968c8c138..f5a54052a 100644 --- a/app/views/well_known/webfinger/show.xml.ruby +++ b/app/views/well_known/webfinger/show.xml.ruby @@ -4,40 +4,47 @@ doc << Ox::Element.new('XRD').tap do |xrd| xrd['xmlns'] = 'http://docs.oasis-open.org/ns/xri/xrd-1.0' xrd << (Ox::Element.new('Subject') << @account.to_webfinger_s) - xrd << (Ox::Element.new('Alias') << short_account_url(@account)) - xrd << (Ox::Element.new('Alias') << account_url(@account)) - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'http://webfinger.net/rel/profile-page' - link['type'] = 'text/html' - link['href'] = short_account_url(@account) - end - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'http://schemas.google.com/g/2010#updates-from' - link['type'] = 'application/atom+xml' - link['href'] = account_url(@account, format: 'atom') - end - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'self' - link['type'] = 'application/activity+json' - link['href'] = account_url(@account) - end - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'salmon' - link['href'] = api_salmon_url(@account.id) - end - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'magic-public-key' - link['href'] = "data:application/magic-public-key,#{@account.magic_key}" - end - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'http://ostatus.org/schema/1.0/subscribe' - link['template'] = "#{authorize_interaction_url}?acct={uri}" + if @account.instance_actor? + xrd << (Ox::Element.new('Alias') << instance_actor_url) + + xrd << Ox::Element.new('Link').tap do |link| + link['rel'] = 'http://webfinger.net/rel/profile-page' + link['type'] = 'text/html' + link['href'] = about_more_url(instance_actor: true) + end + + xrd << Ox::Element.new('Link').tap do |link| + link['rel'] = 'self' + link['type'] = 'application/activity+json' + link['href'] = instance_actor_url + end + else + xrd << (Ox::Element.new('Alias') << short_account_url(@account)) + xrd << (Ox::Element.new('Alias') << account_url(@account)) + + xrd << Ox::Element.new('Link').tap do |link| + link['rel'] = 'http://webfinger.net/rel/profile-page' + link['type'] = 'text/html' + link['href'] = short_account_url(@account) + end + + xrd << Ox::Element.new('Link').tap do |link| + link['rel'] = 'http://schemas.google.com/g/2010#updates-from' + link['type'] = 'application/atom+xml' + link['href'] = account_url(@account, format: 'atom') + end + + xrd << Ox::Element.new('Link').tap do |link| + link['rel'] = 'self' + link['type'] = 'application/activity+json' + link['href'] = account_url(@account) + end + + xrd << Ox::Element.new('Link').tap do |link| + link['rel'] = 'http://ostatus.org/schema/1.0/subscribe' + link['template'] = "#{authorize_interaction_url}?acct={uri}" + end end end diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb index 818fd8f5d..5457d9d4b 100644 --- a/app/workers/activitypub/delivery_worker.rb +++ b/app/workers/activitypub/delivery_worker.rb @@ -2,6 +2,7 @@ class ActivityPub::DeliveryWorker include Sidekiq::Worker + include JsonLdHelper STOPLIGHT_FAILURE_THRESHOLD = 10 STOPLIGHT_COOLDOWN = 60 @@ -18,21 +19,24 @@ class ActivityPub::DeliveryWorker @source_account = Account.find(source_account_id) @inbox_url = inbox_url @host = Addressable::URI.parse(inbox_url).normalized_site + @performed = false perform_request - - failure_tracker.track_success! - rescue => e - failure_tracker.track_failure! - raise e.class, "Delivery failed for #{inbox_url}: #{e.message}", e.backtrace[0] + ensure + if @performed + failure_tracker.track_success! + else + failure_tracker.track_failure! + end end private def build_request(http_client) - request = Request.new(:post, @inbox_url, body: @json, http_client: http_client) - request.on_behalf_of(@source_account, :uri, sign_with: @options[:sign_with]) - request.add_headers(HEADERS) + Request.new(:post, @inbox_url, body: @json, http_client: http_client).tap do |request| + request.on_behalf_of(@source_account, :uri, sign_with: @options[:sign_with]) + request.add_headers(HEADERS) + end end def perform_request @@ -40,6 +44,8 @@ class ActivityPub::DeliveryWorker request_pool.with(@host) do |http_client| build_request(http_client).perform do |response| raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) + + @performed = true end end end @@ -49,14 +55,6 @@ class ActivityPub::DeliveryWorker .run end - def response_successful?(response) - (200...300).cover?(response.code) - end - - def response_error_unsalvageable?(response) - response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)) - end - def failure_tracker @failure_tracker ||= DeliveryFailureTracker.new(@inbox_url) end diff --git a/app/workers/after_remote_follow_request_worker.rb b/app/workers/after_remote_follow_request_worker.rb index 84eb6ade2..ce9c65834 100644 --- a/app/workers/after_remote_follow_request_worker.rb +++ b/app/workers/after_remote_follow_request_worker.rb @@ -5,27 +5,5 @@ class AfterRemoteFollowRequestWorker sidekiq_options queue: 'pull', retry: 5 - attr_reader :follow_request - - def perform(follow_request_id) - @follow_request = FollowRequest.find(follow_request_id) - process_follow_service if processing_required? - rescue ActiveRecord::RecordNotFound - true - end - - private - - def process_follow_service - follow_request.destroy - FollowService.new.call(follow_request.account, updated_account.acct) - end - - def processing_required? - !updated_account.nil? && !updated_account.locked? - end - - def updated_account - @_updated_account ||= FetchRemoteAccountService.new.call(follow_request.target_account.remote_url) - end + def perform(follow_request_id); end end diff --git a/app/workers/after_remote_follow_worker.rb b/app/workers/after_remote_follow_worker.rb index edab83f85..d9719f2bf 100644 --- a/app/workers/after_remote_follow_worker.rb +++ b/app/workers/after_remote_follow_worker.rb @@ -5,27 +5,5 @@ class AfterRemoteFollowWorker sidekiq_options queue: 'pull', retry: 5 - attr_reader :follow - - def perform(follow_id) - @follow = Follow.find(follow_id) - process_follow_service if processing_required? - rescue ActiveRecord::RecordNotFound - true - end - - private - - def process_follow_service - follow.destroy - FollowService.new.call(follow.account, updated_account.acct) - end - - def updated_account - @_updated_account ||= FetchRemoteAccountService.new.call(follow.target_account.remote_url) - end - - def processing_required? - !updated_account.nil? && updated_account.locked? - end + def perform(follow_id); end end diff --git a/app/workers/maintenance/uncache_preview_worker.rb b/app/workers/maintenance/uncache_preview_worker.rb new file mode 100644 index 000000000..810ffd8cc --- /dev/null +++ b/app/workers/maintenance/uncache_preview_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Maintenance::UncachePreviewWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull' + + def perform(preview_card_id) + preview_card = PreviewCard.find(preview_card_id) + + return if preview_card.image.blank? + + preview_card.image.destroy + preview_card.save + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/notification_worker.rb b/app/workers/notification_worker.rb index da1d6ab45..1c0f001cf 100644 --- a/app/workers/notification_worker.rb +++ b/app/workers/notification_worker.rb @@ -5,7 +5,5 @@ class NotificationWorker sidekiq_options queue: 'push', retry: 5 - def perform(xml, source_account_id, target_account_id) - SendInteractionService.new.call(xml, Account.find(source_account_id), Account.find(target_account_id)) - end + def perform(xml, source_account_id, target_account_id); end end diff --git a/app/workers/processing_worker.rb b/app/workers/processing_worker.rb index 978c3aba2..cf3bd8397 100644 --- a/app/workers/processing_worker.rb +++ b/app/workers/processing_worker.rb @@ -5,7 +5,5 @@ class ProcessingWorker sidekiq_options backtrace: true - def perform(account_id, body) - ProcessFeedService.new.call(body, Account.find(account_id), override_timestamps: true) - end + def perform(account_id, body); end end diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb index c0e7b677e..783a8c95f 100644 --- a/app/workers/pubsubhubbub/confirmation_worker.rb +++ b/app/workers/pubsubhubbub/confirmation_worker.rb @@ -2,81 +2,8 @@ class Pubsubhubbub::ConfirmationWorker include Sidekiq::Worker - include RoutingHelper sidekiq_options queue: 'push', retry: false - attr_reader :subscription, :mode, :secret, :lease_seconds - - def perform(subscription_id, mode, secret = nil, lease_seconds = nil) - @subscription = Subscription.find(subscription_id) - @mode = mode - @secret = secret - @lease_seconds = lease_seconds - process_confirmation - end - - private - - def process_confirmation - prepare_subscription - - callback_get_with_params - logger.debug "Confirming PuSH subscription for #{subscription.callback_url} with challenge #{challenge}: #{@callback_response_body}" - - update_subscription - end - - def update_subscription - if successful_subscribe? - subscription.save! - elsif successful_unsubscribe? - subscription.destroy! - end - end - - def successful_subscribe? - subscribing? && response_matches_challenge? - end - - def successful_unsubscribe? - (unsubscribing? && response_matches_challenge?) || !subscription.confirmed? - end - - def response_matches_challenge? - @callback_response_body == challenge - end - - def subscribing? - mode == 'subscribe' - end - - def unsubscribing? - mode == 'unsubscribe' - end - - def callback_get_with_params - Request.new(:get, subscription.callback_url, params: callback_params).perform do |response| - @callback_response_body = response.body_with_limit - end - end - - def callback_params - { - 'hub.topic': account_url(subscription.account, format: :atom), - 'hub.mode': mode, - 'hub.challenge': challenge, - 'hub.lease_seconds': subscription.lease_seconds, - } - end - - def prepare_subscription - subscription.secret = secret - subscription.lease_seconds = lease_seconds - subscription.confirmed = true - end - - def challenge - @_challenge ||= SecureRandom.hex - end + def perform(subscription_id, mode, secret = nil, lease_seconds = nil); end end diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb index 619bfa48a..1260060bd 100644 --- a/app/workers/pubsubhubbub/delivery_worker.rb +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -2,80 +2,8 @@ class Pubsubhubbub::DeliveryWorker include Sidekiq::Worker - include RoutingHelper sidekiq_options queue: 'push', retry: 3, dead: false - sidekiq_retry_in do |count| - 5 * (count + 1) - end - - attr_reader :subscription, :payload - - def perform(subscription_id, payload) - @subscription = Subscription.find(subscription_id) - @payload = payload - process_delivery unless blocked_domain? - rescue => e - raise e.class, "Delivery failed for #{subscription&.callback_url}: #{e.message}", e.backtrace[0] - end - - private - - def process_delivery - callback_post_payload do |payload_delivery| - raise Mastodon::UnexpectedResponseError, payload_delivery unless response_successful? payload_delivery - end - - subscription.touch(:last_successful_delivery_at) - end - - def callback_post_payload(&block) - request = Request.new(:post, subscription.callback_url, body: payload) - request.add_headers(headers) - request.perform(&block) - end - - def blocked_domain? - DomainBlock.blocked?(host) - end - - def host - Addressable::URI.parse(subscription.callback_url).normalized_host - end - - def headers - { - 'Content-Type' => 'application/atom+xml', - 'Link' => link_header, - }.merge(signature_headers.to_h) - end - - def link_header - LinkHeader.new([hub_link_header, self_link_header]).to_s - end - - def hub_link_header - [api_push_url, [%w(rel hub)]] - end - - def self_link_header - [account_url(subscription.account, format: :atom), [%w(rel self)]] - end - - def signature_headers - { 'X-Hub-Signature' => payload_signature } if subscription.secret? - end - - def payload_signature - "sha1=#{hmac_payload_digest}" - end - - def hmac_payload_digest - OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), subscription.secret, payload) - end - - def response_successful?(payload_delivery) - payload_delivery.code > 199 && payload_delivery.code < 300 - end + def perform(subscription_id, payload); end end diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb index fed5e917d..75bac5d6f 100644 --- a/app/workers/pubsubhubbub/distribution_worker.rb +++ b/app/workers/pubsubhubbub/distribution_worker.rb @@ -5,28 +5,5 @@ class Pubsubhubbub::DistributionWorker sidekiq_options queue: 'push' - def perform(stream_entry_ids) - stream_entries = StreamEntry.where(id: stream_entry_ids).includes(:status).reject { |e| e.status.nil? || e.status.hidden? } - - return if stream_entries.empty? - - @account = stream_entries.first.account - @subscriptions = active_subscriptions.to_a - - distribute_public!(stream_entries) - end - - private - - def distribute_public!(stream_entries) - @payload = OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, stream_entries)) - - Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions) do |subscription_id| - [subscription_id, @payload] - end - end - - def active_subscriptions - Subscription.where(account: @account).active.pluck(:id) - end + def perform(stream_entry_ids); end end diff --git a/app/workers/pubsubhubbub/raw_distribution_worker.rb b/app/workers/pubsubhubbub/raw_distribution_worker.rb index 16962a623..ece9c80ac 100644 --- a/app/workers/pubsubhubbub/raw_distribution_worker.rb +++ b/app/workers/pubsubhubbub/raw_distribution_worker.rb @@ -5,18 +5,5 @@ class Pubsubhubbub::RawDistributionWorker sidekiq_options queue: 'push' - def perform(xml, source_account_id) - @account = Account.find(source_account_id) - @subscriptions = active_subscriptions.to_a - - Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions) do |subscription| - [subscription.id, xml] - end - end - - private - - def active_subscriptions - Subscription.where(account: @account).active.select('id, callback_url, domain') - end + def perform(xml, source_account_id); end end diff --git a/app/workers/pubsubhubbub/subscribe_worker.rb b/app/workers/pubsubhubbub/subscribe_worker.rb index 2e176d1c1..b861b5e67 100644 --- a/app/workers/pubsubhubbub/subscribe_worker.rb +++ b/app/workers/pubsubhubbub/subscribe_worker.rb @@ -5,30 +5,5 @@ class Pubsubhubbub::SubscribeWorker sidekiq_options queue: 'push', retry: 10, unique: :until_executed, dead: false - sidekiq_retry_in do |count| - case count - when 0 - 30.minutes.seconds - when 1 - 2.hours.seconds - when 2 - 12.hours.seconds - else - 24.hours.seconds * (count - 2) - end - end - - sidekiq_retries_exhausted do |msg, _e| - account = Account.find(msg['args'].first) - Sidekiq.logger.error "PuSH subscription attempts for #{account.acct} exhausted. Unsubscribing" - ::UnsubscribeService.new.call(account) - end - - def perform(account_id) - account = Account.find(account_id) - logger.debug "PuSH re-subscribing to #{account.acct}" - ::SubscribeService.new.call(account) - rescue => e - raise e.class, "Subscribe failed for #{account&.acct}: #{e.message}", e.backtrace[0] - end + def perform(account_id); end end diff --git a/app/workers/pubsubhubbub/unsubscribe_worker.rb b/app/workers/pubsubhubbub/unsubscribe_worker.rb index a271715b7..0c1c263f6 100644 --- a/app/workers/pubsubhubbub/unsubscribe_worker.rb +++ b/app/workers/pubsubhubbub/unsubscribe_worker.rb @@ -5,11 +5,5 @@ class Pubsubhubbub::UnsubscribeWorker sidekiq_options queue: 'push', retry: false, unique: :until_executed, dead: false - def perform(account_id) - account = Account.find(account_id) - logger.debug "PuSH unsubscribing from #{account.acct}" - ::UnsubscribeService.new.call(account) - rescue ActiveRecord::RecordNotFound - true - end + def perform(account_id); end end diff --git a/app/workers/remote_profile_update_worker.rb b/app/workers/remote_profile_update_worker.rb index 03585ad2d..01e8daf8f 100644 --- a/app/workers/remote_profile_update_worker.rb +++ b/app/workers/remote_profile_update_worker.rb @@ -5,9 +5,5 @@ class RemoteProfileUpdateWorker sidekiq_options queue: 'pull' - def perform(account_id, body, resubscribe) - UpdateRemoteProfileService.new.call(body, Account.find(account_id), resubscribe) - rescue ActiveRecord::RecordNotFound - true - end + def perform(account_id, body, resubscribe); end end diff --git a/app/workers/salmon_worker.rb b/app/workers/salmon_worker.rb index d37d40432..10200b06c 100644 --- a/app/workers/salmon_worker.rb +++ b/app/workers/salmon_worker.rb @@ -5,9 +5,5 @@ class SalmonWorker sidekiq_options backtrace: true - def perform(account_id, body) - ProcessInteractionService.new.call(body, Account.find(account_id)) - rescue Nokogiri::XML::XPath::SyntaxError, ActiveRecord::RecordNotFound - true - end + def perform(account_id, body); end end diff --git a/app/workers/scheduler/preview_cards_cleanup_scheduler.rb b/app/workers/scheduler/preview_cards_cleanup_scheduler.rb new file mode 100644 index 000000000..2b38792f0 --- /dev/null +++ b/app/workers/scheduler/preview_cards_cleanup_scheduler.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Scheduler::PreviewCardsCleanupScheduler + include Sidekiq::Worker + + sidekiq_options unique: :until_executed, retry: 0 + + def perform + Maintenance::UncachePreviewWorker.push_bulk(recent_link_preview_cards.pluck(:id)) + Maintenance::UncachePreviewWorker.push_bulk(older_preview_cards.pluck(:id)) + end + + private + + def recent_link_preview_cards + PreviewCard.where(type: :link).where('updated_at < ?', 1.month.ago) + end + + def older_preview_cards + PreviewCard.where('updated_at < ?', 6.months.ago) + end +end diff --git a/app/workers/scheduler/subscriptions_scheduler.rb b/app/workers/scheduler/subscriptions_scheduler.rb index d5873bccb..6903cadc7 100644 --- a/app/workers/scheduler/subscriptions_scheduler.rb +++ b/app/workers/scheduler/subscriptions_scheduler.rb @@ -5,13 +5,5 @@ class Scheduler::SubscriptionsScheduler sidekiq_options unique: :until_executed, retry: 0 - def perform - Pubsubhubbub::SubscribeWorker.push_bulk(expiring_accounts.pluck(:id)) - end - - private - - def expiring_accounts - Account.expiring(1.day.from_now).partitioned - end + def perform; end end |