diff options
author | Starfall <admin@plural.cafe> | 2020-06-19 13:14:45 -0500 |
---|---|---|
committer | Starfall <admin@plural.cafe> | 2020-06-19 13:14:45 -0500 |
commit | 5668836f56cddf3257f38a2483c1d42cacbad3a8 (patch) | |
tree | c241a44562f79ccecaf215eb572c22d559b08dd1 /app | |
parent | 76f79dd29909d39f1d36ef48e6892223d59e29d7 (diff) | |
parent | c7da2cc5a1821e589e7241aec21d8bb426722c2a (diff) |
Merge branch 'glitch' into main
Diffstat (limited to 'app')
194 files changed, 2597 insertions, 299 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index e3d8c1061..b8bca580f 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class AccountsController < ApplicationController - PAGE_SIZE = 20 + PAGE_SIZE = 20 + PAGE_SIZE_MAX = 200 include AccountControllerConcern include SignatureAuthentication @@ -27,7 +28,7 @@ class AccountsController < ApplicationController return end - @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? + @pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses? @statuses = filtered_status_page @statuses = cache_collection(@statuses, Status) @rss_url = rss_url @@ -41,7 +42,8 @@ class AccountsController < ApplicationController format.rss do expires_in 1.minute, public: true - @statuses = filtered_statuses.without_reblogs.limit(PAGE_SIZE) + limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE + @statuses = filtered_statuses.without_reblogs.limit(limit) @statuses = cache_collection(@statuses, Status) render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag]) end diff --git a/app/controllers/activitypub/claims_controller.rb b/app/controllers/activitypub/claims_controller.rb new file mode 100644 index 000000000..08ad952df --- /dev/null +++ b/app/controllers/activitypub/claims_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ActivityPub::ClaimsController < ActivityPub::BaseController + include SignatureVerification + include AccountOwnedConcern + + skip_before_action :authenticate_user! + + before_action :require_signature! + before_action :set_claim_result + + def create + render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer + end + + private + + def set_claim_result + @claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id]) + end +end diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index c1e7aa550..e62fba748 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -5,8 +5,9 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController include AccountOwnedConcern before_action :require_signature!, if: :authorized_fetch_mode? + before_action :set_items before_action :set_size - before_action :set_statuses + before_action :set_type before_action :set_cache_headers def show @@ -16,40 +17,53 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController private - def set_statuses - @statuses = scope_for_collection - @statuses = cache_collection(@statuses, Status) + def set_items + case params[:id] + when 'featured' + @items = begin + # Because in public fetch mode we cache the response, there would be no + # benefit from performing the check below, since a blocked account or domain + # would likely be served the cache from the reverse proxy anyway + + if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + [] + else + cache_collection(@account.pinned_statuses.not_local_only, Status) + end + end + when 'devices' + @items = @account.devices + else + not_found + end end def set_size case params[:id] - when 'featured' - @size = @account.pinned_statuses.count + when 'featured', 'devices' + @size = @items.size else not_found end end - def scope_for_collection + def set_type case params[:id] when 'featured' - # Because in public fetch mode we cache the response, there would be no - # benefit from performing the check below, since a blocked account or domain - # would likely be served the cache from the reverse proxy anyway - if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) - Status.none - else - @account.pinned_statuses - end + @type = :ordered + when 'devices' + @type = :unordered + else + not_found end end def collection_presenter ActivityPub::CollectionPresenter.new( id: account_collection_url(@account, params[:id]), - type: :ordered, + type: @type, size: @size, - items: @statuses + items: @items ) end end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index efa8f2950..71efb543e 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -33,6 +33,8 @@ module Admin @form.save rescue ActionController::ParameterMissing flash[:alert] = I18n.t('admin.accounts.no_account_selected') + rescue Mastodon::NotPermittedError + flash[:alert] = I18n.t('admin.custom_emojis.not_permitted') ensure redirect_to admin_custom_emojis_path(filter_params) end diff --git a/app/controllers/api/v1/crypto/deliveries_controller.rb b/app/controllers/api/v1/crypto/deliveries_controller.rb new file mode 100644 index 000000000..aa9df6e03 --- /dev/null +++ b/app/controllers/api/v1/crypto/deliveries_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::DeliveriesController < Api::BaseController + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + before_action :set_current_device + + def create + devices.each do |device_params| + DeliverToDeviceService.new.call(current_account, @current_device, device_params) + end + + render_empty + end + + private + + def set_current_device + @current_device = Device.find_by!(access_token: doorkeeper_token) + end + + def resource_params + params.require(:device) + params.permit(device: [:account_id, :device_id, :type, :body, :hmac]) + end + + def devices + Array(resource_params[:device]) + end +end diff --git a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb new file mode 100644 index 000000000..c764915e5 --- /dev/null +++ b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController + LIMIT = 80 + + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + before_action :set_current_device + + before_action :set_encrypted_messages, only: :index + after_action :insert_pagination_headers, only: :index + + def index + render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer + end + + def clear + @current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all + render_empty + end + + private + + def set_current_device + @current_device = Device.find_by!(access_token: doorkeeper_token) + end + + def set_encrypted_messages + @encrypted_messages = @current_device.encrypted_messages.paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty? + end + + def pagination_max_id + @encrypted_messages.last.id + end + + def pagination_since_id + @encrypted_messages.first.id + end + + def records_continue? + @encrypted_messages.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/crypto/keys/claims_controller.rb b/app/controllers/api/v1/crypto/keys/claims_controller.rb new file mode 100644 index 000000000..34b21a380 --- /dev/null +++ b/app/controllers/api/v1/crypto/keys/claims_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + before_action :set_claim_results + + def create + render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer + end + + private + + def set_claim_results + @claim_results = devices.map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }.compact + end + + def resource_params + params.permit(device: [:account_id, :device_id]) + end + + def devices + Array(resource_params[:device]) + end +end diff --git a/app/controllers/api/v1/crypto/keys/counts_controller.rb b/app/controllers/api/v1/crypto/keys/counts_controller.rb new file mode 100644 index 000000000..ffd7151b7 --- /dev/null +++ b/app/controllers/api/v1/crypto/keys/counts_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::Keys::CountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + before_action :set_current_device + + def show + render json: { one_time_keys: @current_device.one_time_keys.count } + end + + private + + def set_current_device + @current_device = Device.find_by!(access_token: doorkeeper_token) + end +end diff --git a/app/controllers/api/v1/crypto/keys/queries_controller.rb b/app/controllers/api/v1/crypto/keys/queries_controller.rb new file mode 100644 index 000000000..0851d797d --- /dev/null +++ b/app/controllers/api/v1/crypto/keys/queries_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::Keys::QueriesController < Api::BaseController + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + before_action :set_accounts + before_action :set_query_results + + def create + render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer + end + + private + + def set_accounts + @accounts = Account.where(id: account_ids).includes(:devices) + end + + def set_query_results + @query_results = @accounts.map { |account| ::Keys::QueryService.new.call(account) }.compact + end + + def account_ids + Array(params[:id]).map(&:to_i) + end +end diff --git a/app/controllers/api/v1/crypto/keys/uploads_controller.rb b/app/controllers/api/v1/crypto/keys/uploads_controller.rb new file mode 100644 index 000000000..fc4abf63b --- /dev/null +++ b/app/controllers/api/v1/crypto/keys/uploads_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::Keys::UploadsController < Api::BaseController + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + + def create + device = Device.find_or_initialize_by(access_token: doorkeeper_token) + + device.transaction do + device.account = current_account + device.update!(resource_params[:device]) + + if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable) + resource_params[:one_time_keys].each do |one_time_key_params| + device.one_time_keys.create!(one_time_key_params) + end + end + end + + render json: device, serializer: REST::Keys::DeviceSerializer + end + + private + + def resource_params + params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature]) + end +end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index c36561b86..c54f6643a 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -9,7 +9,9 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_functional! prepend_before_action :set_pack - prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] + + include TwoFactorAuthenticationConcern + include SignInTokenAuthenticationConcern before_action :set_instance_presenter, only: [:new] before_action :set_body_classes @@ -40,8 +42,8 @@ class Auth::SessionsController < Devise::SessionsController protected def find_user - if session[:otp_user_id] - User.find(session[:otp_user_id]) + if session[:attempt_user_id] + User.find(session[:attempt_user_id]) else user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication @@ -50,7 +52,7 @@ class Auth::SessionsController < Devise::SessionsController end def user_params - params.require(:user).permit(:email, :password, :otp_attempt) + params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt) end def after_sign_in_path_for(resource) @@ -71,48 +73,6 @@ class Auth::SessionsController < Devise::SessionsController super end - def two_factor_enabled? - find_user&.otp_required_for_login? - end - - def valid_otp_attempt?(user) - user.validate_and_consume_otp!(user_params[:otp_attempt]) || - user.invalidate_otp_backup_code!(user_params[:otp_attempt]) - rescue OpenSSL::Cipher::CipherError - false - end - - def authenticate_with_two_factor - user = self.resource = find_user - - if user_params[:otp_attempt].present? && session[:otp_user_id] - authenticate_with_two_factor_via_otp(user) - elsif user.present? && (user.encrypted_password.blank? || user.valid_password?(user_params[:password])) - # If encrypted_password is blank, we got the user from LDAP or PAM, - # so credentials are already valid - - prompt_for_two_factor(user) - end - end - - def authenticate_with_two_factor_via_otp(user) - if valid_otp_attempt?(user) - session.delete(:otp_user_id) - remember_me(user) - sign_in(user) - else - flash.now[:alert] = I18n.t('users.invalid_otp_token') - prompt_for_two_factor(user) - end - end - - def prompt_for_two_factor(user) - session[:otp_user_id] = user.id - use_pack 'auth' - @body_classes = 'lighter' - render :two_factor - end - def require_no_authentication super # Delete flash message that isn't entirely useful and may be confusing in diff --git a/app/controllers/concerns/sign_in_token_authentication_concern.rb b/app/controllers/concerns/sign_in_token_authentication_concern.rb new file mode 100644 index 000000000..88c009b19 --- /dev/null +++ b/app/controllers/concerns/sign_in_token_authentication_concern.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module SignInTokenAuthenticationConcern + extend ActiveSupport::Concern + + included do + prepend_before_action :authenticate_with_sign_in_token, if: :sign_in_token_required?, only: [:create] + end + + def sign_in_token_required? + find_user&.suspicious_sign_in?(request.remote_ip) + end + + def valid_sign_in_token_attempt?(user) + Devise.secure_compare(user.sign_in_token, user_params[:sign_in_token_attempt]) + end + + def authenticate_with_sign_in_token + user = self.resource = find_user + + if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id] + authenticate_with_sign_in_token_attempt(user) + elsif user.present? && user.external_or_valid_password?(user_params[:password]) + prompt_for_sign_in_token(user) + end + end + + def authenticate_with_sign_in_token_attempt(user) + if valid_sign_in_token_attempt?(user) + session.delete(:attempt_user_id) + remember_me(user) + sign_in(user) + else + flash.now[:alert] = I18n.t('users.invalid_sign_in_token') + prompt_for_sign_in_token(user) + end + end + + def prompt_for_sign_in_token(user) + if user.sign_in_token_expired? + user.generate_sign_in_token && user.save + UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later! + end + + session[:attempt_user_id] = user.id + use_pack 'auth' + @body_classes = 'lighter' + render :sign_in_token + end +end diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb new file mode 100644 index 000000000..0d9f87455 --- /dev/null +++ b/app/controllers/concerns/two_factor_authentication_concern.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module TwoFactorAuthenticationConcern + extend ActiveSupport::Concern + + included do + prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] + end + + def two_factor_enabled? + find_user&.otp_required_for_login? + end + + def valid_otp_attempt?(user) + user.validate_and_consume_otp!(user_params[:otp_attempt]) || + user.invalidate_otp_backup_code!(user_params[:otp_attempt]) + rescue OpenSSL::Cipher::CipherError + false + end + + def authenticate_with_two_factor + user = self.resource = find_user + + if user_params[:otp_attempt].present? && session[:attempt_user_id] + authenticate_with_two_factor_attempt(user) + elsif user.present? && user.external_or_valid_password?(user_params[:password]) + prompt_for_two_factor(user) + end + end + + def authenticate_with_two_factor_attempt(user) + if valid_otp_attempt?(user) + session.delete(:attempt_user_id) + remember_me(user) + sign_in(user) + else + flash.now[:alert] = I18n.t('users.invalid_otp_token') + prompt_for_two_factor(user) + end + end + + def prompt_for_two_factor(user) + session[:attempt_user_id] = user.id + use_pack 'auth' + @body_classes = 'lighter' + render :two_factor + end +end diff --git a/app/controllers/settings/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb index 6e5b72ffb..97193ade0 100644 --- a/app/controllers/settings/migration/redirects_controller.rb +++ b/app/controllers/settings/migration/redirects_controller.rb @@ -18,7 +18,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController if @redirect.valid_with_challenge?(current_user) current_account.update!(moved_to_account: @redirect.target_account) ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) - redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct) + redirect_to settings_migration_path, notice: I18n.t('migrations.redirected_msg', acct: current_account.moved_to_account.acct) else render :new end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index a1b7f4320..b0abad984 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -44,7 +44,7 @@ class StatusesController < ApplicationController def activity expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? - render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter + render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter end def embed diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 3d12c9eaf..2363cb31b 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -3,7 +3,8 @@ class TagsController < ApplicationController include SignatureVerification - PAGE_SIZE = 20 + PAGE_SIZE = 20 + PAGE_SIZE_MAX = 200 layout 'public' @@ -26,6 +27,7 @@ class TagsController < ApplicationController format.rss do expires_in 0, public: true + limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE @statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(PAGE_SIZE) @statuses = cache_collection(@statuses, Status) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 40f914f1e..2f11ccb6f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -137,6 +137,11 @@ module ApplicationHelper text: [params[:title], params[:text], params[:url]].compact.join(' '), } + permit_visibilities = %w(public unlisted private direct) + default_privacy = current_account&.user&.setting_default_privacy + permit_visibilities.shift(permit_visibilities.index(default_privacy) + 1) if default_privacy.present? + state_params[:visibility] = params[:visibility] if permit_visibilities.include? params[:visibility] + if user_signed_in? state_params[:settings] = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {}) state_params[:push_subscription] = current_account.user.web_push_subscription(current_session) diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb index 70c493210..ab7ca4698 100644 --- a/app/helpers/webfinger_helper.rb +++ b/app/helpers/webfinger_helper.rb @@ -1,5 +1,16 @@ # frozen_string_literal: true +# Monkey-patch on monkey-patch. +# Because it conflicts with the request.rb patch. +class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation + def connect(socket_class, host, port, nodelay = false) + ::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do + @socket = socket_class.open(host, port) + @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay + end + end +end + module WebfingerHelper def webfinger!(uri) hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri) @@ -12,6 +23,14 @@ module WebfingerHelper headers: { 'User-Agent': Mastodon::Version.user_agent, }, + + timeout_class: HTTP::Timeout::PerOperationOriginal, + + timeout_options: { + write_timeout: 10, + connect_timeout: 5, + read_timeout: 10, + }, } Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index 52ad17779..05955963c 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { export function searchTextFromRawStatus (status) { const spoilerText = status.spoiler_text || ''; - const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; } diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js index ec2fbbe4b..1ce2f42b4 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js @@ -208,7 +208,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { <span style={{ display: 'none' }}>{placeholder}</span> <Textarea - inputRef={this.setTextarea} + ref={this.setTextarea} className='autosuggest-textarea__textarea' disabled={disabled} placeholder={placeholder} diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js index c022290a4..fae0a7393 100644 --- a/app/javascript/flavours/glitch/components/scrollable_list.js +++ b/app/javascript/flavours/glitch/components/scrollable_list.js @@ -32,6 +32,7 @@ export default class ScrollableList extends PureComponent { hasMore: PropTypes.bool, numPending: PropTypes.number, prepend: PropTypes.node, + append: PropTypes.node, alwaysPrepend: PropTypes.bool, emptyMessage: PropTypes.node, children: PropTypes.node, @@ -272,7 +273,7 @@ export default class ScrollableList extends PureComponent { } render () { - const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = React.Children.count(children); @@ -319,6 +320,8 @@ export default class ScrollableList extends PureComponent { ))} {loadMore} + + {!hasMore && append} </div> </div> ); diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 91bc06b3c..e036c0da7 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -656,6 +656,7 @@ class Status extends ImmutablePureComponent { compact cacheWidth={this.props.cacheMediaWidth} defaultWidth={this.props.cachedMediaWidth} + sensitive={status.get('sensitive')} /> ); mediaIcon = 'link'; diff --git a/app/javascript/flavours/glitch/components/timeline_hint.js b/app/javascript/flavours/glitch/components/timeline_hint.js new file mode 100644 index 000000000..fb55a62cc --- /dev/null +++ b/app/javascript/flavours/glitch/components/timeline_hint.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +const TimelineHint = ({ resource, url }) => ( + <div className='timeline-hint'> + <strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong> + <br /> + <a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a> + </div> +); + +TimelineHint.propTypes = { + resource: PropTypes.node.isRequired, + url: PropTypes.string.isRequired, +}; + +export default TimelineHint; diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js index f25c82a00..a8e8aa7a8 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.js +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -15,11 +15,14 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { FormattedMessage } from 'react-intl'; import { fetchAccountIdentityProofs } from '../../actions/identity_proofs'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import TimelineHint from 'flavours/glitch/components/timeline_hint'; const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { const path = withReplies ? `${accountId}:with_replies` : accountId; return { + remote: !!state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username']), + remoteUrl: state.getIn(['accounts', accountId, 'url']), isAccount: !!state.getIn(['accounts', accountId]), statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), @@ -28,6 +31,14 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) }; }; +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + export default @connect(mapStateToProps) class AccountTimeline extends ImmutablePureComponent { @@ -40,6 +51,8 @@ class AccountTimeline extends ImmutablePureComponent { hasMore: PropTypes.bool, withReplies: PropTypes.bool, isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, }; @@ -78,7 +91,7 @@ class AccountTimeline extends ImmutablePureComponent { } render () { - const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn } = this.props; + const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -97,6 +110,16 @@ class AccountTimeline extends ImmutablePureComponent { ); } + let emptyMessage; + + if (remote && statusIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; + return ( <Column ref={this.setRef} name='account'> <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> @@ -104,13 +127,14 @@ class AccountTimeline extends ImmutablePureComponent { <StatusList prepend={<HeaderContainer accountId={this.props.params.accountId} />} alwaysPrepend + append={remoteMessage} scrollKey='account_timeline' statusIds={statusIds} featuredStatusIds={featuredStatusIds} isLoading={isLoading} hasMore={hasMore} onLoadMore={this.handleLoadMore} - emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />} + emptyMessage={emptyMessage} bindToDocument={!multiColumn} timelineId='account' /> diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js index 49e91227f..ba3534492 100644 --- a/app/javascript/flavours/glitch/features/audio/index.js +++ b/app/javascript/flavours/glitch/features/audio/index.js @@ -125,6 +125,7 @@ class Audio extends React.PureComponent { this.wavesurfer.createPeakCache(); this.wavesurfer.load(this.props.src); this.wavesurfer.toggleInteraction(); + this.wavesurfer.setVolume(this.state.volume); this.loaded = true; } diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js index 3717fcd82..14e5cb94a 100644 --- a/app/javascript/flavours/glitch/features/emoji_picker/index.js +++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js @@ -279,12 +279,13 @@ class EmojiPickerMenu extends React.PureComponent { }; } - handleClick = emoji => { + handleClick = (emoji, event) => { if (!emoji.native) { emoji.native = emoji.colons; } - - this.props.onClose(); + if (!event.ctrlKey) { + this.props.onClose(); + } this.props.onPick(emoji); } diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js index bf41f3b98..8ae46be94 100644 --- a/app/javascript/flavours/glitch/features/followers/index.js +++ b/app/javascript/flavours/glitch/features/followers/index.js @@ -17,14 +17,25 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container import ImmutablePureComponent from 'react-immutable-pure-component'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import TimelineHint from 'flavours/glitch/components/timeline_hint'; const mapStateToProps = (state, props) => ({ + remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']), + remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']), isAccount: !!state.getIn(['accounts', props.params.accountId]), accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']), hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']), isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true), }); +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + export default @connect(mapStateToProps) class Followers extends ImmutablePureComponent { @@ -35,6 +46,8 @@ class Followers extends ImmutablePureComponent { hasMore: PropTypes.bool, isLoading: PropTypes.bool, isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, }; @@ -65,7 +78,7 @@ class Followers extends ImmutablePureComponent { } render () { - const { accountIds, hasMore, isAccount, multiColumn, isLoading } = this.props; + const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -83,7 +96,15 @@ class Followers extends ImmutablePureComponent { ); } - const emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />; + let emptyMessage; + + if (remote && accountIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; return ( <Column ref={this.setRef}> @@ -96,6 +117,7 @@ class Followers extends ImmutablePureComponent { onLoadMore={this.handleLoadMore} prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} alwaysPrepend + append={remoteMessage} emptyMessage={emptyMessage} bindToDocument={!multiColumn} > diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js index f090900cc..e06eaa8a6 100644 --- a/app/javascript/flavours/glitch/features/following/index.js +++ b/app/javascript/flavours/glitch/features/following/index.js @@ -17,14 +17,25 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container import ImmutablePureComponent from 'react-immutable-pure-component'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import TimelineHint from 'flavours/glitch/components/timeline_hint'; const mapStateToProps = (state, props) => ({ + remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']), + remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']), isAccount: !!state.getIn(['accounts', props.params.accountId]), accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']), hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']), isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true), }); +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + export default @connect(mapStateToProps) class Following extends ImmutablePureComponent { @@ -35,6 +46,8 @@ class Following extends ImmutablePureComponent { hasMore: PropTypes.bool, isLoading: PropTypes.bool, isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, }; @@ -65,7 +78,7 @@ class Following extends ImmutablePureComponent { } render () { - const { accountIds, hasMore, isAccount, multiColumn, isLoading } = this.props; + const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -83,7 +96,15 @@ class Following extends ImmutablePureComponent { ); } - const emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />; + let emptyMessage; + + if (remote && accountIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; return ( <Column ref={this.setRef}> @@ -96,6 +117,7 @@ class Following extends ImmutablePureComponent { onLoadMore={this.handleLoadMore} prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} alwaysPrepend + append={remoteMessage} emptyMessage={emptyMessage} bindToDocument={!multiColumn} > diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js index 0bb71e872..abc3f468f 100644 --- a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js +++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js @@ -106,6 +106,10 @@ class KeyboardShortcuts extends ImmutablePureComponent { <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td> </tr> <tr> + <td><kbd>alt</kbd>+<kbd>x</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td> + </tr> + <tr> <td><kbd>backspace</kbd></td> <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td> </tr> diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js index e3ee7dada..03867e03a 100644 --- a/app/javascript/flavours/glitch/features/status/components/card.js +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -2,10 +2,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; import punycode from 'punycode'; import classnames from 'classnames'; import { decode as decodeIDNA } from 'flavours/glitch/util/idna'; import Icon from 'flavours/glitch/components/icon'; +import classNames from 'classnames'; +import { useBlurhash } from 'flavours/glitch/util/initial_state'; +import { decode } from 'blurhash'; const getHostname = url => { const parser = document.createElement('a'); @@ -55,6 +59,7 @@ export default class Card extends React.PureComponent { compact: PropTypes.bool, defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, + sensitive: PropTypes.bool, }; static defaultProps = { @@ -64,12 +69,44 @@ export default class Card extends React.PureComponent { state = { width: this.props.defaultWidth || 280, + previewLoaded: false, embedded: false, + revealed: !this.props.sensitive, }; componentWillReceiveProps (nextProps) { if (!Immutable.is(this.props.card, nextProps.card)) { - this.setState({ embedded: false }); + this.setState({ embedded: false, previewLoaded: false }); + } + if (this.props.sensitive !== nextProps.sensitive) { + this.setState({ revealed: !nextProps.sensitive }); + } + } + + componentDidMount () { + if (this.props.card && this.props.card.get('blurhash')) { + this._decode(); + } + } + + componentDidUpdate (prevProps) { + const { card } = this.props; + if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash'))) { + this._decode(); + } + } + + _decode () { + if (!useBlurhash) return; + + const hash = this.props.card.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); } } @@ -111,6 +148,18 @@ export default class Card extends React.PureComponent { } } + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ previewLoaded: true }); + } + + handleReveal = () => { + this.setState({ revealed: true }); + } + renderVideo () { const { card } = this.props; const content = { __html: addAutoPlay(card.get('html')) }; @@ -130,7 +179,7 @@ export default class Card extends React.PureComponent { render () { const { card, maxDescription, compact, defaultWidth } = this.props; - const { width, embedded } = this.state; + const { width, embedded, revealed } = this.state; if (card === null) { return null; @@ -145,7 +194,7 @@ export default class Card extends React.PureComponent { const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); const description = ( - <div className='status-card__content'> + <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}> {title} {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>} <span className='status-card__host'>{provider}</span> @@ -153,7 +202,18 @@ export default class Card extends React.PureComponent { ); let embed = ''; - let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />; + let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />; + let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />; + let spoilerButton = ( + <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + </button> + ); + spoilerButton = ( + <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}> + {spoilerButton} + </div> + ); if (interactive) { if (embedded) { @@ -167,14 +227,18 @@ export default class Card extends React.PureComponent { embed = ( <div className='status-card__image'> + {canvas} {thumbnail} - <div className='status-card__actions'> - <div> - <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> - {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} + {revealed && ( + <div className='status-card__actions'> + <div> + <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> + {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} + </div> </div> - </div> + )} + {!revealed && spoilerButton} </div> ); } @@ -188,13 +252,16 @@ export default class Card extends React.PureComponent { } else if (card.get('image')) { embed = ( <div className='status-card__image'> + {canvas} {thumbnail} + {!revealed && spoilerButton} </div> ); } else { embed = ( <div className='status-card__image'> <Icon id='file-text' /> + {!revealed && spoilerButton} </div> ); } diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 17f22a8a2..4fbd65517 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -184,7 +184,7 @@ export default class DetailedStatus extends ImmutablePureComponent { mediaIcon = 'picture-o'; } } else if (status.get('card')) { - media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />; + media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />; mediaIcon = 'link'; } diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index f8f6cff88..bf76c0e57 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -7,7 +7,7 @@ import { connect } from 'react-redux'; import { Redirect, withRouter } from 'react-router-dom'; import { isMobile } from 'flavours/glitch/util/is_mobile'; import { debounce } from 'lodash'; -import { uploadCompose, resetCompose } from 'flavours/glitch/actions/compose'; +import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose'; import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications'; import { fetchFilters } from 'flavours/glitch/actions/filters'; @@ -81,6 +81,7 @@ const keyMap = { new: 'n', search: 's', forceNew: 'option+n', + toggleComposeSpoilers: 'option+x', focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], reply: 'r', favourite: 'f', @@ -396,7 +397,7 @@ class UI extends React.Component { componentDidMount () { this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; }; } @@ -455,6 +456,11 @@ class UI extends React.Component { this.props.dispatch(resetCompose()); } + handleHotkeyToggleComposeSpoilers = e => { + e.preventDefault(); + this.props.dispatch(changeComposeSpoilerness()); + } + handleHotkeyFocusColumn = e => { const index = (e.key * 1) + 1; // First child is drawer, skip that const column = this.node.querySelector(`.column:nth-child(${index})`); @@ -569,6 +575,7 @@ class UI extends React.Component { new: this.handleHotkeyNew, search: this.handleHotkeySearch, forceNew: this.handleHotkeyForceNew, + toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers, focusColumn: this.handleHotkeyFocusColumn, back: this.handleHotkeyBack, goToHome: this.handleHotkeyGoToHome, diff --git a/app/javascript/flavours/glitch/styles/accessibility.scss b/app/javascript/flavours/glitch/styles/accessibility.scss index 35e91da80..1a2de2f06 100644 --- a/app/javascript/flavours/glitch/styles/accessibility.scss +++ b/app/javascript/flavours/glitch/styles/accessibility.scss @@ -1,13 +1,13 @@ -$emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash' !default; +$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange' 'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on' 'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default; -%emoji-outline { - filter: drop-shadow(1px 1px 0 $primary-text-color) drop-shadow(-1px 1px 0 $primary-text-color) drop-shadow(1px -1px 0 $primary-text-color) drop-shadow(-1px -1px 0 $primary-text-color); +%emoji-color-inversion { + filter: invert(1); } .emojione { - @each $emoji in $emojis-requiring-outlines { + @each $emoji in $emojis-requiring-inversion { &[title=':#{$emoji}:'] { - @extend %emoji-outline; + @extend %emoji-color-inversion; } } } diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index 3269638eb..6b657660a 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -363,8 +363,8 @@ @extend .column-header__button; background: transparent; text-align: center; - padding: 10px 0; - white-space: pre-wrap; + padding: 10px 5px; + font-size: 14px; } b { @@ -372,6 +372,23 @@ } } + +.layout-single-column .column-header__notif-cleaning-buttons { + @media screen and (min-width: $no-gap-breakpoint) { + b, i { + margin-right: 5px; + } + + br { + display: none; + } + + button { + padding: 15px 5px; + } + } +} + // The notifs drawer with no padding to have more space for the buttons .column-header__collapsible-inner.nopad-drawer { padding: 0; diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 50cea8b26..a37cef795 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -1093,6 +1093,31 @@ border-bottom: 1px solid lighten($ui-base-color, 8%); } +.timeline-hint { + text-align: center; + color: $darker-text-color; + padding: 15px; + box-sizing: border-box; + width: 100%; + cursor: default; + + strong { + font-weight: 500; + } + + a { + color: lighten($ui-highlight-color, 8%); + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + color: lighten($ui-highlight-color, 12%); + } + } +} + .missing-indicator { padding-top: 20px + 48px; diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 50b7f2a72..28a4ce0ce 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -874,6 +874,11 @@ a.status-card { flex: 1 1 auto; overflow: hidden; padding: 14px 14px 14px 8px; + + &--blurred { + filter: blur(2px); + pointer-events: none; + } } .status-card__description { @@ -911,7 +916,8 @@ a.status-card { width: 100%; } - .status-card__image-image { + .status-card__image-image, + .status-card__image-preview { border-radius: 4px 4px 0 0; } @@ -956,6 +962,24 @@ a.status-card.compact:hover { background-position: center center; } +.status-card__image-preview { + border-radius: 4px 0 0 4px; + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: fill; + position: absolute; + top: 0; + left: 0; + z-index: 0; + background: $base-overlay-background; + + &--hidden { + display: none; + } +} + .attachment-list { display: flex; font-size: 14px; diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss index 5de650f0a..6767c15f1 100644 --- a/app/javascript/flavours/glitch/styles/forms.scss +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -578,7 +578,7 @@ code { &.alert { border: 1px solid rgba($error-value-color, 0.5); - background: rgba($error-value-color, 0.25); + background: rgba($error-value-color, 0.1); color: $error-value-color; } diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss index 312f5e314..7709d4535 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss @@ -37,4 +37,4 @@ $account-background-color: $white !default; @return hsl(hue($color), saturation($color), lightness($color) - $amount); } -$emojis-requiring-outlines: 'alien' 'baseball' 'chains' 'chicken' 'cloud' 'crescent_moon' 'dash' 'dove_of_peace' 'eyes' 'first_quarter_moon' 'first_quarter_moon_with_face' 'fish_cake' 'full_moon' 'full_moon_with_face' 'ghost' 'goat' 'grey_exclamation' 'grey_question' 'ice_skate' 'last_quarter_moon' 'last_quarter_moon_with_face' 'lightning' 'loud_sound' 'moon' 'mute' 'page_with_curl' 'rain_cloud' 'ram' 'rice' 'rice_ball' 'rooster' 'sheep' 'skull' 'skull_and_crossbones' 'snow_cloud' 'sound' 'speaker' 'speech_balloon' 'thought_balloon' 'volleyball' 'waning_crescent_moon' 'waning_gibbous_moon' 'waving_white_flag' 'waxing_crescent_moon' 'white_circle' 'white_large_square' 'white_medium_small_square' 'white_medium_square' 'white_small_square' 'wind_blowing_face'; +$emojis-requiring-inversion: 'chains'; diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js index e1a244127..61f211c92 100644 --- a/app/javascript/flavours/glitch/util/emoji/index.js +++ b/app/javascript/flavours/glitch/util/emoji/index.js @@ -6,6 +6,20 @@ const trie = new Trie(Object.keys(unicodeMapping)); const assetHost = process.env.CDN_HOST || ''; +// Convert to file names from emojis. (For different variation selector emojis) +const emojiFilenames = (emojis) => { + return emojis.map(v => unicodeMapping[v].filename); +}; + +// Emoji requiring extra borders depending on theme +const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴']); +const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']); + +const emojiFilename = (filename) => { + const borderedEmoji = (document.body && document.body.classList.contains('skin-mastodon-light')) ? lightEmoji : darkEmoji; + return borderedEmoji.includes(filename) ? (filename + '_border') : filename; +}; + const emojify = (str, customEmojis = {}) => { const tagCharsWithoutEmojis = '<&'; const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&'; @@ -60,7 +74,7 @@ const emojify = (str, customEmojis = {}) => { } else if (!useSystemEmojiFont) { // matched to unicode emoji const { filename, shortCode } = unicodeMapping[match]; const title = shortCode ? `:${shortCode}:` : ''; - replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`; + replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${emojiFilename(filename)}.svg" />`; rend = i + match.length; // If the matched character was followed by VS15 (for selecting text presentation), skip it. if (str.codePointAt(rend) === 65038) { diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index f7cbe4c1c..dca44917a 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { export function searchTextFromRawStatus (status) { const spoilerText = status.spoiler_text || ''; - const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; } diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index ac2a6366a..58ec4f6eb 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -208,7 +208,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { <span style={{ display: 'none' }}>{placeholder}</span> <Textarea - inputRef={this.setTextarea} + ref={this.setTextarea} className='autosuggest-textarea__textarea' disabled={disabled} placeholder={placeholder} diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js index fa4e59192..6297b5e29 100644 --- a/app/javascript/mastodon/components/modal_root.js +++ b/app/javascript/mastodon/components/modal_root.js @@ -66,7 +66,7 @@ export default class ModalRoot extends React.PureComponent { // immediately selectable, we have to wait for observers to run, as // described in https://github.com/WICG/inert#performance-and-gotchas Promise.resolve().then(() => { - this.activeElement.focus(); + this.activeElement.focus({ preventScroll: true }); this.activeElement = null; }).catch((error) => { console.error(error); diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index 65ca43911..7eb0910c9 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -32,6 +32,7 @@ export default class ScrollableList extends PureComponent { hasMore: PropTypes.bool, numPending: PropTypes.number, prepend: PropTypes.node, + append: PropTypes.node, alwaysPrepend: PropTypes.bool, emptyMessage: PropTypes.node, children: PropTypes.node, @@ -280,7 +281,7 @@ export default class ScrollableList extends PureComponent { } render () { - const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = React.Children.count(children); @@ -327,6 +328,8 @@ export default class ScrollableList extends PureComponent { ))} {loadMore} + + {!hasMore && append} </div> </div> ); diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 9e4442cef..f99ccd39a 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -401,6 +401,7 @@ class Status extends ImmutablePureComponent { compact cacheWidth={this.props.cacheMediaWidth} defaultWidth={this.props.cachedMediaWidth} + sensitive={status.get('sensitive')} /> ); } diff --git a/app/javascript/mastodon/components/timeline_hint.js b/app/javascript/mastodon/components/timeline_hint.js new file mode 100644 index 000000000..fb55a62cc --- /dev/null +++ b/app/javascript/mastodon/components/timeline_hint.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +const TimelineHint = ({ resource, url }) => ( + <div className='timeline-hint'> + <strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong> + <br /> + <a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a> + </div> +); + +TimelineHint.propTypes = { + resource: PropTypes.node.isRequired, + url: PropTypes.string.isRequired, +}; + +export default TimelineHint; diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index 37622d4c0..6740e8adb 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -14,6 +14,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { FormattedMessage } from 'react-intl'; import { fetchAccountIdentityProofs } from '../../actions/identity_proofs'; import MissingIndicator from 'mastodon/components/missing_indicator'; +import TimelineHint from 'mastodon/components/timeline_hint'; const emptyList = ImmutableList(); @@ -21,6 +22,8 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) const path = withReplies ? `${accountId}:with_replies` : accountId; return { + remote: !!state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username']), + remoteUrl: state.getIn(['accounts', accountId, 'url']), isAccount: !!state.getIn(['accounts', accountId]), statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList), featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList), @@ -30,6 +33,14 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) }; }; +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + export default @connect(mapStateToProps) class AccountTimeline extends ImmutablePureComponent { @@ -44,6 +55,8 @@ class AccountTimeline extends ImmutablePureComponent { withReplies: PropTypes.bool, blockedBy: PropTypes.bool, isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, }; @@ -78,7 +91,7 @@ class AccountTimeline extends ImmutablePureComponent { } render () { - const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn } = this.props; + const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -97,7 +110,17 @@ class AccountTimeline extends ImmutablePureComponent { ); } - const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />; + let emptyMessage; + + if (blockedBy) { + emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; + } else if (remote && statusIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; return ( <Column> @@ -106,6 +129,7 @@ class AccountTimeline extends ImmutablePureComponent { <StatusList prepend={<HeaderContainer accountId={this.props.params.accountId} />} alwaysPrepend + append={remoteMessage} scrollKey='account_timeline' statusIds={blockedBy ? emptyList : statusIds} featuredStatusIds={featuredStatusIds} diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js index 95c9c7751..baad1c0e5 100644 --- a/app/javascript/mastodon/features/audio/index.js +++ b/app/javascript/mastodon/features/audio/index.js @@ -128,6 +128,7 @@ class Audio extends React.PureComponent { this.wavesurfer.createPeakCache(); this.wavesurfer.load(this.props.src); this.wavesurfer.toggleInteraction(); + this.wavesurfer.setVolume(this.state.volume); this.loaded = true; } diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index 582bb0d39..a6186010b 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -199,12 +199,13 @@ class EmojiPickerMenu extends React.PureComponent { }; } - handleClick = emoji => { + handleClick = (emoji, event) => { if (!emoji.native) { emoji.native = emoji.colons; } - - this.props.onClose(); + if (!event.ctrlKey) { + this.props.onClose(); + } this.props.onPick(emoji); } diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js index c8425c4c6..07b3d8c53 100644 --- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js +++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js @@ -76,7 +76,17 @@ describe('emoji', () => { it('skips the textual presentation VS15 character', () => { expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15 - .toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734.svg" />'); + .toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734_border.svg" />'); + }); + + it('does an simple emoji properly', () => { + expect(emojify('♀♂')) + .toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg" /><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg" />'); + }); + + it('does an emoji containing ZWJ properly', () => { + expect(emojify('💂♀️💂♂️')) + .toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg" /><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg" />'); }); }); }); diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index cd10e20b7..f7d3cfd08 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -6,6 +6,20 @@ const trie = new Trie(Object.keys(unicodeMapping)); const assetHost = process.env.CDN_HOST || ''; +// Convert to file names from emojis. (For different variation selector emojis) +const emojiFilenames = (emojis) => { + return emojis.map(v => unicodeMapping[v].filename); +}; + +// Emoji requiring extra borders depending on theme +const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴']); +const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']); + +const emojiFilename = (filename) => { + const borderedEmoji = (document.body && document.body.classList.contains('theme-mastodon-light')) ? lightEmoji : darkEmoji; + return borderedEmoji.includes(filename) ? (filename + '_border') : filename; +}; + const emojify = (str, customEmojis = {}) => { const tagCharsWithoutEmojis = '<&'; const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&'; @@ -60,7 +74,7 @@ const emojify = (str, customEmojis = {}) => { } else { // matched to unicode emoji const { filename, shortCode } = unicodeMapping[match]; const title = shortCode ? `:${shortCode}:` : ''; - replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`; + replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${emojiFilename(filename)}.svg" />`; rend = i + match.length; // If the matched character was followed by VS15 (for selecting text presentation), skip it. if (str.codePointAt(rend) === 65038) { diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js index 302ccffdd..439161118 100644 --- a/app/javascript/mastodon/features/followers/index.js +++ b/app/javascript/mastodon/features/followers/index.js @@ -17,8 +17,11 @@ import HeaderContainer from '../account_timeline/containers/header_container'; import ColumnBackButton from '../../components/column_back_button'; import ScrollableList from '../../components/scrollable_list'; import MissingIndicator from 'mastodon/components/missing_indicator'; +import TimelineHint from 'mastodon/components/timeline_hint'; const mapStateToProps = (state, props) => ({ + remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']), + remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']), isAccount: !!state.getIn(['accounts', props.params.accountId]), accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']), hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']), @@ -26,6 +29,14 @@ const mapStateToProps = (state, props) => ({ blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false), }); +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + export default @connect(mapStateToProps) class Followers extends ImmutablePureComponent { @@ -38,6 +49,8 @@ class Followers extends ImmutablePureComponent { isLoading: PropTypes.bool, blockedBy: PropTypes.bool, isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, }; @@ -60,7 +73,7 @@ class Followers extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props; + const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -78,7 +91,17 @@ class Followers extends ImmutablePureComponent { ); } - const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />; + let emptyMessage; + + if (blockedBy) { + emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; + } else if (remote && accountIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; return ( <Column> @@ -92,6 +115,7 @@ class Followers extends ImmutablePureComponent { shouldUpdateScroll={shouldUpdateScroll} prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} alwaysPrepend + append={remoteMessage} emptyMessage={emptyMessage} bindToDocument={!multiColumn} > diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js index 06311bbdc..2d2ed7be4 100644 --- a/app/javascript/mastodon/features/following/index.js +++ b/app/javascript/mastodon/features/following/index.js @@ -17,8 +17,11 @@ import HeaderContainer from '../account_timeline/containers/header_container'; import ColumnBackButton from '../../components/column_back_button'; import ScrollableList from '../../components/scrollable_list'; import MissingIndicator from 'mastodon/components/missing_indicator'; +import TimelineHint from 'mastodon/components/timeline_hint'; const mapStateToProps = (state, props) => ({ + remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']), + remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']), isAccount: !!state.getIn(['accounts', props.params.accountId]), accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']), hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']), @@ -26,6 +29,14 @@ const mapStateToProps = (state, props) => ({ blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false), }); +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + export default @connect(mapStateToProps) class Following extends ImmutablePureComponent { @@ -38,6 +49,8 @@ class Following extends ImmutablePureComponent { isLoading: PropTypes.bool, blockedBy: PropTypes.bool, isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, }; @@ -60,7 +73,7 @@ class Following extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props; + const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -78,7 +91,17 @@ class Following extends ImmutablePureComponent { ); } - const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />; + let emptyMessage; + + if (blockedBy) { + emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; + } else if (remote && accountIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; return ( <Column> @@ -92,6 +115,7 @@ class Following extends ImmutablePureComponent { shouldUpdateScroll={shouldUpdateScroll} prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} alwaysPrepend + append={remoteMessage} emptyMessage={emptyMessage} bindToDocument={!multiColumn} > diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.js b/app/javascript/mastodon/features/keyboard_shortcuts/index.js index 666baf621..d278d2b26 100644 --- a/app/javascript/mastodon/features/keyboard_shortcuts/index.js +++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.js @@ -89,6 +89,10 @@ class KeyboardShortcuts extends ImmutablePureComponent { <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td> </tr> <tr> + <td><kbd>alt</kbd>+<kbd>x</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td> + </tr> + <tr> <td><kbd>backspace</kbd></td> <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td> </tr> diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index b8344a667..630e99f2c 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -2,9 +2,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; import punycode from 'punycode'; import classnames from 'classnames'; import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; +import { useBlurhash } from 'mastodon/initial_state'; +import { decode } from 'blurhash'; const IDNA_PREFIX = 'xn--'; @@ -63,6 +67,7 @@ export default class Card extends React.PureComponent { compact: PropTypes.bool, defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, + sensitive: PropTypes.bool, }; static defaultProps = { @@ -72,12 +77,44 @@ export default class Card extends React.PureComponent { state = { width: this.props.defaultWidth || 280, + previewLoaded: false, embedded: false, + revealed: !this.props.sensitive, }; componentWillReceiveProps (nextProps) { if (!Immutable.is(this.props.card, nextProps.card)) { - this.setState({ embedded: false }); + this.setState({ embedded: false, previewLoaded: false }); + } + if (this.props.sensitive !== nextProps.sensitive) { + this.setState({ revealed: !nextProps.sensitive }); + } + } + + componentDidMount () { + if (this.props.card && this.props.card.get('blurhash')) { + this._decode(); + } + } + + componentDidUpdate (prevProps) { + const { card } = this.props; + if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash'))) { + this._decode(); + } + } + + _decode () { + if (!useBlurhash) return; + + const hash = this.props.card.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); } } @@ -119,6 +156,18 @@ export default class Card extends React.PureComponent { } } + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ previewLoaded: true }); + } + + handleReveal = () => { + this.setState({ revealed: true }); + } + renderVideo () { const { card } = this.props; const content = { __html: addAutoPlay(card.get('html')) }; @@ -138,7 +187,7 @@ export default class Card extends React.PureComponent { render () { const { card, maxDescription, compact } = this.props; - const { width, embedded } = this.state; + const { width, embedded, revealed } = this.state; if (card === null) { return null; @@ -153,7 +202,7 @@ export default class Card extends React.PureComponent { const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); const description = ( - <div className='status-card__content'> + <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}> {title} {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>} <span className='status-card__host'>{provider}</span> @@ -161,7 +210,18 @@ export default class Card extends React.PureComponent { ); let embed = ''; - let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />; + let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />; + let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />; + let spoilerButton = ( + <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + </button> + ); + spoilerButton = ( + <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}> + {spoilerButton} + </div> + ); if (interactive) { if (embedded) { @@ -175,14 +235,18 @@ export default class Card extends React.PureComponent { embed = ( <div className='status-card__image'> + {canvas} {thumbnail} - <div className='status-card__actions'> - <div> - <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> - {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} + {revealed && ( + <div className='status-card__actions'> + <div> + <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> + {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} + </div> </div> - </div> + )} + {!revealed && spoilerButton} </div> ); } @@ -196,13 +260,16 @@ export default class Card extends React.PureComponent { } else if (card.get('image')) { embed = ( <div className='status-card__image'> + {canvas} {thumbnail} + {!revealed && spoilerButton} </div> ); } else { embed = ( <div className='status-card__image'> <Icon id='file-text' /> + {!revealed && spoilerButton} </div> ); } diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 4201b237e..2ac47677e 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -153,7 +153,7 @@ export default class DetailedStatus extends ImmutablePureComponent { ); } } else if (status.get('spoiler_text').length === 0) { - media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />; + media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />; } if (status.get('application')) { diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.js b/app/javascript/mastodon/features/ui/components/compose_panel.js index c7821f473..3d0c48c7a 100644 --- a/app/javascript/mastodon/features/ui/components/compose_panel.js +++ b/app/javascript/mastodon/features/ui/components/compose_panel.js @@ -1,16 +1,36 @@ import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; import SearchContainer from 'mastodon/features/compose/containers/search_container'; import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container'; import NavigationContainer from 'mastodon/features/compose/containers/navigation_container'; import LinkFooter from './link_footer'; +import { changeComposing } from 'mastodon/actions/compose'; -const ComposePanel = () => ( - <div className='compose-panel'> - <SearchContainer openInRoute /> - <NavigationContainer /> - <ComposeFormContainer singleColumn /> - <LinkFooter withHotkeys /> - </div> -); +export default @connect() +class ComposePanel extends React.PureComponent { -export default ComposePanel; + static propTypes = { + dispatch: PropTypes.func.isRequired, + }; + + onFocus = () => { + this.props.dispatch(changeComposing(true)); + } + + onBlur = () => { + this.props.dispatch(changeComposing(false)); + } + + render() { + return ( + <div className='compose-panel' onFocus={this.onFocus}> + <SearchContainer openInRoute /> + <NavigationContainer onClose={this.onBlur} /> + <ComposeFormContainer singleColumn /> + <LinkFooter withHotkeys /> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 81ffad22e..553cb3365 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -10,7 +10,7 @@ import LoadingBarContainer from './containers/loading_bar_container'; import ModalContainer from './containers/modal_container'; import { isMobile } from '../../is_mobile'; import { debounce } from 'lodash'; -import { uploadCompose, resetCompose } from '../../actions/compose'; +import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { expandHomeTimeline } from '../../actions/timelines'; import { expandNotifications } from '../../actions/notifications'; import { fetchFilters } from '../../actions/filters'; @@ -76,6 +76,7 @@ const keyMap = { new: 'n', search: 's', forceNew: 'option+n', + toggleComposeSpoilers: 'option+x', focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], reply: 'r', favourite: 'f', @@ -254,6 +255,7 @@ class UI extends React.PureComponent { dispatch(synchronouslySubmitMarkers()); if (isComposing && (hasComposingText || hasMediaAttachments)) { + e.preventDefault(); // Setting returnValue to any string causes confirmation dialog. // Many browsers no longer display this text to users, // but we set user-friendly message for other browsers, e.g. Edge. @@ -374,7 +376,7 @@ class UI extends React.PureComponent { componentDidMount () { this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; }; } @@ -419,6 +421,11 @@ class UI extends React.PureComponent { this.props.dispatch(resetCompose()); } + handleHotkeyToggleComposeSpoilers = e => { + e.preventDefault(); + this.props.dispatch(changeComposeSpoilerness()); + } + handleHotkeyFocusColumn = e => { const index = (e.key * 1) + 1; // First child is drawer, skip that const column = this.node.querySelector(`.column:nth-child(${index})`); @@ -514,6 +521,7 @@ class UI extends React.PureComponent { new: this.handleHotkeyNew, search: this.handleHotkeySearch, forceNew: this.handleHotkeyForceNew, + toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers, focusColumn: this.handleHotkeyFocusColumn, back: this.handleHotkeyBack, goToHome: this.handleHotkeyGoToHome, diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index 70868e397..e1871b31a 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -5,6 +5,7 @@ "account.block": "حظر @{name}", "account.block_domain": "إخفاء كل شيء قادم من اسم النطاق {domain}", "account.blocked": "محظور", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "إلغاء طلب المتابَعة", "account.direct": "رسالة خاصة إلى @{name}", "account.domain_blocked": "النطاق مخفي", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "للردّ", "keyboard_shortcuts.requests": "لفتح قائمة طلبات المتابعة", "keyboard_shortcuts.search": "للتركيز على البحث", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "لفتح عمود \"هيا نبدأ\"", "keyboard_shortcuts.toggle_hidden": "لعرض أو إخفاء النص مِن وراء التحذير", "keyboard_shortcuts.toggle_sensitivity": "لعرض/إخفاء الوسائط", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# دقيقة} other {# دقائق}} متبقية", "time_remaining.moments": "لحظات متبقية", "time_remaining.seconds": "{number, plural, one {# ثانية} other {# ثوانٍ}} متبقية", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, zero {} one {شخص واحد} two {شخصين} few {أشخاص} many {أشخاص} other {أشخاص}} تتحدّث", "trends.trending_now": "المتداولة الآن", "ui.beforeunload": "سوف تفقد مسودتك إن تركت ماستدون.", diff --git a/app/javascript/mastodon/locales/ast.json b/app/javascript/mastodon/locales/ast.json index 6c53e33db..3989978a0 100644 --- a/app/javascript/mastodon/locales/ast.json +++ b/app/javascript/mastodon/locales/ast.json @@ -5,6 +5,7 @@ "account.block": "Bloquiar a @{name}", "account.block_domain": "Anubrir tolo de {domain}", "account.blocked": "Blocked", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Encaboxar la solicitú de siguimientu", "account.direct": "Unviar un mensaxe direutu a @{name}", "account.domain_blocked": "Dominiu anubríu", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "pa responder", "keyboard_shortcuts.requests": "p'abrir la llista de solicitúes de siguimientu", "keyboard_shortcuts.search": "pa enfocar la gueta", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "p'abrir la columna «entamar»", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minutu restante} other {# minutos restantes}}", "time_remaining.moments": "Moments remaining", "time_remaining.seconds": "{number, plural, one {# segundu restante} other {# segundos restantes}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {persones}} falando", "trends.trending_now": "Trending now", "ui.beforeunload": "El borrador va perdese si coles de Mastodon.", diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json index b571d8c0e..e8bf05b13 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -5,6 +5,7 @@ "account.block": "Блокирай", "account.block_domain": "скрий всичко от (домейн)", "account.blocked": "Блокирани", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Откажи искането за следване", "account.direct": "Direct Message @{name}", "account.domain_blocked": "Скрит домейн", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/bn.json b/app/javascript/mastodon/locales/bn.json index 20a64b7e3..7f27f58f2 100644 --- a/app/javascript/mastodon/locales/bn.json +++ b/app/javascript/mastodon/locales/bn.json @@ -5,6 +5,7 @@ "account.block": "@{name} কে ব্লক করুন", "account.block_domain": "{domain} থেকে সব আড়াল করুন", "account.blocked": "অবরুদ্ধ", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "অনুসরণ অনুরোধ বাতিল করুন", "account.direct": "@{name} কে সরাসরি বার্তা", "account.domain_blocked": "ডোমেন গোপন করুন", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "মতামত দিতে", "keyboard_shortcuts.requests": "অনুসরণ অনুরোধের তালিকা দেখতে", "keyboard_shortcuts.search": "খোঁজার অংশে ফোকাস করতে", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "\"প্রথম শুরুর\" কলাম বের করতে", "keyboard_shortcuts.toggle_hidden": "CW লেখা দেখতে বা লুকাতে", "keyboard_shortcuts.toggle_sensitivity": "ভিডিও/ছবি দেখতে বা বন্ধ করতে", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# মিনিট} other {# মিনিট}} বাকি আছে", "time_remaining.moments": "সময় বাকি আছে", "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} বাকি আছে", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} কথা বলছে", "trends.trending_now": "বর্তমানে জনপ্রিয়", "ui.beforeunload": "যে পর্যন্ত এটা লেখা হয়েছে, মাস্টাডন থেকে চলে গেলে এটা মুছে যাবে।", diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json index 5bf8cdb1c..ae7283573 100644 --- a/app/javascript/mastodon/locales/br.json +++ b/app/javascript/mastodon/locales/br.json @@ -5,6 +5,7 @@ "account.block": "Berzañ @{name}", "account.block_domain": "Berzañ pep tra eus {domain}", "account.blocked": "Stanket", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Nullañ ar bedadenn heuliañ", "account.direct": "Kas ur gemennadenn da @{name}", "account.domain_blocked": "Domani berzet", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "da respont", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Luskad ar mare", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 00965f153..d5e5ce565 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -5,6 +5,7 @@ "account.block": "Bloqueja @{name}", "account.block_domain": "Amaga-ho tot de {domain}", "account.blocked": "Bloquejat", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Anul·la la sol·licitud de seguiment", "account.direct": "Missatge directe @{name}", "account.domain_blocked": "Domini ocult", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "respondre", "keyboard_shortcuts.requests": "per a obrir la llista de sol·licituds de seguiment", "keyboard_shortcuts.search": "per a centrar la cerca", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "per a obrir la columna \"Començar\"", "keyboard_shortcuts.toggle_hidden": "per a mostrar o amagar text sota CW", "keyboard_shortcuts.toggle_sensitivity": "per a mostrar o amagar contingut multimèdia", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minut} other {# minuts}} restants", "time_remaining.moments": "Moments restants", "time_remaining.seconds": "{number, plural, one {# segon} other {# segons}} restants", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {persones}} parlant-hi", "trends.trending_now": "Ara en tendència", "ui.beforeunload": "El teu esborrany es perdrà si surts de Mastodon.", diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json index 2e8b8e1a4..76b4abd50 100644 --- a/app/javascript/mastodon/locales/co.json +++ b/app/javascript/mastodon/locales/co.json @@ -5,6 +5,7 @@ "account.block": "Bluccà @{name}", "account.block_domain": "Piattà u duminiu {domain}", "account.blocked": "Bluccatu", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Annullà a dumanda d'abbunamentu", "account.direct": "Missaghju direttu @{name}", "account.domain_blocked": "Duminiu piattatu", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "risponde", "keyboard_shortcuts.requests": "per apre a lista di dumande d'abbunamentu", "keyboard_shortcuts.search": "fucalizà nant'à l'area di circata", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "per apre a culonna \"per principià\"", "keyboard_shortcuts.toggle_hidden": "vede/piattà u testu daretu à l'avertimentu CW", "keyboard_shortcuts.toggle_sensitivity": "vede/piattà i media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minuta ferma} other {# minute fermanu}} left", "time_remaining.moments": "Ci fermanu qualchi mumentu", "time_remaining.seconds": "{number, plural, one {# siconda ferma} other {# siconde fermanu}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} parlanu", "trends.trending_now": "Tindenze d'avà", "ui.beforeunload": "A bruttacopia sarà persa s'ellu hè chjosu Mastodon.", diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json index 3c51b7f9c..0b092a9c7 100644 --- a/app/javascript/mastodon/locales/cs.json +++ b/app/javascript/mastodon/locales/cs.json @@ -5,6 +5,7 @@ "account.block": "Zablokovat uživatele @{name}", "account.block_domain": "Skrýt vše ze serveru {domain}", "account.blocked": "Blokováno", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Zrušit žádost o sledování", "account.direct": "Poslat uživateli @{name} přímou zprávu", "account.domain_blocked": "Doména skryta", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "odpovědět", "keyboard_shortcuts.requests": "otevření seznamu požadavků o sledování", "keyboard_shortcuts.search": "zaměření na hledání", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "otevření sloupce „začínáme“", "keyboard_shortcuts.toggle_hidden": "zobrazení/skrytí textu za varováním o obsahu", "keyboard_shortcuts.toggle_sensitivity": "zobrazení/skrytí médií", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {Zbývá # minuta} few {Zbývají # minuty} many {Zbývá # minut} other {Zbývá # minut}}", "time_remaining.moments": "Zbývá několik sekund", "time_remaining.seconds": "{number, plural, one {Zbývá # sekunda} few {Zbývají # sekundy} many {Zbývá # sekund} other {Zbývá # sekund}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {člověk} few {lidé} many {lidí} other {lidí}} hovoří", "trends.trending_now": "Aktuální trendy", "ui.beforeunload": "Pokud Mastodon opustíte, váš koncept se ztratí.", diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json index d8b8b56f4..0dbc91b8d 100644 --- a/app/javascript/mastodon/locales/cy.json +++ b/app/javascript/mastodon/locales/cy.json @@ -5,6 +5,7 @@ "account.block": "Blocio @{name}", "account.block_domain": "Cuddio popeth rhag {domain}", "account.blocked": "Blociwyd", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Canslo cais dilyn", "account.direct": "Neges breifat @{name}", "account.domain_blocked": "Parth wedi ei guddio", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "i ateb", "keyboard_shortcuts.requests": "i agor rhestr ceisiadau dilyn", "keyboard_shortcuts.search": "i ffocysu chwilio", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "i agor colofn \"dechrau arni\"", "keyboard_shortcuts.toggle_hidden": "i ddangos/cuddio testun tu ôl i CW", "keyboard_shortcuts.toggle_sensitivity": "i ddangos/gyddio cyfryngau", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# funud} other {# o funudau}} ar ôl", "time_remaining.moments": "Munudau ar ôl", "time_remaining.seconds": "{number, plural, one {# eiliad} other {# o eiliadau}} ar ôl", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} yn siarad", "trends.trending_now": "Yn tueddu nawr", "ui.beforeunload": "Mi fyddwch yn colli eich drafft os gadewch Mastodon.", diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json index d93a73dee..5fb6ea4d7 100644 --- a/app/javascript/mastodon/locales/da.json +++ b/app/javascript/mastodon/locales/da.json @@ -5,6 +5,7 @@ "account.block": "Bloker @{name}", "account.block_domain": "Skjul alt fra {domain}", "account.blocked": "Blokeret", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Annullér følgeranmodning", "account.direct": "Send en direkte besked til @{name}", "account.domain_blocked": "Domænet er blevet skjult", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "for at svare", "keyboard_shortcuts.requests": "for at åbne listen over følgeranmodninger", "keyboard_shortcuts.search": "for at fokusere søgningen", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "for at åbne \"kom igen\" kolonnen", "keyboard_shortcuts.toggle_hidden": "for at vise/skjule tekst bag CW", "keyboard_shortcuts.toggle_sensitivity": "for at vise/skjule medier", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minut} other {# minutter}} tilbage", "time_remaining.moments": "Få øjeblikke tilbage", "time_remaining.seconds": "{number, plural, one {# sekund} other {# sekunder}} tilbage", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {personer}} snakker", "trends.trending_now": "Hot lige nu", "ui.beforeunload": "Din kladde vil gå tabt hvis du forlader Mastodon.", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 35bc3025b..5220772cd 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -5,6 +5,7 @@ "account.block": "@{name} blockieren", "account.block_domain": "Alles von {domain} blockieren", "account.blocked": "Blockiert", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Folgeanfrage abbrechen", "account.direct": "Direktnachricht an @{name}", "account.domain_blocked": "Domain versteckt", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "antworten", "keyboard_shortcuts.requests": "Liste der Folge-Anfragen öffnen", "keyboard_shortcuts.search": "Suche fokussieren", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "\"Erste Schritte\"-Spalte öffnen", "keyboard_shortcuts.toggle_hidden": "Text hinter einer Inhaltswarnung verstecken/anzeigen", "keyboard_shortcuts.toggle_sensitivity": "Medien hinter einer Inhaltswarnung verstecken/anzeigen", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# Minute} other {# Minuten}} verbleibend", "time_remaining.moments": "Schließt in Kürze", "time_remaining.seconds": "{number, plural, one {# Sekunde} other {# Sekunden}} verbleibend", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, eine {Person} other {Personen}} reden darüber", "trends.trending_now": "In den Trends", "ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.", diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 7bd4a274c..1d280d710 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -510,6 +510,19 @@ { "descriptors": [ { + "defaultMessage": "{resource} from other servers are not displayed.", + "id": "timeline_hint.remote_resource_not_displayed" + }, + { + "defaultMessage": "Browse more on the original profile", + "id": "account.browse_more_on_origin_server" + } + ], + "path": "app/javascript/mastodon/components/timeline_hint.json" + }, + { + "descriptors": [ + { "defaultMessage": "Unfollow", "id": "confirmations.unfollow.confirm" }, @@ -620,6 +633,10 @@ { "descriptors": [ { + "defaultMessage": "Older toots", + "id": "timeline_hint.resources.statuses" + }, + { "defaultMessage": "Profile unavailable", "id": "empty_column.account_unavailable" }, @@ -1543,6 +1560,10 @@ { "descriptors": [ { + "defaultMessage": "Followers", + "id": "timeline_hint.resources.followers" + }, + { "defaultMessage": "Profile unavailable", "id": "empty_column.account_unavailable" }, @@ -1556,6 +1577,10 @@ { "descriptors": [ { + "defaultMessage": "Follows", + "id": "timeline_hint.resources.follows" + }, + { "defaultMessage": "Profile unavailable", "id": "empty_column.account_unavailable" }, @@ -1921,6 +1946,10 @@ "id": "keyboard_shortcuts.toot" }, { + "defaultMessage": "to show/hide CW field", + "id": "keyboard_shortcuts.spoilers" + }, + { "defaultMessage": "to navigate back", "id": "keyboard_shortcuts.back" }, @@ -2430,6 +2459,15 @@ { "descriptors": [ { + "defaultMessage": "Sensitive content", + "id": "status.sensitive_warning" + } + ], + "path": "app/javascript/mastodon/features/status/components/card.json" + }, + { + "descriptors": [ + { "defaultMessage": "Delete", "id": "confirmations.delete.confirm" }, @@ -2982,4 +3020,4 @@ ], "path": "app/javascript/mastodon/features/video/index.json" } -] +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index 202c91f90..31d289207 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -5,6 +5,7 @@ "account.block": "Αποκλεισμός @{name}", "account.block_domain": "Απόκρυψη όλων από {domain}", "account.blocked": "Αποκλεισμένος/η", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Ακύρωση αιτήματος παρακολούθησης", "account.direct": "Προσωπικό μήνυμα προς @{name}", "account.domain_blocked": "Κρυμμένος τομέας", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "απάντηση", "keyboard_shortcuts.requests": "άνοιγμα λίστας αιτημάτων παρακολούθησης", "keyboard_shortcuts.search": "εστίαση αναζήτησης", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "άνοιγμα κολώνας \"Ξεκινώντας\"", "keyboard_shortcuts.toggle_hidden": "εμφάνιση/απόκρυψη κειμένου πίσω από την προειδοποίηση", "keyboard_shortcuts.toggle_sensitivity": "εμφάνιση/απόκρυψη πολυμέσων", @@ -412,6 +414,10 @@ "time_remaining.minutes": "απομένουν {number, plural, one {# λεπτό} other {# λεπτά}}", "time_remaining.moments": "Απομένουν στιγμές", "time_remaining.seconds": "απομένουν {number, plural, one {# δευτερόλεπτο} other {# δευτερόλεπτα}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {άτομο μιλάει} other {άτομα μιλάνε}}", "trends.trending_now": "Δημοφιλή τώρα", "ui.beforeunload": "Το προσχέδιό σου θα χαθεί αν φύγεις από το Mastodon.", diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 225126e6f..1779f4713 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -5,6 +5,7 @@ "account.block": "Block @{name}", "account.block_domain": "Block domain {domain}", "account.blocked": "Blocked", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Direct message @{name}", "account.domain_blocked": "Domain blocked", @@ -106,7 +107,7 @@ "confirmations.block.confirm": "Block", "confirmations.block.message": "Are you sure you want to block {name}?", "confirmations.delete.confirm": "Delete", - "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.delete.message": "Are you sure you want to delete this toot?", "confirmations.delete_list.confirm": "Delete", "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?", "confirmations.domain_block.confirm": "Block entire domain", @@ -117,7 +118,7 @@ "confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.", "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.redraft.confirm": "Delete & redraft", - "confirmations.redraft.message": "Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.", + "confirmations.redraft.message": "Are you sure you want to delete this toot and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.", "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.unfollow.confirm": "Unfollow", @@ -130,7 +131,7 @@ "directory.local": "From {domain} only", "directory.new_arrivals": "New arrivals", "directory.recently_active": "Recently active", - "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.instructions": "Embed this toot on your website by copying the code below.", "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.custom": "Custom", @@ -159,7 +160,7 @@ "empty_column.hashtag": "There is nothing in this hashtag yet.", "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.", "empty_column.home.public_timeline": "the public timeline", - "empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.", + "empty_column.list": "There is nothing in this list yet. When members of this list post new toots, they will appear here.", "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.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", @@ -216,12 +217,12 @@ "keyboard_shortcuts.back": "to navigate back", "keyboard_shortcuts.blocked": "to open blocked users list", "keyboard_shortcuts.boost": "to boost", - "keyboard_shortcuts.column": "to focus a status in one of the columns", + "keyboard_shortcuts.column": "to focus a toot in one of the columns", "keyboard_shortcuts.compose": "to focus the compose textarea", "keyboard_shortcuts.description": "Description", "keyboard_shortcuts.direct": "to open direct messages column", "keyboard_shortcuts.down": "to move down in the list", - "keyboard_shortcuts.enter": "to open status", + "keyboard_shortcuts.enter": "to open toot", "keyboard_shortcuts.favourite": "to favourite", "keyboard_shortcuts.favourites": "to open favourites list", "keyboard_shortcuts.federated": "to open federated timeline", @@ -240,6 +241,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -289,13 +291,13 @@ "navigation_bar.preferences": "Preferences", "navigation_bar.public_timeline": "Federated timeline", "navigation_bar.security": "Security", - "notification.favourite": "{name} favourited your status", + "notification.favourite": "{name} favourited your toot", "notification.follow": "{name} followed you", "notification.follow_request": "{name} has requested to follow you", "notification.mention": "{name} mentioned you", "notification.own_poll": "Your poll has ended", "notification.poll": "A poll you have voted in has ended", - "notification.reblog": "{name} boosted your status", + "notification.reblog": "{name} boosted your toot", "notifications.clear": "Clear notifications", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", "notifications.column_settings.alert": "Desktop notifications", @@ -326,7 +328,7 @@ "poll.voted": "You voted for this answer", "poll_button.add_poll": "Add a poll", "poll_button.remove_poll": "Remove poll", - "privacy.change": "Adjust status privacy", + "privacy.change": "Adjust toot privacy", "privacy.direct.long": "Visible for mentioned users only", "privacy.direct.short": "Direct", "privacy.private.long": "Visible for followers only", @@ -353,9 +355,9 @@ "report.target": "Reporting {target}", "search.placeholder": "Search", "search_popout.search_format": "Advanced search format", - "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", + "search_popout.tips.full_text": "Simple text returns toots you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.hashtag": "hashtag", - "search_popout.tips.status": "status", + "search_popout.tips.status": "toot", "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.user": "user", "search_results.accounts": "People", @@ -364,12 +366,12 @@ "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", + "status.admin_status": "Open this toot in the moderation interface", "status.block": "Block @{name}", "status.bookmark": "Bookmark", "status.cancel_reblog_private": "Unboost", "status.cannot_reblog": "This post cannot be boosted", - "status.copy": "Copy link to status", + "status.copy": "Copy link to toot", "status.delete": "Delete", "status.detailed_status": "Detailed conversation view", "status.direct": "Direct message @{name}", @@ -382,7 +384,7 @@ "status.more": "More", "status.mute": "Mute @{name}", "status.mute_conversation": "Mute conversation", - "status.open": "Expand this status", + "status.open": "Expand this toot", "status.pin": "Pin on profile", "status.pinned": "Pinned toot", "status.read_more": "Read more", @@ -417,6 +419,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index e476538be..fff7508ae 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -5,6 +5,7 @@ "account.block": "Bloki @{name}", "account.block_domain": "Kaŝi ĉion de {domain}", "account.blocked": "Blokita", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Nuligi peton de sekvado", "account.direct": "Rekte mesaĝi @{name}", "account.domain_blocked": "Domajno kaŝita", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "respondi", "keyboard_shortcuts.requests": "malfermi la liston de petoj de sekvado", "keyboard_shortcuts.search": "enfokusigi la serĉilon", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "malfermi la kolumnon «por komenci»", "keyboard_shortcuts.toggle_hidden": "montri/kaŝi tekston malantaŭ enhava averto", "keyboard_shortcuts.toggle_sensitivity": "montri/kaŝi aŭdovidaĵojn", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minuto} other {# minutoj}} restas", "time_remaining.moments": "Momenteto restas", "time_remaining.seconds": "{number, plural, one {# sekundo} other {# sekundoj}} restas", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {persono} other {personoj}} parolas", "trends.trending_now": "Nunaj furoraĵoj", "ui.beforeunload": "Via malneto perdiĝos se vi eliras de Mastodon.", diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json index 15ef9afa9..a5ccea076 100644 --- a/app/javascript/mastodon/locales/es-AR.json +++ b/app/javascript/mastodon/locales/es-AR.json @@ -5,6 +5,7 @@ "account.block": "Bloquear a @{name}", "account.block_domain": "Ocultar todo de {domain}", "account.blocked": "Bloqueado", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancelar la solicitud de seguimiento", "account.direct": "Mensaje directo a @{name}", "account.domain_blocked": "Dominio oculto", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "para responder", "keyboard_shortcuts.requests": "para abrir la lista de solicitudes de seguimiento", "keyboard_shortcuts.search": "para enfocar la búsqueda", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "para abrir la columna \"Introducción\"", "keyboard_shortcuts.toggle_hidden": "para mostrar/ocultar el texto detrás de la advertencia de contenido", "keyboard_shortcuts.toggle_sensitivity": "para mostrar/ocultar los medios", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural,one {queda # minuto} other {quedan # minutos}}", "time_remaining.moments": "Momentos restantes", "time_remaining.seconds": "{number, plural,one {queda # segundo} other {quedan # segundos}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {personas}} hablando", "trends.trending_now": "Tendencia ahora", "ui.beforeunload": "Tu borrador se perderá si abandonás Mastodon.", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index e8767cd35..a3a7a110d 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -5,6 +5,7 @@ "account.block": "Bloquear a @{name}", "account.block_domain": "Ocultar todo de {domain}", "account.blocked": "Bloqueado", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancelar la solicitud de seguimiento", "account.direct": "Mensaje directo a @{name}", "account.domain_blocked": "Dominio oculto", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "para responder", "keyboard_shortcuts.requests": "abrir la lista de peticiones de seguidores", "keyboard_shortcuts.search": "para poner el foco en la búsqueda", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "abrir la columna \"comenzar\"", "keyboard_shortcuts.toggle_hidden": "mostrar/ocultar texto tras aviso de contenido (CW)", "keyboard_shortcuts.toggle_sensitivity": "mostrar/ocultar medios", @@ -412,6 +414,10 @@ "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}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {personas}} hablando", "trends.trending_now": "Tendencia ahora", "ui.beforeunload": "Tu borrador se perderá si sales de Mastodon.", diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json index be3ee148b..bedba181d 100644 --- a/app/javascript/mastodon/locales/et.json +++ b/app/javascript/mastodon/locales/et.json @@ -5,6 +5,7 @@ "account.block": "Blokeeri @{name}", "account.block_domain": "Peida kõik domeenist {domain}", "account.blocked": "Blokeeritud", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Tühista jälgimistaotlus", "account.direct": "Otsesõnum @{name}", "account.domain_blocked": "Domeen peidetud", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "vastamiseks", "keyboard_shortcuts.requests": "avamaks jälgimistaotluste nimistut", "keyboard_shortcuts.search": "otsingu fokuseerimiseks", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "avamaks \"Alusta\" tulpa", "keyboard_shortcuts.toggle_hidden": "näitamaks/peitmaks teksti CW taga", "keyboard_shortcuts.toggle_sensitivity": "et peita/näidata meediat", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minut} other {# minutit}} left", "time_remaining.moments": "Hetked jäänud", "time_remaining.seconds": "{number, plural, one {# sekund} other {# sekundit}} left", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {inimene} other {inimesed}} talking", "trends.trending_now": "Praegu populaarne", "ui.beforeunload": "Teie mustand läheb kaotsi, kui lahkute Mastodonist.", diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json index 7f777eeaf..f9bd3e090 100644 --- a/app/javascript/mastodon/locales/eu.json +++ b/app/javascript/mastodon/locales/eu.json @@ -5,6 +5,7 @@ "account.block": "Blokeatu @{name}", "account.block_domain": "Ezkutatu {domain} domeinuko guztia", "account.blocked": "Blokeatuta", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Ezeztatu jarraitzeko eskaria", "account.direct": "Mezu zuzena @{name}(r)i", "account.domain_blocked": "Ezkutatutako domeinua", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "erantzutea", "keyboard_shortcuts.requests": "jarraitzeko eskarien zerrenda irekitzeko", "keyboard_shortcuts.search": "bilaketan fokua jartzea", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "\"Menua\" zutabea irekitzeko", "keyboard_shortcuts.toggle_hidden": "testua erakustea/ezkutatzea abisu baten atzean", "keyboard_shortcuts.toggle_sensitivity": "multimedia erakutsi/ezkutatzeko", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {minutu #} other {# minutu}} amaitzeko", "time_remaining.moments": "Amaitzekotan", "time_remaining.seconds": "{number, plural, one {segundo #} other {# segundo}} amaitzeko", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {pertsona} other {pertsona}} hitz egiten", "trends.trending_now": "Joera orain", "ui.beforeunload": "Zure zirriborroa galduko da Mastodon uzten baduzu.", diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index 7eca45e42..714e8a30d 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -5,6 +5,7 @@ "account.block": "مسدودسازی @{name}", "account.block_domain": "نهفتن همه چیز از {domain}", "account.blocked": "مسدود", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "لغو درخواست پیگیری", "account.direct": "پیام خصوصی به @{name}", "account.domain_blocked": "دامنهٔ نهفته", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "برای پاسخ", "keyboard_shortcuts.requests": "برای گشودن فهرست درخواستهای پیگیری", "keyboard_shortcuts.search": "برای تمرکز روی جستجو", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "برای گشودن ستون «آغاز کنید»", "keyboard_shortcuts.toggle_hidden": "برای نمایش/نهفتن نوشتهٔ پشت هشدار محتوا", "keyboard_shortcuts.toggle_sensitivity": "برای نمایش/نهفتن رسانه", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# دقیقه} other {# دقیقه}} باقی مانده", "time_remaining.moments": "زمان باقیمانده", "time_remaining.seconds": "{number, plural, one {# ثانیه} other {# ثانیه}} باقی مانده", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {نفر نوشته است} other {نفر نوشتهاند}}", "trends.trending_now": "پرطرفدار", "ui.beforeunload": "اگر از ماستودون خارج شوید پیشنویس شما از دست خواهد رفت.", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 3b7b4e909..3ff7a3cb1 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -5,6 +5,7 @@ "account.block": "Estä @{name}", "account.block_domain": "Piilota kaikki sisältö verkkotunnuksesta {domain}", "account.blocked": "Estetty", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Peruuta seurauspyyntö", "account.direct": "Viesti käyttäjälle @{name}", "account.domain_blocked": "Verkko-osoite piilotettu", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "vastaa", "keyboard_shortcuts.requests": "avaa lista seurauspyynnöistä", "keyboard_shortcuts.search": "siirry hakukenttään", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "avaa \"Aloitus\" -sarake", "keyboard_shortcuts.toggle_hidden": "näytä/piilota sisältövaroituksella merkitty teksti", "keyboard_shortcuts.toggle_sensitivity": "näytä/piilota media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minuutti} other {# minuuttia}} jäljellä", "time_remaining.moments": "Hetki jäljellä", "time_remaining.seconds": "{number, plural, one {# sekunti} other {# sekuntia}} jäljellä", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {henkilö} other {henkilöä}} keskustelee", "trends.trending_now": "Suosittua nyt", "ui.beforeunload": "Luonnos häviää, jos poistut Mastodonista.", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 5e03386f6..fe98a8e97 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -5,6 +5,7 @@ "account.block": "Bloquer @{name}", "account.block_domain": "Bloquer le domaine {domain}", "account.blocked": "Bloqué·e", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Annuler la demande de suivi", "account.direct": "Envoyer un message direct à @{name}", "account.domain_blocked": "Domaine bloqué", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "répondre", "keyboard_shortcuts.requests": "ouvrir la liste de demandes d’abonnement", "keyboard_shortcuts.search": "cibler la zone de recherche", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "ouvrir la colonne « Pour commencer »", "keyboard_shortcuts.toggle_hidden": "déplier/replier le texte derrière un CW", "keyboard_shortcuts.toggle_sensitivity": "afficher/cacher les médias", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} restantes", "time_remaining.moments": "Encore quelques instants", "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} restantes", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {personne discute} other {personnes discutent}}", "trends.trending_now": "Tendance en ce moment", "ui.beforeunload": "Votre brouillon sera perdu si vous quittez Mastodon.", diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json index 81a1fbae0..19054f716 100644 --- a/app/javascript/mastodon/locales/ga.json +++ b/app/javascript/mastodon/locales/ga.json @@ -5,6 +5,7 @@ "account.block": "Block @{name}", "account.block_domain": "Hide everything from {domain}", "account.blocked": "Blocked", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Direct message @{name}", "account.domain_blocked": "Domain hidden", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index 73a3b744e..c3140182c 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -5,6 +5,7 @@ "account.block": "Bloquear @{name}", "account.block_domain": "Agochar todo de {domain}", "account.blocked": "Bloqueada", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Desbotar solicitude de seguimento", "account.direct": "Mensaxe directa @{name}", "account.domain_blocked": "Dominio agochado", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "para responder", "keyboard_shortcuts.requests": "para abrir a listaxe das peticións de seguimento", "keyboard_shortcuts.search": "para destacar a procura", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "para abrir a columna dos \"primeiros pasos\"", "keyboard_shortcuts.toggle_hidden": "para amosar/agochar texto detrás do aviso de contido (AC)", "keyboard_shortcuts.toggle_sensitivity": "para amosar/agochar contido multimedia", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minuto} other {# minutos}} restantes", "time_remaining.moments": "Momentos restantes", "time_remaining.seconds": "{number, plural, one {# segundo} other {# segundos}} restantes", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {persoa} other {persoas}} falando", "trends.trending_now": "Tendencias actuais", "ui.beforeunload": "O borrador perderase se saes de Mastodon.", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index 02fddcc72..4b65cd967 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -5,6 +5,7 @@ "account.block": "חסימת @{name}", "account.block_domain": "להסתיר הכל מהקהילה {domain}", "account.blocked": "חסום", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "בטל בקשת מעקב", "account.direct": "Direct Message @{name}", "account.domain_blocked": "הדומיין חסוי", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "לענות", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "להתמקד בחלון החיפוש", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "הטיוטא תאבד אם תעזבו את מסטודון.", diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json index 438c84610..e26b607bb 100644 --- a/app/javascript/mastodon/locales/hi.json +++ b/app/javascript/mastodon/locales/hi.json @@ -5,6 +5,7 @@ "account.block": "@{name} को ब्लॉक करें", "account.block_domain": "{domain} के सारी चीज़े छुपाएं", "account.blocked": "ब्लॉक", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "फ़ॉलो रिक्वेस्ट रद्द करें", "account.direct": "प्रत्यक्ष संदेश @{name}", "account.domain_blocked": "छिपा हुआ डोमेन", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "जवाब के लिए", "keyboard_shortcuts.requests": "फॉलो रिक्वेस्ट लिस्ट खोलने के लिए", "keyboard_shortcuts.search": "गहरी खोज के लिए", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json index 10daf96f3..f12450d7b 100644 --- a/app/javascript/mastodon/locales/hr.json +++ b/app/javascript/mastodon/locales/hr.json @@ -5,6 +5,7 @@ "account.block": "Blokiraj @{name}", "account.block_domain": "Sakrij sve sa {domain}", "account.blocked": "Blocked", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Direct Message @{name}", "account.domain_blocked": "Domain hidden", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 241f074a6..eb824f4ae 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -5,6 +5,7 @@ "account.block": "@{name} letiltása", "account.block_domain": "Minden elrejtése innen: {domain}", "account.blocked": "Letiltva", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Követési kérelem törlése", "account.direct": "Közvetlen üzenet @{name} számára", "account.domain_blocked": "Rejtett domain", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "válasz", "keyboard_shortcuts.requests": "követési kérések listájának megnyitása", "keyboard_shortcuts.search": "fókuszálás a keresőre", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "\"Első lépések\" megnyitása", "keyboard_shortcuts.toggle_hidden": "tartalmi figyelmeztetéssel ellátott szöveg mutatása/elrejtése", "keyboard_shortcuts.toggle_sensitivity": "média mutatása/elrejtése", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# perc} other {# perc}} van hátra", "time_remaining.moments": "Pillanatok vannak hátra", "time_remaining.seconds": "{number, plural, one {# másodperc} other {# másodperc}} van hátra", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {résztvevő} other {résztvevő}} beszélget", "trends.trending_now": "Most felkapott", "ui.beforeunload": "A piszkozatod el fog veszni, ha elhagyod a Mastodont.", diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json index 1cdccd601..5520f7041 100644 --- a/app/javascript/mastodon/locales/hy.json +++ b/app/javascript/mastodon/locales/hy.json @@ -5,6 +5,7 @@ "account.block": "Արգելափակել @{name}֊ին", "account.block_domain": "Թաքցնել ամէնը հետեւեալ տիրոյթից՝ {domain}", "account.blocked": "Արգելափակուած է", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "չեղարկել հետեւելու հայցը", "account.direct": "Նամակ գրել @{name} -ին", "account.domain_blocked": "Տիրոյթը արգելափակուած է", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "պատասխանելու համար", "keyboard_shortcuts.requests": "հետեւելու հայցերի ցանկը դիտելու համար", "keyboard_shortcuts.search": "որոնման դաշտին սեւեռվելու համար", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "«սկսել» սիւնակը բացելու համար", "keyboard_shortcuts.toggle_hidden": "CW֊ի ետեւի տեքստը ցուցադրել֊թաքցնելու համար", "keyboard_shortcuts.toggle_sensitivity": "մեդիան ցուցադրել֊թաքցնելու համար", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# րոպե} other {# րոպե}} անց", "time_remaining.moments": "Մնացել է մի քանի վարկյան", "time_remaining.seconds": "{number, plural, one {# վայրկյան} other {# վայրկյան}} անց", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {հոգի} other {հոգի}} խոսում է սրա մասին", "trends.trending_now": "Այժմ արդիական", "ui.beforeunload": "Քո սեւագիրը կկորի, եթե լքես Մաստոդոնը։", diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json index 09f5fdee7..df3775cf9 100644 --- a/app/javascript/mastodon/locales/id.json +++ b/app/javascript/mastodon/locales/id.json @@ -5,6 +5,7 @@ "account.block": "Blokir @{name}", "account.block_domain": "Sembunyikan segalanya dari {domain}", "account.blocked": "Terblokir", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Batalkan permintaan ikuti", "account.direct": "Direct Message @{name}", "account.domain_blocked": "Domain disembunyikan", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "balas", "keyboard_shortcuts.requests": "buka daftar permintaan ikuti", "keyboard_shortcuts.search": "untuk fokus mencari", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "buka kolom \"memulai\"", "keyboard_shortcuts.toggle_hidden": "tampilkan/sembunyikan teks di belakang CW", "keyboard_shortcuts.toggle_sensitivity": "tampilkan/sembunyikan media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, other {# menit}} tersisa", "time_remaining.moments": "Momen tersisa", "time_remaining.seconds": "{number, plural, other {# detik}} tersisa", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, other {orang}} berbicara", "trends.trending_now": "Sedang tren sekarang", "ui.beforeunload": "Naskah anda akan hilang jika anda keluar dari Mastodon.", diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json index 696ef694f..a7bc54e85 100644 --- a/app/javascript/mastodon/locales/io.json +++ b/app/javascript/mastodon/locales/io.json @@ -5,6 +5,7 @@ "account.block": "Blokusar @{name}", "account.block_domain": "Hide everything from {domain}", "account.blocked": "Blocked", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Direct Message @{name}", "account.domain_blocked": "Domain hidden", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/is.json b/app/javascript/mastodon/locales/is.json index 5c5c9edad..ada0a37af 100644 --- a/app/javascript/mastodon/locales/is.json +++ b/app/javascript/mastodon/locales/is.json @@ -5,6 +5,7 @@ "account.block": "Útiloka @{name}", "account.block_domain": "Fela allt frá {domain}", "account.blocked": "Útilokaður", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Hætta við beiðni um að fylgjast með", "account.direct": "Bein skilaboð til @{name}", "account.domain_blocked": "Lén falið", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "að svara", "keyboard_shortcuts.requests": "að opna lista yfir fylgjendabeiðnir", "keyboard_shortcuts.search": "að setja virkni í leit", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "að opna \"komast í gang\" dálk", "keyboard_shortcuts.toggle_hidden": "að birta/fela texta á bak við aðvörun vegna efnis", "keyboard_shortcuts.toggle_sensitivity": "að birta/fela myndir", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# mínúta} other {# mínútur}} eftir", "time_remaining.moments": "Tími eftir", "time_remaining.seconds": "{number, plural, one {# sekúnda} other {# sekúndur}} eftir", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {aðili} other {aðilar}} að tala", "trends.trending_now": "Í umræðunni núna", "ui.beforeunload": "Drögin tapast ef þú ferð út úr Mastodon.", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 5ae34551d..1fab8f17c 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -5,6 +5,7 @@ "account.block": "Blocca @{name}", "account.block_domain": "Nascondi tutto da {domain}", "account.blocked": "Bloccato", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Annulla richiesta di seguire", "account.direct": "Invia messaggio privato a @{name}", "account.domain_blocked": "Dominio nascosto", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "per rispondere", "keyboard_shortcuts.requests": "per aprire l'elenco delle richieste di seguirti", "keyboard_shortcuts.search": "per spostare il focus sulla ricerca", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "per aprire la colonna \"Come iniziare\"", "keyboard_shortcuts.toggle_hidden": "per mostrare/nascondere il testo dei CW", "keyboard_shortcuts.toggle_sensitivity": "mostrare/nascondere media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minuto} other {# minuti}} left", "time_remaining.moments": "Restano pochi istanti", "time_remaining.seconds": "{number, plural, one {# secondo} other {# secondi}} left", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {persona ne sta} other {persone ne stanno}} parlando", "trends.trending_now": "Di tendenza ora", "ui.beforeunload": "La bozza andrà persa se esci da Mastodon.", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 75a215145..a519b819b 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -5,6 +5,7 @@ "account.block": "@{name}さんをブロック", "account.block_domain": "{domain}全体をブロック", "account.blocked": "ブロック済み", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "フォローリクエストを取り消す", "account.direct": "@{name}さんにダイレクトメッセージ", "account.domain_blocked": "ドメインブロック中", @@ -240,6 +241,7 @@ "keyboard_shortcuts.reply": "返信", "keyboard_shortcuts.requests": "フォローリクエストのリストを開く", "keyboard_shortcuts.search": "検索欄に移動", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "\"スタート\" カラムを開く", "keyboard_shortcuts.toggle_hidden": "CWで隠れた文を見る/隠す", "keyboard_shortcuts.toggle_sensitivity": "非表示のメディアを見る/隠す", @@ -417,6 +419,10 @@ "time_remaining.minutes": "残り{number}分", "time_remaining.moments": "まもなく終了", "time_remaining.seconds": "残り{number}秒", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count}人がトゥート", "trends.trending_now": "トレンドタグ", "ui.beforeunload": "Mastodonから離れると送信前の投稿は失われます。", diff --git a/app/javascript/mastodon/locales/ka.json b/app/javascript/mastodon/locales/ka.json index 2c487a9b6..d7913dd2a 100644 --- a/app/javascript/mastodon/locales/ka.json +++ b/app/javascript/mastodon/locales/ka.json @@ -5,6 +5,7 @@ "account.block": "დაბლოკე @{name}", "account.block_domain": "დაიმალოს ყველაფერი დომენიდან {domain}", "account.blocked": "დაიბლოკა", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "პირდაპირი წერილი @{name}-ს", "account.domain_blocked": "დომენი დამალულია", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "პასუხისთვის", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "ძიებაზე ფოკუსირებისთვის", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "გაფრთხილების უკან ტექსტის გამოსაჩენად/დასამალვად", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} საუბრობს", "trends.trending_now": "Trending now", "ui.beforeunload": "თქვენი დრაფტი გაუქმდება თუ დატოვებთ მასტოდონს.", diff --git a/app/javascript/mastodon/locales/kab.json b/app/javascript/mastodon/locales/kab.json index abf6cbbca..9237f486b 100644 --- a/app/javascript/mastodon/locales/kab.json +++ b/app/javascript/mastodon/locales/kab.json @@ -5,6 +5,7 @@ "account.block": "Seḥbes @{name}", "account.block_domain": "Ffer kra i d-yekkan seg {domain}", "account.blocked": "Yettuseḥbes", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Sefsex asuter n uḍfaṛ", "account.direct": "Izen usrid i @{name}", "account.domain_blocked": "Taɣult yeffren", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "i tririt", "keyboard_shortcuts.requests": "akken ad d-teldiḍ umuγ n yisuturen n teḍfeṛt", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "akken ad d-teldiḍ ajgu n \"bdu\"", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "i teskent/tuffra n yimidyaten", @@ -412,6 +414,10 @@ "time_remaining.minutes": "Mazal {number, plural, one {# n tesdat} other {# n tesdatin}}", "time_remaining.moments": "Moments remaining", "time_remaining.seconds": "Mazal {number, plural, one {# n tasint} other {# n tsinin}} id yugran", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {n umdan} other {n yemdanen}} i yettmeslayen", "trends.trending_now": "Trending now", "ui.beforeunload": "Arewway-ik·im ad iruḥ ma yella tefeɣ-d deg Maṣṭudun.", diff --git a/app/javascript/mastodon/locales/kk.json b/app/javascript/mastodon/locales/kk.json index f1782424c..48e6e414d 100644 --- a/app/javascript/mastodon/locales/kk.json +++ b/app/javascript/mastodon/locales/kk.json @@ -5,6 +5,7 @@ "account.block": "Бұғаттау @{name}", "account.block_domain": "Домендегі барлығын бұғатта {domain}", "account.blocked": "Бұғатталды", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Жазылуға сұранымды қайтару", "account.direct": "Жеке хат @{name}", "account.domain_blocked": "Домен жабық", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "жауап жазу", "keyboard_shortcuts.requests": "жазылу сұранымдарын қарау", "keyboard_shortcuts.search": "іздеу", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "бастапқы бағанға бару", "keyboard_shortcuts.toggle_hidden": "жабық мәтінді CW ашу/жабу", "keyboard_shortcuts.toggle_sensitivity": "көрсет/жап", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# минут} other {# минут}}", "time_remaining.moments": "Қалған уақыт", "time_remaining.seconds": "{number, plural, one {# секунд} other {# секунд}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} жазған екен", "trends.trending_now": "Тренд тақырыптар", "ui.beforeunload": "Mastodon желісінен шықсаңыз, нобайыңыз сақталмайды.", diff --git a/app/javascript/mastodon/locales/kn.json b/app/javascript/mastodon/locales/kn.json index d0757efbf..33fec4a4c 100644 --- a/app/javascript/mastodon/locales/kn.json +++ b/app/javascript/mastodon/locales/kn.json @@ -5,6 +5,7 @@ "account.block": "Block @{name}", "account.block_domain": "Hide everything from {domain}", "account.blocked": "Blocked", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Direct message @{name}", "account.domain_blocked": "Domain hidden", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index 54a24376c..c8e470b38 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -5,6 +5,7 @@ "account.block": "@{name}을 차단", "account.block_domain": "{domain} 전체를 숨김", "account.blocked": "차단됨", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "팔로우 요청 취소", "account.direct": "@{name}의 다이렉트 메시지", "account.domain_blocked": "도메인 숨겨짐", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "답장", "keyboard_shortcuts.requests": "팔로우 요청 리스트 열기", "keyboard_shortcuts.search": "검색창에 포커스", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "\"시작하기\" 컬럼 열기", "keyboard_shortcuts.toggle_hidden": "CW로 가려진 텍스트를 표시/비표시", "keyboard_shortcuts.toggle_sensitivity": "이미지 보이기/숨기기", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number} 분 남음", "time_remaining.moments": "남은 시간", "time_remaining.seconds": "{number} 초 남음", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} 명의 사람들이 말하고 있습니다", "trends.trending_now": "지금 유행중", "ui.beforeunload": "지금 나가면 저장되지 않은 항목을 잃게 됩니다.", diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json index d0757efbf..33fec4a4c 100644 --- a/app/javascript/mastodon/locales/lt.json +++ b/app/javascript/mastodon/locales/lt.json @@ -5,6 +5,7 @@ "account.block": "Block @{name}", "account.block_domain": "Hide everything from {domain}", "account.blocked": "Blocked", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Direct message @{name}", "account.domain_blocked": "Domain hidden", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json index 241d7c080..d4288f96b 100644 --- a/app/javascript/mastodon/locales/lv.json +++ b/app/javascript/mastodon/locales/lv.json @@ -5,6 +5,7 @@ "account.block": "Bloķēt @{name}", "account.block_domain": "Slēpt visu no {domain}", "account.blocked": "Bloķēts", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Privātā ziņa @{name}", "account.domain_blocked": "Domēns ir paslēpts", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/mk.json b/app/javascript/mastodon/locales/mk.json index bc5f91264..61202ec19 100644 --- a/app/javascript/mastodon/locales/mk.json +++ b/app/javascript/mastodon/locales/mk.json @@ -5,6 +5,7 @@ "account.block": "Блокирај @{name}", "account.block_domain": "Сокријај се од {domain}", "account.blocked": "Блокиран", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Одкажи барање за следење", "account.direct": "Директна порана @{name}", "account.domain_blocked": "Скриен домен", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "одговори", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# минута} other {# минути}} {number, plural, one {остана} other {останаа}}", "time_remaining.moments": "Уште некои мига", "time_remaining.seconds": "{number, plural, one {# секунда} other {# секунди}} {number, plural, one {остана} other {останаа}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/ml.json b/app/javascript/mastodon/locales/ml.json index 788200c87..7b74c10ee 100644 --- a/app/javascript/mastodon/locales/ml.json +++ b/app/javascript/mastodon/locales/ml.json @@ -5,6 +5,7 @@ "account.block": "@{name} നെ ബ്ലോക്ക് ചെയ്യുക", "account.block_domain": "{domain} ൽ നിന്നുള്ള എല്ലാം മറയ്കുക", "account.blocked": "തടഞ്ഞു", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "പിന്തുടരാനുള്ള അപേക്ഷ നിരസിക്കുക", "account.direct": "@{name} ന് നേരിട്ട് മെസേജ് അയക്കുക", "account.domain_blocked": "മേഖല മറയ്ക്കപ്പെട്ടിരിക്കുന്നു", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/mr.json b/app/javascript/mastodon/locales/mr.json index f213c2942..46fd5acc5 100644 --- a/app/javascript/mastodon/locales/mr.json +++ b/app/javascript/mastodon/locales/mr.json @@ -5,6 +5,7 @@ "account.block": "@{name} यांना ब्लॉक करा", "account.block_domain": "{domain} पासून सर्व लपवा", "account.blocked": "ब्लॉक केले आहे", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "अनुयायी होण्याची विनंती रद्द करा", "account.direct": "थेट संदेश @{name}", "account.domain_blocked": "Domain hidden", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json index a5b4d199a..9a9fc975a 100644 --- a/app/javascript/mastodon/locales/ms.json +++ b/app/javascript/mastodon/locales/ms.json @@ -5,6 +5,7 @@ "account.block": "Block @{name}", "account.block_domain": "Hide everything from {domain}", "account.blocked": "Blocked", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Direct message @{name}", "account.domain_blocked": "Domain hidden", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index 6cdd02c02..ca5be5164 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -5,6 +5,7 @@ "account.block": "@{name} blokkeren", "account.block_domain": "Alles van {domain} verbergen", "account.blocked": "Geblokkeerd", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Volgverzoek annuleren", "account.direct": "@{name} een direct bericht sturen", "account.domain_blocked": "Domein verborgen", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "om te reageren", "keyboard_shortcuts.requests": "om jouw volgverzoeken te tonen", "keyboard_shortcuts.search": "om het zoekvak te focussen", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "om de \"Aan de slag\"-kolom te tonen", "keyboard_shortcuts.toggle_hidden": "om tekst achter een waarschuwing (CW) te tonen/verbergen", "keyboard_shortcuts.toggle_sensitivity": "om media te tonen/verbergen", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minuut} other {# minuten}} te gaan", "time_remaining.moments": "Nog enkele ogenblikken resterend", "time_remaining.seconds": "{number, plural, one {# seconde} other {# seconden}} te gaan", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {persoon praat} other {mensen praten}} hierover", "trends.trending_now": "Trends", "ui.beforeunload": "Je concept zal verloren gaan als je Mastodon verlaat.", diff --git a/app/javascript/mastodon/locales/nn.json b/app/javascript/mastodon/locales/nn.json index 0c1fd8bbf..dee0cd836 100644 --- a/app/javascript/mastodon/locales/nn.json +++ b/app/javascript/mastodon/locales/nn.json @@ -5,6 +5,7 @@ "account.block": "Blokker @{name}", "account.block_domain": "Skjul alt frå {domain}", "account.blocked": "Blokkert", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Fjern fylgjeførespurnad", "account.direct": "Send melding til @{name}", "account.domain_blocked": "Domenet er gøymt", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "for å svara", "keyboard_shortcuts.requests": "for å opna lista med fylgjeførespurnader", "keyboard_shortcuts.search": "for å fokusera søket", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "for å opna \"kom i gang\"-feltet", "keyboard_shortcuts.toggle_hidden": "for å visa/gøyma tekst bak innhaldsvarsel", "keyboard_shortcuts.toggle_sensitivity": "for å visa/gøyma media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minutt} other {# minutt}} igjen", "time_remaining.moments": "Kort tid igjen", "time_remaining.seconds": "{number, plural, one {# sekund} other {# sekund}} igjen", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {folk}} snakkar", "trends.trending_now": "Populært no", "ui.beforeunload": "Kladden din forsvinn om du forlèt Mastodon no.", diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index f7d47f7f7..71cfdfe14 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -5,6 +5,7 @@ "account.block": "Blokkér @{name}", "account.block_domain": "Skjul alt fra {domain}", "account.blocked": "Blokkert", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Avbryt følge forespørsel", "account.direct": "Direct Message @{name}", "account.domain_blocked": "Domenet skjult", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "for å svare", "keyboard_shortcuts.requests": "åpne følgingsforespørselslisten", "keyboard_shortcuts.search": "å fokusere søk", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "åpne «Sett i gang»-kolonnen", "keyboard_shortcuts.toggle_hidden": "å vise/skjule tekst bak en innholdsadvarsel", "keyboard_shortcuts.toggle_sensitivity": "å vise/skjule media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minutt} other {# minutter}} igjen", "time_remaining.moments": "Gjenværende øyeblikk", "time_remaining.seconds": "{number, plural, one {# sekund} other {# sekunder}} igjen", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {personer}} snakker om det", "trends.trending_now": "Trender nå", "ui.beforeunload": "Din kladd vil bli forkastet om du forlater Mastodon.", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 5dc7bf61c..370a73475 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -5,6 +5,7 @@ "account.block": "Blocar @{name}", "account.block_domain": "Tot amagar del domeni {domain}", "account.blocked": "Blocat", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Anullar la demanda de seguiment", "account.direct": "Escriure un MP a @{name}", "account.domain_blocked": "Domeni amagat", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "respondre", "keyboard_shortcuts.requests": "dorbir la lista de demanda d’abonament", "keyboard_shortcuts.search": "anar a la recèrca", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "dobrir la colomna « Per començar »", "keyboard_shortcuts.toggle_hidden": "mostrar/amagar lo tèxte dels avertiments", "keyboard_shortcuts.toggle_sensitivity": "mostrar/rescondre los mèdias", @@ -412,6 +414,10 @@ "time_remaining.minutes": "demòra{number, plural, one { # minuta} other {n # minutas}}", "time_remaining.moments": "Moments restants", "time_remaining.seconds": "demòra{number, plural, one { # segonda} other {n # segondas}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} ne charra other {people}} ne charran", "trends.trending_now": "Tendéncia del moment", "ui.beforeunload": "Vòstre brolhon serà perdut se quitatz Mastodon.", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index afb4e29e6..bbfe4cce3 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -5,6 +5,7 @@ "account.block": "Blokuj @{name}", "account.block_domain": "Blokuj wszystko z {domain}", "account.blocked": "Zablokowany(-a)", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Zrezygnuj z prośby o możliwość śledzenia", "account.direct": "Wyślij wiadomość bezpośrednią do @{name}", "account.domain_blocked": "Ukryto domenę", @@ -240,6 +241,7 @@ "keyboard_shortcuts.reply": "aby odpowiedzieć", "keyboard_shortcuts.requests": "aby przejść do listy próśb o możliwość śledzenia", "keyboard_shortcuts.search": "aby przejść do pola wyszukiwania", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "aby otworzyć kolumnę „Rozpocznij”", "keyboard_shortcuts.toggle_hidden": "aby wyświetlić lub ukryć wpis spod CW", "keyboard_shortcuts.toggle_sensitivity": "by pokazać/ukryć multimedia", @@ -417,6 +419,10 @@ "time_remaining.minutes": "{number, plural, one {Pozostała # minuta} few {Pozostały # minuty} many {Pozostało # minut} other {Pozostało # minut}}", "time_remaining.moments": "Pozostała chwila", "time_remaining.seconds": "{number, plural, one {Pozostała # sekunda} few {Pozostały # sekundy} many {Pozostało # sekund} other {Pozostało # sekund}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {osoba rozmawia} few {osoby rozmawiają} other {osób rozmawia}} o tym", "trends.trending_now": "Popularne teraz", "ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Mastodona.", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index 4eb61279a..334e0e8c6 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -5,6 +5,7 @@ "account.block": "Bloquear @{name}", "account.block_domain": "Bloquear domínio {domain}", "account.blocked": "Bloqueado", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancelar solicitação para seguir", "account.direct": "Enviar toot direto para @{name}", "account.domain_blocked": "Domínio bloqueado", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "para responder", "keyboard_shortcuts.requests": "para abrir lista de pedidos para seguir", "keyboard_shortcuts.search": "para focar pesquisa", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "para abrir coluna \"primeiros passos\"", "keyboard_shortcuts.toggle_hidden": "mostrar/ocultar o toot com Aviso de Conteúdo", "keyboard_shortcuts.toggle_sensitivity": "mostrar/ocultar mídia", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minuto restante} other {# minutos restantes}}", "time_remaining.moments": "Momentos faltantes", "time_remaining.seconds": "{number, plural, one {# segundo restante} other {# segundos restantes}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {pessoa} other {pessoas}} falando", "trends.trending_now": "Em alta no momento", "ui.beforeunload": "Seu rascunho vai ser perdido se você sair do Mastodon.", diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json index 41433a43f..a888f7a58 100644 --- a/app/javascript/mastodon/locales/pt-PT.json +++ b/app/javascript/mastodon/locales/pt-PT.json @@ -5,6 +5,7 @@ "account.block": "Bloquear @{name}", "account.block_domain": "Esconder tudo do domínio {domain}", "account.blocked": "Bloqueado", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancelar pedido de seguidor", "account.direct": "Mensagem directa @{name}", "account.domain_blocked": "Domínio escondido", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "para responder", "keyboard_shortcuts.requests": "para abrir a lista dos pedidos de seguidor", "keyboard_shortcuts.search": "para focar na pesquisa", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "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": "mostrar/ocultar media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{número, plural, um {# minute} outro {# minutes}} faltam", "time_remaining.moments": "Momentos restantes", "time_remaining.seconds": "{número, plural, um {# second} outro {# seconds}} faltam", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, uma {person} outra {people}} a falar", "trends.trending_now": "Tendências atuais", "ui.beforeunload": "O teu rascunho será perdido se abandonares o Mastodon.", diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json index f7143a2e2..1a998e8fb 100644 --- a/app/javascript/mastodon/locales/ro.json +++ b/app/javascript/mastodon/locales/ro.json @@ -5,6 +5,7 @@ "account.block": "Blocați @{name}", "account.block_domain": "Blocați domeniul {domain}", "account.blocked": "Blocat", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Anulați cererea de urmărire", "account.direct": "Mesaj direct @{name}", "account.domain_blocked": "Domeniu blocat", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "să răspundă", "keyboard_shortcuts.requests": "să deschidă lista cu cereri de urmărire", "keyboard_shortcuts.search": "să focalizeze căutarea", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "să deschidă coloana \"Începere\"", "keyboard_shortcuts.toggle_hidden": "să arate/ascundă textul în spatele CW", "keyboard_shortcuts.toggle_sensitivity": "pentru a afișa/ascunde media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minut} other {# minute}} rămase", "time_remaining.moments": "Momente rămase", "time_remaining.seconds": "{number, plural, one {# secundă} other {# secunde}} rămase", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {persoană} other {persoane}} vorbește/ecs", "trends.trending_now": "În tendință acum", "ui.beforeunload": "Postarea se va pierde dacă părăsești pagina.", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index fa81bdc36..ed1518d02 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -5,6 +5,7 @@ "account.block": "Заблокировать @{name}", "account.block_domain": "Заблокировать {domain}", "account.blocked": "Заблокирован(а)", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Отменить запрос", "account.direct": "Написать @{name}", "account.domain_blocked": "Домен скрыт", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "ответить", "keyboard_shortcuts.requests": "перейти к запросам на подписку", "keyboard_shortcuts.search": "перейти к поиску", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "перейти к разделу \"добро пожаловать\"", "keyboard_shortcuts.toggle_hidden": "показать/скрыть текст за предупреждением", "keyboard_shortcuts.toggle_sensitivity": "показать/скрыть медиафайлы", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {осталась # минута} few {осталось # минуты} many {осталось # минут} other {осталось # минут}}", "time_remaining.moments": "остались считанные мгновения", "time_remaining.seconds": "{number, plural, one {# секунда} many {# секунд} other {# секунды}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {человек говорит} few {человека говорят} other {человек говорят}} про это", "trends.trending_now": "Самое актуальное", "ui.beforeunload": "Ваш черновик будет утерян, если вы покинете Mastodon.", diff --git a/app/javascript/mastodon/locales/sc.json b/app/javascript/mastodon/locales/sc.json index db2bb0dac..dadc34cde 100644 --- a/app/javascript/mastodon/locales/sc.json +++ b/app/javascript/mastodon/locales/sc.json @@ -5,6 +5,7 @@ "account.block": "Bloca @{name}", "account.block_domain": "Bloca domìniu{domain}", "account.blocked": "Blocadu", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Annulla rechesta de sighidura", "account.direct": "Messàgiu deretu a @{name}", "account.domain_blocked": "Domìniu blocadu", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "pro rispòndere", "keyboard_shortcuts.requests": "pro abèrrere sa lista de rechestas de sighidura", "keyboard_shortcuts.search": "pro atzentrare sa chirca", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "pro abèrrere sa colunna \"Cumintza\"", "keyboard_shortcuts.toggle_hidden": "pro ammustrare o cuare testu de is CW", "keyboard_shortcuts.toggle_sensitivity": "pro ammustrare o cuare mèdias", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {abarrat # minutu} other {abarrant # minutos}}", "time_remaining.moments": "Abarrant pagu momentos", "time_remaining.seconds": "{number, plural, one {abarrat # segundu} other {abarrant # segundos}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {persone} other {persones}} nde sunt chistionende", "trends.trending_now": "Est tendèntzia immoe", "ui.beforeunload": "S'abbotzu tuo at a èssere pèrdidu si essis dae Mastodon.", diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json index ec34bb29e..d37a7db6f 100644 --- a/app/javascript/mastodon/locales/sk.json +++ b/app/javascript/mastodon/locales/sk.json @@ -5,6 +5,7 @@ "account.block": "Blokuj @{name}", "account.block_domain": "Ukry všetko z {domain}", "account.blocked": "Blokovaný/á", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Zruš žiadosť o sledovanie", "account.direct": "Priama správa pre @{name}", "account.domain_blocked": "Doména ukrytá", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "odpovedať", "keyboard_shortcuts.requests": "otvor zoznam žiadostí o sledovanie", "keyboard_shortcuts.search": "zameraj sa na vyhľadávanie", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "otvor panel ''začíname''", "keyboard_shortcuts.toggle_hidden": "ukáž/skry text za CW", "keyboard_shortcuts.toggle_sensitivity": "pre zobrazenie/skrytie médií", @@ -412,6 +414,10 @@ "time_remaining.minutes": "Ostáva {number, plural, one {# minúta} few {# minút} many {# minút} other {# minúty}}", "time_remaining.moments": "Ostáva už iba chviľka", "time_remaining.seconds": "Ostáva {number, plural, one {# sekunda} few {# sekúnd} many {# sekúnd} other {# sekúnd}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {človek spomína} other {ľudí spomína}}", "trends.trending_now": "Teraz populárne", "ui.beforeunload": "Čo máš rozpísané sa stratí, ak opustíš Mastodon.", diff --git a/app/javascript/mastodon/locales/sl.json b/app/javascript/mastodon/locales/sl.json index 3d8bcc571..e969b9ad2 100644 --- a/app/javascript/mastodon/locales/sl.json +++ b/app/javascript/mastodon/locales/sl.json @@ -5,6 +5,7 @@ "account.block": "Blokiraj @{name}", "account.block_domain": "Skrij vse iz {domain}", "account.blocked": "Blokirano", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Neposredno sporočilo @{name}", "account.domain_blocked": "Skrita domena", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "odgovori", "keyboard_shortcuts.requests": "odpri seznam s prošnjami za sledenje", "keyboard_shortcuts.search": "fokusiraj na iskanje", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "odpri stolpec \"začni\"", "keyboard_shortcuts.toggle_hidden": "prikaži/skrij besedilo za CW", "keyboard_shortcuts.toggle_sensitivity": "prikaži/skrij medije", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {oseba} other {ljudi}} govori", "trends.trending_now": "Trending now", "ui.beforeunload": "Vaš osnutek bo izgubljen, če zapustite Mastodona.", diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json index 8626b9ce7..2729c0266 100644 --- a/app/javascript/mastodon/locales/sq.json +++ b/app/javascript/mastodon/locales/sq.json @@ -5,6 +5,7 @@ "account.block": "Blloko @{name}", "account.block_domain": "Fshih gjithçka prej {domain}", "account.blocked": "E bllokuar", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Anulo kërkesën e ndjekjes", "account.direct": "Mesazh i drejtpërdrejt për @{name}", "account.domain_blocked": "Përkatësi e fshehur", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "për t’u përgjigjur", "keyboard_shortcuts.requests": "për hapje liste kërkesash për ndjekje", "keyboard_shortcuts.search": "për kalim fokusi te kërkimi", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "për hapjen e shtyllës \"fillojani\"", "keyboard_shortcuts.toggle_hidden": "për shfaqje/fshehje teksti pas CW", "keyboard_shortcuts.toggle_sensitivity": "për të shfaqur/të fshehur media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural,one {# minutë}other {# minuta}} mbetur", "time_remaining.moments": "Momente të mbetura", "time_remaining.seconds": "{number, plural,one {# sekond}other {# sekonda}} mbetur", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, një {person} {people} të tjerë} po flasin", "trends.trending_now": "Në trend", "ui.beforeunload": "Skica juaj do të humbë nëse dilni nga Mastodon-i.", diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json index d1b4ee7ec..6e0bd4d1e 100644 --- a/app/javascript/mastodon/locales/sr-Latn.json +++ b/app/javascript/mastodon/locales/sr-Latn.json @@ -5,6 +5,7 @@ "account.block": "Blokiraj korisnika @{name}", "account.block_domain": "Sakrij sve sa domena {domain}", "account.blocked": "Blocked", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Direct Message @{name}", "account.domain_blocked": "Domain hidden", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "da odgovorite", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "da se prebacite na pretragu", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Ako napustite Mastodont, izgubićete napisani nacrt.", diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json index 277ef356f..356ff85bc 100644 --- a/app/javascript/mastodon/locales/sr.json +++ b/app/javascript/mastodon/locales/sr.json @@ -5,6 +5,7 @@ "account.block": "Блокирај @{name}", "account.block_domain": "Сакриј све са домена {domain}", "account.blocked": "Блокиран", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Поништи захтеве за праћење", "account.direct": "Директна порука @{name}", "account.domain_blocked": "Домен сакривен", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "да одговорите", "keyboard_shortcuts.requests": "да отворите листу примљених захтева за праћење", "keyboard_shortcuts.search": "да се пребаците на претрагу", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "да отворите колону \"почнимо\"", "keyboard_shortcuts.toggle_hidden": "да прикажете/сакријте текст иза CW-а", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {човек} other {људи}} прича", "trends.trending_now": "Trending now", "ui.beforeunload": "Ако напустите Мастодонт, изгубићете написани нацрт.", diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json index c83ede5bc..11b72db36 100644 --- a/app/javascript/mastodon/locales/sv.json +++ b/app/javascript/mastodon/locales/sv.json @@ -5,6 +5,7 @@ "account.block": "Blockera @{name}", "account.block_domain": "Dölj allt från {domain}", "account.blocked": "Blockerad", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Avbryt följarförfrågan", "account.direct": "Skicka ett direktmeddelande till @{name}", "account.domain_blocked": "Domän dold", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "för att svara", "keyboard_shortcuts.requests": "för att öppna Följförfrågningar", "keyboard_shortcuts.search": "för att fokusera sökfältet", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "för att öppna \"Kom igång\"-kolumnen", "keyboard_shortcuts.toggle_hidden": "för att visa/gömma text bakom CW", "keyboard_shortcuts.toggle_sensitivity": "för att visa/gömma media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{minutes, plural, one {1 minut} other {# minuter}} kvar", "time_remaining.moments": "Återstående tillfällen", "time_remaining.seconds": "{hours, plural, one {# sekund} other {# sekunder}} kvar", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, en {person} andra {people}} pratar", "trends.trending_now": "Trendar nu", "ui.beforeunload": "Ditt utkast kommer att förloras om du lämnar Mastodon.", diff --git a/app/javascript/mastodon/locales/ta.json b/app/javascript/mastodon/locales/ta.json index 22d24639f..117fa0839 100644 --- a/app/javascript/mastodon/locales/ta.json +++ b/app/javascript/mastodon/locales/ta.json @@ -5,6 +5,7 @@ "account.block": "@{name} -ஐத் தடு", "account.block_domain": "{domain} யில் இருந்து வரும் எல்லாவற்றையும் மறை", "account.blocked": "முடக்கப்பட்டது", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "பின்தொடரும் கோரிக்கையை நிராகரி", "account.direct": "நேரடி செய்தி @{name}", "account.domain_blocked": "மறைக்கப்பட்டத் தளங்கள்", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "பதிலளிக்க", "keyboard_shortcuts.requests": "கோரிக்கைகள் பட்டியலைத் திறக்க", "keyboard_shortcuts.search": "தேடல் கவனம் செலுத்த", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "'தொடங்குவதற்கு' நெடுவரிசை திறக்க", "keyboard_shortcuts.toggle_hidden": "CW க்கு பின்னால் உரையை மறைக்க / மறைக்க", "keyboard_shortcuts.toggle_sensitivity": "படிமங்களைக் காட்ட/மறைக்க", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minute} மற்ற {# minutes}} left", "time_remaining.moments": "தருணங்கள் மீதமுள்ளன", "time_remaining.seconds": "{number, plural, one {# second} மற்ற {# seconds}} left", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} மற்ற {people}} உரையாடு", "trends.trending_now": "இப்போது செல்திசையில் இருப்பவை", "ui.beforeunload": "நீங்கள் வெளியே சென்றால் உங்கள் வரைவு இழக்கப்படும் மஸ்தோடோன்.", diff --git a/app/javascript/mastodon/locales/te.json b/app/javascript/mastodon/locales/te.json index 031d1fad7..bd94f8498 100644 --- a/app/javascript/mastodon/locales/te.json +++ b/app/javascript/mastodon/locales/te.json @@ -5,6 +5,7 @@ "account.block": "@{name} ను బ్లాక్ చేయి", "account.block_domain": "{domain} నుంచి అన్నీ దాచిపెట్టు", "account.blocked": "బ్లాక్ అయినవి", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "@{name}కు నేరుగా సందేశం పంపు", "account.domain_blocked": "డొమైన్ దాచిపెట్టబడినది", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "ప్రత్యుత్తరం ఇవ్వడానికి", "keyboard_shortcuts.requests": "ఫాలో రిక్వెస్ట్ల జాబితాను తెరవడానికి", "keyboard_shortcuts.search": "శోధనపై దృష్టి పెట్టండి", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "\"ఇక్కడ ప్రారంభించండి\" నిలువు వరుసను తెరవడానికి", "keyboard_shortcuts.toggle_hidden": "CW వెనుక ఉన్న పాఠ్యాన్ని చూపడానికి / దాచడానికి", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", "time_remaining.moments": "కొన్ని క్షణాలు మాత్రమే మిగిలి ఉన్నాయి", "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} మాట్లాడుతున్నారు", "trends.trending_now": "Trending now", "ui.beforeunload": "మీరు మాస్టొడొన్ను వదిలివేస్తే మీ డ్రాఫ్ట్లు పోతాయి.", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index 8ba698e4e..a95fc55b9 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -5,6 +5,7 @@ "account.block": "ปิดกั้น @{name}", "account.block_domain": "ปิดกั้นโดเมน {domain}", "account.blocked": "ปิดกั้นอยู่", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "ยกเลิกคำขอติดตาม", "account.direct": "ส่งข้อความโดยตรงถึง @{name}", "account.domain_blocked": "ปิดกั้นโดเมนอยู่", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "เพื่อตอบกลับ", "keyboard_shortcuts.requests": "เพื่อเปิดรายการคำขอติดตาม", "keyboard_shortcuts.search": "เพื่อโฟกัสการค้นหา", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "เพื่อเปิดคอลัมน์ \"เริ่มต้นใช้งาน\"", "keyboard_shortcuts.toggle_hidden": "เพื่อแสดง/ซ่อนข้อความที่อยู่หลังคำเตือนเนื้อหา", "keyboard_shortcuts.toggle_sensitivity": "เพื่อแสดง/ซ่อนสื่อ", @@ -412,6 +414,10 @@ "time_remaining.minutes": "เหลืออีก {number, plural, other {# นาที}}", "time_remaining.moments": "ช่วงเวลาที่เหลือ", "time_remaining.seconds": "เหลืออีก {number, plural, other {# วินาที}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, other {คน}}กำลังพูดคุย", "trends.trending_now": "กำลังนิยม", "ui.beforeunload": "แบบร่างของคุณจะหายไปหากคุณออกจาก Mastodon", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index c6c1c8d8c..17d8a54ff 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -5,6 +5,7 @@ "account.block": "@{name} adlı kişiyi engelle", "account.block_domain": "{domain} alanından her şeyi gizle", "account.blocked": "Engellenmiş", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Takip isteğini iptal et", "account.direct": "Mesaj gönder @{name}", "account.domain_blocked": "Alan adı gizlendi", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "cevaplamak için", "keyboard_shortcuts.requests": "takip istekleri listesini açmak için", "keyboard_shortcuts.search": "aramaya odaklanmak için", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "\"başlayın\" sütununu açmak için", "keyboard_shortcuts.toggle_hidden": "CW'den önceki yazıyı göstermek/gizlemek için", "keyboard_shortcuts.toggle_sensitivity": "medyayı göstermek/gizlemek için", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# dakika} other {# dakika}} kaldı", "time_remaining.moments": "Sadece birkaç dakika kaldı", "time_remaining.seconds": "{number, plural, one {# saniye} other {# saniye}} kaldı", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {kişi} other {kişi}} konuşuyor", "trends.trending_now": "Şu an popüler", "ui.beforeunload": "Mastodon'dan ayrılırsanız taslağınız kaybolacak.", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index 2bf264395..9735f2b83 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -5,6 +5,7 @@ "account.block": "Заблокувати @{name}", "account.block_domain": "Заглушити {domain}", "account.blocked": "Заблоковані", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Скасувати запит на підписку", "account.direct": "Пряме повідомлення @{name}", "account.domain_blocked": "Домен приховано", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "відповісти", "keyboard_shortcuts.requests": "відкрити список бажаючих підписатися", "keyboard_shortcuts.search": "сфокусуватися на пошуку", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "відкрити колонку \"Початок\"", "keyboard_shortcuts.toggle_hidden": "показати/приховати текст під попередженням", "keyboard_shortcuts.toggle_sensitivity": "показати/приховати медіа", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, one {# хвилина} few {# хвилини} other {# хвилин}}", "time_remaining.moments": "Залишилось секунд", "time_remaining.seconds": "{number, plural, one {# секунда} few {# секунди} other {# секунд}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {людина} few {людини} many {людей} other {людей}} обговорюють це", "trends.trending_now": "Актуальні", "ui.beforeunload": "Вашу чернетку буде втрачено, якщо ви покинете Mastodon.", diff --git a/app/javascript/mastodon/locales/ur.json b/app/javascript/mastodon/locales/ur.json index b4989afb7..e3639d477 100644 --- a/app/javascript/mastodon/locales/ur.json +++ b/app/javascript/mastodon/locales/ur.json @@ -5,6 +5,7 @@ "account.block": "مسدود @{name}", "account.block_domain": "{domain} سے سب چھپائیں", "account.blocked": "مسدود کردہ", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "درخواستِ پیروی منسوخ کریں", "account.direct": "راست پیغام @{name}", "account.domain_blocked": "پوشیدہ ڈومین", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "to reply", "keyboard_shortcuts.requests": "to open follow requests list", "keyboard_shortcuts.search": "to focus search", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "to show/hide media", @@ -412,6 +414,10 @@ "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", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", diff --git a/app/javascript/mastodon/locales/vi.json b/app/javascript/mastodon/locales/vi.json index 126fbc561..2d16526c4 100644 --- a/app/javascript/mastodon/locales/vi.json +++ b/app/javascript/mastodon/locales/vi.json @@ -5,6 +5,7 @@ "account.block": "Chặn @{name}", "account.block_domain": "Ẩn mọi thứ từ {domain}", "account.blocked": "Đã chặn", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Hủy theo yêu cầu", "account.direct": "Nhắn riêng @{name}", "account.domain_blocked": "Miền đã ẩn", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "để trả lời", "keyboard_shortcuts.requests": "để mở danh sách các yêu cầu", "keyboard_shortcuts.search": "để vào ô tìm kiếm", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "để mở cột \"Bắt đầu\"", "keyboard_shortcuts.toggle_hidden": "để ẩn/hiện đằng sau văn bản CW", "keyboard_shortcuts.toggle_sensitivity": "để hiển thị / ẩn media", @@ -412,6 +414,10 @@ "time_remaining.minutes": "{number, plural, other {}} left", "time_remaining.moments": "Còn lại", "time_remaining.seconds": "{number, plural, other {}} left", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{Count} {rawCount, số nhiều, một {người} khác {người}} nói chuyện", "trends.trending_now": "Đang là xu hướng", "ui.beforeunload": "Bản nháp của bạn sẽ bị mất nếu bạn rời của Mastodon.", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index ccd69815d..0069b90ae 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -5,6 +5,7 @@ "account.block": "屏蔽 @{name}", "account.block_domain": "隐藏来自 {domain} 的内容", "account.blocked": "已屏蔽", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "取消关注请求", "account.direct": "发送私信给 @{name}", "account.domain_blocked": "网站已屏蔽", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "回复嘟文", "keyboard_shortcuts.requests": "打开关注请求列表", "keyboard_shortcuts.search": "选择搜索框", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "打开“开始使用”栏", "keyboard_shortcuts.toggle_hidden": "显示或隐藏被折叠的正文", "keyboard_shortcuts.toggle_sensitivity": "显示/隐藏媒体", @@ -412,6 +414,10 @@ "time_remaining.minutes": "剩余 {number, plural, one {# 分钟} other {# 分钟}}", "time_remaining.moments": "即将结束", "time_remaining.seconds": "剩余 {number, plural, one {# 秒} other {# 秒}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} 人正在讨论", "trends.trending_now": "现在流行", "ui.beforeunload": "如果你现在离开 Mastodon,你的草稿内容将会丢失。", diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index 1ae125ba0..f3212049b 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -5,6 +5,7 @@ "account.block": "封鎖 @{name}", "account.block_domain": "隱藏來自 {domain} 的一切文章", "account.blocked": "封鎖", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "取消關注請求", "account.direct": "私訊 @{name}", "account.domain_blocked": "服務站被隱藏", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "回覆", "keyboard_shortcuts.requests": "開啟關注請求名單", "keyboard_shortcuts.search": "把標示移動到搜索", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "開啟「開始使用」欄位", "keyboard_shortcuts.toggle_hidden": "顯示或隱藏被標為敏感的文字", "keyboard_shortcuts.toggle_sensitivity": "顯示 / 隱藏媒體", @@ -412,6 +414,10 @@ "time_remaining.minutes": "剩餘{number, plural, one {# 分鐘} other {# 分鐘}}", "time_remaining.moments": "剩餘時間", "time_remaining.seconds": "剩餘 {number, plural, one {# 秒} other {# 秒}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} 位用戶在討論", "trends.trending_now": "目前趨勢", "ui.beforeunload": "如果你現在離開 Mastodon,你的草稿內容將會被丟棄。", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index f4862cf86..62ef113ed 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -5,6 +5,7 @@ "account.block": "封鎖 @{name}", "account.block_domain": "隱藏來自 {domain} 的所有內容", "account.blocked": "已封鎖", + "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "取消關注請求", "account.direct": "傳私訊給 @{name}", "account.domain_blocked": "已隱藏網域", @@ -236,6 +237,7 @@ "keyboard_shortcuts.reply": "回覆", "keyboard_shortcuts.requests": "開啟關注請求名單", "keyboard_shortcuts.search": "將焦點移至搜尋框", + "keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.start": "開啟「開始使用」欄位", "keyboard_shortcuts.toggle_hidden": "顯示/隱藏在內容警告之後的正文", "keyboard_shortcuts.toggle_sensitivity": "顯示 / 隱藏媒體", @@ -412,6 +414,10 @@ "time_remaining.minutes": "剩餘{number, plural, one {# 分鐘} other {# 分鐘}}", "time_remaining.moments": "剩餘時間", "time_remaining.seconds": "剩餘 {number, plural, one {# 秒} other {# 秒}}", + "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.", + "timeline_hint.resources.followers": "Followers", + "timeline_hint.resources.follows": "Follows", + "timeline_hint.resources.statuses": "Older toots", "trends.count_by_accounts": "{count} 位使用者在討論", "trends.trending_now": "目前趨勢", "ui.beforeunload": "如果離開 Mastodon,你的草稿將會不見。", diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss index c68944528..bc039ff03 100644 --- a/app/javascript/styles/mastodon-light/variables.scss +++ b/app/javascript/styles/mastodon-light/variables.scss @@ -39,3 +39,5 @@ $account-background-color: $white !default; @function lighten($color, $amount) { @return hsl(hue($color), saturation($color), lightness($color) - $amount); } + +$emojis-requiring-inversion: 'chains'; diff --git a/app/javascript/styles/mastodon/accessibility.scss b/app/javascript/styles/mastodon/accessibility.scss index d33806c84..c5bcb5941 100644 --- a/app/javascript/styles/mastodon/accessibility.scss +++ b/app/javascript/styles/mastodon/accessibility.scss @@ -1,14 +1,13 @@ -$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash'; +$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange' 'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on' 'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default; -%white-emoji-outline { - filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white); - transform: scale(.71); +%emoji-color-inversion { + filter: invert(1); } .emojione { - @each $emoji in $black-emojis { + @each $emoji in $emojis-requiring-inversion { &[title=':#{$emoji}:'] { - @extend %white-emoji-outline; + @extend %emoji-color-inversion; } } } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 64f97c648..acbd21e8b 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3097,6 +3097,11 @@ a.status-card { flex: 1 1 auto; overflow: hidden; padding: 14px 14px 14px 8px; + + &--blurred { + filter: blur(2px); + pointer-events: none; + } } .status-card__description { @@ -3134,7 +3139,8 @@ a.status-card { width: 100%; } - .status-card__image-image { + .status-card__image-image, + .status-card__image-preview { border-radius: 4px 4px 0 0; } @@ -3179,6 +3185,24 @@ a.status-card.compact:hover { background-position: center center; } +.status-card__image-preview { + border-radius: 4px 0 0 4px; + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: fill; + position: absolute; + top: 0; + left: 0; + z-index: 0; + background: $base-overlay-background; + + &--hidden { + display: none; + } +} + .load-more { display: block; color: $dark-text-color; @@ -3203,6 +3227,31 @@ a.status-card.compact:hover { border-bottom: 1px solid lighten($ui-base-color, 8%); } +.timeline-hint { + text-align: center; + color: $darker-text-color; + padding: 15px; + box-sizing: border-box; + width: 100%; + cursor: default; + + strong { + font-weight: 500; + } + + a { + color: lighten($ui-highlight-color, 8%); + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + color: lighten($ui-highlight-color, 12%); + } + } +} + .regeneration-indicator { text-align: center; font-size: 16px; diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 0e5b00e8f..7a0b2f9a3 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -587,7 +587,7 @@ code { &.alert { border: 1px solid rgba($error-value-color, 0.5); - background: rgba($error-value-color, 0.25); + background: rgba($error-value-color, 0.1); color: $error-value-color; } diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 572b8087e..3509a6c40 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -2,6 +2,45 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def perform + case @object['type'] + when 'EncryptedMessage' + create_encrypted_message + else + create_status + end + end + + private + + def create_encrypted_message + return reject_payload! if invalid_origin?(@object['id']) || @options[:delivered_to_account_id].blank? + + target_account = Account.find(@options[:delivered_to_account_id]) + target_device = target_account.devices.find_by(device_id: @object.dig('to', 'deviceId')) + + return if target_device.nil? + + target_device.encrypted_messages.create!( + from_account: @account, + from_device_id: @object.dig('attributedTo', 'deviceId'), + type: @object['messageType'], + body: @object['cipherText'], + digest: @object.dig('digest', 'digestValue'), + message_franking: message_franking.to_token + ) + end + + def message_franking + MessageFranking.new( + hmac: @object.dig('digest', 'digestValue'), + original_franking: @object['messageFranking'], + source_account_id: @account.id, + target_account_id: @options[:delivered_to_account_id], + timestamp: Time.now.utc + ) + end + + def create_status return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity? RedisLock.acquire(lock_options) do |lock| @@ -23,8 +62,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @status end - private - def audience_to @object['to'] || @json['to'] end @@ -262,6 +299,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def poll_vote! poll = replied_to_status.preloadable_poll already_voted = true + RedisLock.acquire(poll_lock_options) do |lock| if lock.acquired? already_voted = poll.votes.where(account: @account).exists? @@ -270,20 +308,24 @@ class ActivityPub::Activity::Create < ActivityPub::Activity raise Mastodon::RaceConditionError end end + increment_voters_count! unless already_voted ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, replied_to_status.id) unless replied_to_status.preloadable_poll.hide_totals? end def resolve_thread(status) return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri) + ThreadResolveWorker.perform_async(status.id, in_reply_to_uri) end def fetch_replies(status) collection = @object['replies'] return if collection.nil? + replies = ActivityPub::FetchRepliesService.new.call(status, collection, false) return unless replies.nil? + uri = value_or_id(collection) ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil? end @@ -291,6 +333,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def conversation_from_uri(uri) return nil if uri.nil? return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri) + begin Conversation.find_or_create_by!(uri: uri) rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique @@ -404,6 +447,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def skip_download? return @skip_download if defined?(@skip_download) + @skip_download ||= DomainBlock.reject_media?(@account.domain) end @@ -436,11 +480,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity 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]) end def increment_voters_count! poll = replied_to_status.preloadable_poll + unless poll.voters_count.nil? poll.voters_count = poll.voters_count + 1 poll.save diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 78138fb73..634ed29fa 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -22,6 +22,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' }, discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, + olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' }, }.freeze def self.default_key_transform diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 3ce182809..8b3198df7 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -287,9 +287,14 @@ class FeedManager combined_regex = active_filters.reduce { |memo, obj| Regexp.union(memo, obj) } status = status.reblog if status.reblog? - !combined_regex.match(Formatter.instance.plaintext(status)).nil? || - (status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?) || - (status.preloadable_poll && !combined_regex.match(status.preloadable_poll.options.join("\n\n")).nil?) + combined_text = [ + Formatter.instance.plaintext(status), + status.spoiler_text, + status.preloadable_poll ? status.preloadable_poll.options.join("\n\n") : nil, + status.media_attachments.map(&:description).join("\n\n"), + ].compact.join("\n\n") + + !combined_regex.match(combined_text).nil? end # Adds a status to an account's feed, returning true if a status was diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb index 27e334a4d..b70814748 100644 --- a/app/lib/inline_renderer.rb +++ b/app/lib/inline_renderer.rb @@ -19,6 +19,8 @@ class InlineRenderer serializer = REST::AnnouncementSerializer when :reaction serializer = REST::ReactionSerializer + when :encrypted_message + serializer = REST::EncryptedMessageSerializer else return end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 88a11f761..2cd58e60a 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -126,4 +126,21 @@ class UserMailer < Devise::Mailer reply_to: Setting.site_contact_email end end + + def sign_in_token(user, remote_ip, user_agent, timestamp) + @resource = user + @instance = Rails.configuration.x.local_domain + @remote_ip = remote_ip + @user_agent = user_agent + @detection = Browser.new(user_agent) + @timestamp = timestamp.to_time.utc + + return if @resource.disabled? + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, + subject: I18n.t('user_mailer.sign_in_token.subject'), + reply_to: Setting.site_contact_email + end + end end diff --git a/app/models/account.rb b/app/models/account.rb index 5038d4768..0b3c48543 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -49,6 +49,7 @@ # hide_collections :boolean # avatar_storage_schema_version :integer # header_storage_schema_version :integer +# devices_url :string # class Account < ApplicationRecord diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 499edbf4e..cca3a17fa 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -9,6 +9,7 @@ module AccountAssociations # Identity proofs has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account + has_many :devices, dependent: :destroy, inverse_of: :account # Timelines has_many :statuses, inverse_of: :account, dependent: :destroy diff --git a/app/models/concerns/domain_normalizable.rb b/app/models/concerns/domain_normalizable.rb index c00b3142f..fb84058fc 100644 --- a/app/models/concerns/domain_normalizable.rb +++ b/app/models/concerns/domain_normalizable.rb @@ -4,7 +4,7 @@ module DomainNormalizable extend ActiveSupport::Concern included do - before_save :normalize_domain + before_validation :normalize_domain end private diff --git a/app/models/device.rb b/app/models/device.rb new file mode 100644 index 000000000..97d0d2774 --- /dev/null +++ b/app/models/device.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: devices +# +# id :bigint(8) not null, primary key +# access_token_id :bigint(8) +# account_id :bigint(8) +# device_id :string default(""), not null +# name :string default(""), not null +# fingerprint_key :text default(""), not null +# identity_key :text default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Device < ApplicationRecord + belongs_to :access_token, class_name: 'Doorkeeper::AccessToken' + belongs_to :account + + has_many :one_time_keys, dependent: :destroy, inverse_of: :device + has_many :encrypted_messages, dependent: :destroy, inverse_of: :device + + validates :name, :fingerprint_key, :identity_key, presence: true + validates :fingerprint_key, :identity_key, ed25519_key: true + + before_save :invalidate_associations, if: -> { device_id_changed? || fingerprint_key_changed? || identity_key_changed? } + + private + + def invalidate_associations + one_time_keys.destroy_all + encrypted_messages.destroy_all + end +end diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index f0a5bd296..2b18e01fa 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -50,11 +50,13 @@ class DomainBlock < ApplicationRecord def rule_for(domain) return if domain.blank? - uri = Addressable::URI.new.tap { |u| u.host = domain.gsub(/[\/]/, '') } + uri = Addressable::URI.new.tap { |u| u.host = domain.strip.gsub(/[\/]/, '') } segments = uri.normalized_host.split('.') variants = segments.map.with_index { |_, i| segments[i..-1].join('.') } where(domain: variants).order(Arel.sql('char_length(domain) desc')).first + rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError + nil end end diff --git a/app/models/encrypted_message.rb b/app/models/encrypted_message.rb new file mode 100644 index 000000000..5e0aba434 --- /dev/null +++ b/app/models/encrypted_message.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: encrypted_messages +# +# id :bigint(8) not null, primary key +# device_id :bigint(8) +# from_account_id :bigint(8) +# from_device_id :string default(""), not null +# type :integer default(0), not null +# body :text default(""), not null +# digest :text default(""), not null +# message_franking :text default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class EncryptedMessage < ApplicationRecord + self.inheritance_column = nil + + include Paginable + + scope :up_to, ->(id) { where(arel_table[:id].lteq(id)) } + + belongs_to :device + belongs_to :from_account, class_name: 'Account' + + around_create Mastodon::Snowflake::Callbacks + + after_commit :push_to_streaming_api + + private + + def push_to_streaming_api + Rails.logger.info(streaming_channel) + Rails.logger.info(subscribed_to_timeline?) + + return if destroyed? || !subscribed_to_timeline? + + PushEncryptedMessageWorker.perform_async(id) + end + + def subscribed_to_timeline? + Redis.current.exists("subscribed:#{streaming_channel}") + end + + def streaming_channel + "timeline:#{device.account_id}:#{device.device_id}" + end +end diff --git a/app/models/message_franking.rb b/app/models/message_franking.rb new file mode 100644 index 000000000..c72bd1cca --- /dev/null +++ b/app/models/message_franking.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class MessageFranking + attr_reader :hmac, :source_account_id, :target_account_id, + :timestamp, :original_franking + + def initialize(attributes = {}) + @hmac = attributes[:hmac] + @source_account_id = attributes[:source_account_id] + @target_account_id = attributes[:target_account_id] + @timestamp = attributes[:timestamp] + @original_franking = attributes[:original_franking] + end + + def to_token + crypt = ActiveSupport::MessageEncryptor.new(SystemKey.current_key, serializer: Oj) + crypt.encrypt_and_sign(self) + end +end diff --git a/app/models/one_time_key.rb b/app/models/one_time_key.rb new file mode 100644 index 000000000..8ada34824 --- /dev/null +++ b/app/models/one_time_key.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: one_time_keys +# +# id :bigint(8) not null, primary key +# device_id :bigint(8) +# key_id :string default(""), not null +# key :text default(""), not null +# signature :text default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class OneTimeKey < ApplicationRecord + belongs_to :device + + validates :key_id, :key, :signature, presence: true + validates :key, ed25519_key: true + validates :signature, ed25519_signature: { message: :key, verify_key: ->(one_time_key) { one_time_key.device.fingerprint_key } } +end diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb index 2802f4667..235928260 100644 --- a/app/models/preview_card.rb +++ b/app/models/preview_card.rb @@ -23,19 +23,25 @@ # updated_at :datetime not null # embed_url :string default(""), not null # image_storage_schema_version :integer +# blurhash :string # class PreviewCard < ApplicationRecord IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze LIMIT = 1.megabytes + BLURHASH_OPTIONS = { + x_comp: 4, + y_comp: 4, + }.freeze + self.inheritance_column = false enum type: [:link, :photo, :video, :rich] has_and_belongs_to_many :statuses - has_attached_file :image, styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' } + has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' } include Attachmentable @@ -72,6 +78,7 @@ class PreviewCard < ApplicationRecord geometry: '400x400>', file_geometry_parser: FastGeometryParser, convert_options: '-coalesce -strip', + blurhash: BLURHASH_OPTIONS, }, } diff --git a/app/models/system_key.rb b/app/models/system_key.rb new file mode 100644 index 000000000..f17db7c2d --- /dev/null +++ b/app/models/system_key.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: system_keys +# +# id :bigint(8) not null, primary key +# key :binary +# created_at :datetime not null +# updated_at :datetime not null +# +class SystemKey < ApplicationRecord + ROTATION_PERIOD = 1.week.freeze + + before_validation :set_key + + scope :expired, ->(now = Time.now.utc) { where(arel_table[:created_at].lt(now - ROTATION_PERIOD * 3)) } + + class << self + def current_key + previous_key = order(id: :asc).last + + if previous_key && previous_key.created_at >= ROTATION_PERIOD.ago + previous_key.key + else + create.key + end + end + end + + private + + def set_key + return if key.present? + + cipher = OpenSSL::Cipher.new('AES-256-GCM') + cipher.encrypt + + self.key = cipher.random_key + end +end diff --git a/app/models/user.rb b/app/models/user.rb index c8dbd2fd3..a05d98d88 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -38,6 +38,8 @@ # chosen_languages :string is an Array # created_by_application_id :bigint(8) # approved :boolean default(TRUE), not null +# sign_in_token :string +# sign_in_token_sent_at :datetime # class User < ApplicationRecord @@ -114,7 +116,7 @@ class User < ApplicationRecord :default_content_type, :system_emoji_font, to: :settings, prefix: :setting, allow_nil: false - attr_reader :invite_code + attr_reader :invite_code, :sign_in_token_attempt attr_writer :external def confirmed? @@ -168,6 +170,10 @@ class User < ApplicationRecord true end + def suspicious_sign_in?(ip) + !otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 2.weeks.ago && !recent_ip?(ip) + end + def functional? confirmed? && approved? && !disabled? && !account.suspended? end @@ -270,6 +276,13 @@ class User < ApplicationRecord super end + def external_or_valid_password?(compare_password) + # If encrypted_password is blank, we got the user from LDAP or PAM, + # so credentials are already valid + + encrypted_password.blank? || valid_password?(compare_password) + end + def send_reset_password_instructions return false if encrypted_password.blank? @@ -305,6 +318,15 @@ class User < ApplicationRecord end end + def sign_in_token_expired? + sign_in_token_sent_at.nil? || sign_in_token_sent_at < 5.minutes.ago + end + + def generate_sign_in_token + self.sign_in_token = Devise.friendly_token(6) + self.sign_in_token_sent_at = Time.now.utc + end + protected def send_devise_notification(notification, *args) @@ -321,6 +343,10 @@ class User < ApplicationRecord private + def recent_ip?(ip) + recent_ips.any? { |(_, recent_ip)| recent_ip == ip } + end + def send_pending_devise_notifications pending_devise_notifications.each do |notification, args| render_and_send_devise_message(notification, *args) diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb new file mode 100644 index 000000000..5d174767f --- /dev/null +++ b/app/presenters/activitypub/activity_presenter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model + attributes :id, :type, :actor, :published, :to, :cc, :virtual_object + + class << self + def from_status(status) + new.tap do |presenter| + presenter.id = ActivityPub::TagManager.instance.activity_uri_for(status) + presenter.type = status.reblog? ? 'Announce' : 'Create' + presenter.actor = ActivityPub::TagManager.instance.uri_for(status.account) + presenter.published = status.created_at + presenter.to = ActivityPub::TagManager.instance.to(status) + presenter.cc = ActivityPub::TagManager.instance.cc(status) + + presenter.virtual_object = begin + if status.reblog? + if status.account == status.proper.account && status.proper.private_visibility? && status.local? + status.proper + else + ActivityPub::TagManager.instance.uri_for(status.proper) + end + else + status.proper + end + end + end + end + + def from_encrypted_message(encrypted_message) + new.tap do |presenter| + presenter.id = ActivityPub::TagManager.instance.generate_uri_for(nil) + presenter.type = 'Create' + presenter.actor = ActivityPub::TagManager.instance.uri_for(encrypted_message.source_account) + presenter.published = Time.now.utc + presenter.to = ActivityPub::TagManager.instance.uri_for(encrypted_message.target_account) + presenter.virtual_object = encrypted_message + end + end + end +end diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb index 70c496be8..06482935c 100644 --- a/app/presenters/initial_state_presenter.rb +++ b/app/presenters/initial_state_presenter.rb @@ -2,5 +2,5 @@ class InitialStatePresenter < ActiveModelSerializers::Model attributes :settings, :push_subscription, :token, - :current_account, :admin, :text + :current_account, :admin, :text, :visibility end diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index d0edad786..5bdf53f03 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -1,52 +1,22 @@ # frozen_string_literal: true 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? - - def id - ActivityPub::TagManager.instance.activity_uri_for(object) + def self.serializer_for(model, options) + case model.class.name + when 'Status' + ActivityPub::NoteSerializer + when 'DeliverToDeviceService::EncryptedMessage' + ActivityPub::EncryptedMessageSerializer + else + super + end end - def type - announce? ? 'Announce' : 'Create' - end + attributes :id, :type, :actor, :published, :to, :cc - def actor - ActivityPub::TagManager.instance.uri_for(object.account) - end + has_one :virtual_object, key: :object def published - object.created_at.iso8601 - end - - def to - ActivityPub::TagManager.instance.to(object) - end - - def cc - ActivityPub::TagManager.instance.cc(object) - end - - def proper_uri - ActivityPub::TagManager.instance.uri_for(object.proper) - end - - def atom_uri - OStatus::TagManager.instance.uri_for(object) - end - - def announce? - object.reblog? - end - - def serialize_object? - return true unless announce? - # Serialize private self-boosts of local toots - object.account == object.proper.account && object.proper.private_visibility? && object.local? + object.published.iso8601 end end diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index aa64936a7..627d4446b 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -7,7 +7,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer context_extensions :manually_approves_followers, :featured, :also_known_as, :moved_to, :property_value, :identity_proof, - :discoverable + :discoverable, :olm attributes :id, :type, :following, :followers, :inbox, :outbox, :featured, @@ -20,6 +20,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer has_many :virtual_tags, key: :tag has_many :virtual_attachments, key: :attachment + attribute :devices, unless: :instance_actor? attribute :moved_to, if: :moved? attribute :also_known_as, if: :also_known_as? @@ -38,7 +39,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer has_one :icon, serializer: ActivityPub::ImageSerializer, if: :avatar_exists? has_one :image, serializer: ActivityPub::ImageSerializer, if: :header_exists? - delegate :moved?, to: :object + delegate :moved?, :instance_actor?, to: :object def id object.instance_actor? ? instance_actor_url : account_url(object) @@ -68,6 +69,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer object.instance_actor? ? instance_actor_inbox_url : account_inbox_url(object) end + def devices + account_collection_url(object, :devices) + end + def outbox account_outbox_url(object) end diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb index da1ba735f..ea7af5433 100644 --- a/app/serializers/activitypub/collection_serializer.rb +++ b/app/serializers/activitypub/collection_serializer.rb @@ -1,10 +1,28 @@ # frozen_string_literal: true class ActivityPub::CollectionSerializer < ActivityPub::Serializer + class StringSerializer < ActiveModel::Serializer + # Despite the name, it does not return a hash, but the same can be said of + # the ActiveModel::Serializer::CollectionSerializer class which handles + # arrays. + def serializable_hash(*_args) + object + end + end + def self.serializer_for(model, options) - return ActivityPub::NoteSerializer if model.class.name == 'Status' - return ActivityPub::CollectionSerializer if model.class.name == 'ActivityPub::CollectionPresenter' - super + case model.class.name + when 'Status' + ActivityPub::NoteSerializer + when 'Device' + ActivityPub::DeviceSerializer + when 'ActivityPub::CollectionPresenter' + ActivityPub::CollectionSerializer + when 'String' + StringSerializer + else + super + end end attribute :id, if: -> { object.id.present? } diff --git a/app/serializers/activitypub/device_serializer.rb b/app/serializers/activitypub/device_serializer.rb new file mode 100644 index 000000000..5f0fdc8af --- /dev/null +++ b/app/serializers/activitypub/device_serializer.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ActivityPub::DeviceSerializer < ActivityPub::Serializer + context_extensions :olm + + include RoutingHelper + + class FingerprintKeySerializer < ActivityPub::Serializer + attributes :type, :public_key_base64 + + def type + 'Ed25519Key' + end + + def public_key_base64 + object.fingerprint_key + end + end + + class IdentityKeySerializer < ActivityPub::Serializer + attributes :type, :public_key_base64 + + def type + 'Curve25519Key' + end + + def public_key_base64 + object.identity_key + end + end + + attributes :device_id, :type, :name, :claim + + has_one :fingerprint_key, serializer: FingerprintKeySerializer + has_one :identity_key, serializer: IdentityKeySerializer + + def type + 'Device' + end + + def claim + account_claim_url(object.account, id: object.device_id) + end + + def fingerprint_key + object + end + + def identity_key + object + end +end diff --git a/app/serializers/activitypub/encrypted_message_serializer.rb b/app/serializers/activitypub/encrypted_message_serializer.rb new file mode 100644 index 000000000..3c525d23e --- /dev/null +++ b/app/serializers/activitypub/encrypted_message_serializer.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class ActivityPub::EncryptedMessageSerializer < ActivityPub::Serializer + context :security + + context_extensions :olm + + class DeviceSerializer < ActivityPub::Serializer + attributes :type, :device_id + + def type + 'Device' + end + + def device_id + object + end + end + + class DigestSerializer < ActivityPub::Serializer + attributes :type, :digest_algorithm, :digest_value + + def type + 'Digest' + end + + def digest_algorithm + 'http://www.w3.org/2000/09/xmldsig#hmac-sha256' + end + + def digest_value + object + end + end + + attributes :type, :message_type, :cipher_text, :message_franking + + has_one :attributed_to, serializer: DeviceSerializer + has_one :to, serializer: DeviceSerializer + has_one :digest, serializer: DigestSerializer + + def type + 'EncryptedMessage' + end + + def attributed_to + object.source_device.device_id + end + + def to + object.target_device_id + end + + def message_type + object.type + end + + def cipher_text + object.body + end +end diff --git a/app/serializers/activitypub/one_time_key_serializer.rb b/app/serializers/activitypub/one_time_key_serializer.rb new file mode 100644 index 000000000..5932eb5b5 --- /dev/null +++ b/app/serializers/activitypub/one_time_key_serializer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class ActivityPub::OneTimeKeySerializer < ActivityPub::Serializer + context :security + + context_extensions :olm + + class SignatureSerializer < ActivityPub::Serializer + attributes :type, :signature_value + + def type + 'Ed25519Signature' + end + + def signature_value + object.signature + end + end + + attributes :key_id, :type, :public_key_base64 + + has_one :signature, serializer: SignatureSerializer + + def type + 'Curve25519Key' + end + + def public_key_base64 + object.key + end + + def signature + object + end +end diff --git a/app/serializers/activitypub/outbox_serializer.rb b/app/serializers/activitypub/outbox_serializer.rb index 48fbad0fd..4f4f950a5 100644 --- a/app/serializers/activitypub/outbox_serializer.rb +++ b/app/serializers/activitypub/outbox_serializer.rb @@ -2,7 +2,14 @@ class ActivityPub::OutboxSerializer < ActivityPub::CollectionSerializer def self.serializer_for(model, options) - return ActivityPub::ActivitySerializer if model.is_a?(Status) - super + if model.class.name == 'ActivityPub::ActivityPresenter' + ActivityPub::ActivitySerializer + else + super + end + end + + def items + object.items.map { |status| ActivityPub::ActivityPresenter.from_status(status) } end end diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb index 6758af679..a925efc18 100644 --- a/app/serializers/activitypub/undo_announce_serializer.rb +++ b/app/serializers/activitypub/undo_announce_serializer.rb @@ -3,7 +3,7 @@ class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer attributes :id, :type, :actor, :to - has_one :object, serializer: ActivityPub::ActivitySerializer + has_one :virtual_object, key: :object, serializer: ActivityPub::ActivitySerializer def id [ActivityPub::TagManager.instance.uri_for(object.account), '#announces/', object.id, '/undo'].join @@ -20,4 +20,8 @@ class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer def to [ActivityPub::TagManager::COLLECTIONS[:public]] end + + def virtual_object + ActivityPub::ActivityPresenter.from_status(object) + end end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index e5e88c200..c520c9bcb 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -72,7 +72,7 @@ class InitialStateSerializer < ActiveModel::Serializer if object.current_account store[:me] = object.current_account.id.to_s - store[:default_privacy] = object.current_account.user.setting_default_privacy + store[:default_privacy] = object.visibility || object.current_account.user.setting_default_privacy store[:default_sensitive] = object.current_account.user.setting_default_sensitive end diff --git a/app/serializers/rest/encrypted_message_serializer.rb b/app/serializers/rest/encrypted_message_serializer.rb new file mode 100644 index 000000000..80c26d060 --- /dev/null +++ b/app/serializers/rest/encrypted_message_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class REST::EncryptedMessageSerializer < ActiveModel::Serializer + attributes :id, :account_id, :device_id, + :type, :body, :digest, :message_franking, + :created_at + + def id + object.id.to_s + end + + def account_id + object.from_account_id.to_s + end + + def device_id + object.from_device_id + end +end diff --git a/app/serializers/rest/keys/claim_result_serializer.rb b/app/serializers/rest/keys/claim_result_serializer.rb new file mode 100644 index 000000000..145044f55 --- /dev/null +++ b/app/serializers/rest/keys/claim_result_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::Keys::ClaimResultSerializer < ActiveModel::Serializer + attributes :account_id, :device_id, :key_id, :key, :signature + + def account_id + object.account.id.to_s + end +end diff --git a/app/serializers/rest/keys/device_serializer.rb b/app/serializers/rest/keys/device_serializer.rb new file mode 100644 index 000000000..f9b821b79 --- /dev/null +++ b/app/serializers/rest/keys/device_serializer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class REST::Keys::DeviceSerializer < ActiveModel::Serializer + attributes :device_id, :name, :identity_key, + :fingerprint_key +end diff --git a/app/serializers/rest/keys/query_result_serializer.rb b/app/serializers/rest/keys/query_result_serializer.rb new file mode 100644 index 000000000..8f8bdde28 --- /dev/null +++ b/app/serializers/rest/keys/query_result_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class REST::Keys::QueryResultSerializer < ActiveModel::Serializer + attributes :account_id + + has_many :devices, serializer: REST::Keys::DeviceSerializer + + def account_id + object.account.id.to_s + end +end diff --git a/app/serializers/rest/preview_card_serializer.rb b/app/serializers/rest/preview_card_serializer.rb index 2df9d07a7..66ff47d22 100644 --- a/app/serializers/rest/preview_card_serializer.rb +++ b/app/serializers/rest/preview_card_serializer.rb @@ -6,7 +6,7 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer attributes :url, :title, :description, :type, :author_name, :author_url, :provider_name, :provider_url, :html, :width, :height, - :image, :embed_url + :image, :embed_url, :blurhash def image object.image? ? full_asset_url(object.image.url(:original)) : nil diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 7b4c53d50..f4276cece 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -76,6 +76,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || '' @account.followers_url = @json['followers'] || '' @account.featured_collection_url = @json['featured'] || '' + @account.devices_url = @json['devices'] || '' @account.url = url || @uri @account.uri = @uri @account.display_name = @json['name'] || '' diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 989fd6784..749c84736 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -22,7 +22,7 @@ class BackupService < BaseService account.statuses.with_includes.reorder(nil).find_in_batches do |statuses| statuses.each do |status| - item = serialize_payload(status, ActivityPub::ActivitySerializer, signer: @account, allow_local_only: true) + item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, signer: @account, allow_local_only: true) item.delete(:'@context') unless item[:type] == 'Announce' || item[:object][:attachment].blank? diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index 9f0860674..dc23ef8d8 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -26,59 +26,20 @@ class BlockDomainService < BaseService suspend_accounts! end - clear_media! if domain_block.reject_media? - end - - def invalidate_association_caches! - # Normally, associated models of a status are immutable (except for accounts) - # so they are aggressively cached. After updating the media attachments to no - # longer point to a local file, we need to clear the cache to make those - # changes appear in the API and UI - @affected_status_ids.each { |id| Rails.cache.delete_matched("statuses/#{id}-*") } + DomainClearMediaWorker.perform_async(domain_block.id) if domain_block.reject_media? end def silence_accounts! blocked_domain_accounts.without_silenced.in_batches.update_all(silenced_at: @domain_block.created_at) end - def clear_media! - @affected_status_ids = [] - - clear_account_images! - clear_account_attachments! - clear_emojos! - - invalidate_association_caches! - end - def suspend_accounts! - blocked_domain_accounts.without_suspended.reorder(nil).find_each do |account| + blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at) + blocked_domain_accounts.where(suspended_at: @domain_block.created_at).reorder(nil).find_each do |account| SuspendAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at) end end - def clear_account_images! - blocked_domain_accounts.reorder(nil).find_each do |account| - account.avatar.destroy if account.avatar.exists? - account.header.destroy if account.header.exists? - account.save - end - end - - def clear_account_attachments! - media_from_blocked_domain.reorder(nil).find_each do |attachment| - @affected_status_ids << attachment.status_id if attachment.status_id.present? - - attachment.file.destroy if attachment.file.exists? - attachment.type = :unknown - attachment.save - end - end - - def clear_emojos! - emojis_from_blocked_domains.destroy_all - end - def blocked_domain domain_block.domain end @@ -86,12 +47,4 @@ class BlockDomainService < BaseService def blocked_domain_accounts Account.by_domain_and_subdomains(blocked_domain) end - - def media_from_blocked_domain - MediaAttachment.joins(:account).merge(blocked_domain_accounts).reorder(nil) - end - - def emojis_from_blocked_domains - CustomEmoji.by_domain_and_subdomains(blocked_domain) - end end diff --git a/app/services/clear_domain_media_service.rb b/app/services/clear_domain_media_service.rb new file mode 100644 index 000000000..704cfb71a --- /dev/null +++ b/app/services/clear_domain_media_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class ClearDomainMediaService < BaseService + attr_reader :domain_block + + def call(domain_block) + @domain_block = domain_block + clear_media! if domain_block.reject_media? + end + + private + + def invalidate_association_caches! + # Normally, associated models of a status are immutable (except for accounts) + # so they are aggressively cached. After updating the media attachments to no + # longer point to a local file, we need to clear the cache to make those + # changes appear in the API and UI + @affected_status_ids.each { |id| Rails.cache.delete_matched("statuses/#{id}-*") } + end + + def clear_media! + @affected_status_ids = [] + + begin + clear_account_images! + clear_account_attachments! + clear_emojos! + ensure + invalidate_association_caches! + end + end + + def clear_account_images! + blocked_domain_accounts.reorder(nil).find_each do |account| + account.avatar.destroy if account.avatar&.exists? + account.header.destroy if account.header&.exists? + account.save + end + end + + def clear_account_attachments! + media_from_blocked_domain.reorder(nil).find_each do |attachment| + @affected_status_ids << attachment.status_id if attachment.status_id.present? + + attachment.file.destroy if attachment.file&.exists? + attachment.type = :unknown + attachment.save + end + end + + def clear_emojos! + emojis_from_blocked_domains.destroy_all + end + + def blocked_domain + domain_block.domain + end + + def blocked_domain_accounts + Account.by_domain_and_subdomains(blocked_domain) + end + + def media_from_blocked_domain + MediaAttachment.joins(:account).merge(blocked_domain_accounts).reorder(nil) + end + + def emojis_from_blocked_domains + CustomEmoji.by_domain_and_subdomains(blocked_domain) + end +end diff --git a/app/services/concerns/payloadable.rb b/app/services/concerns/payloadable.rb index 7f9f21c4b..3e45570c3 100644 --- a/app/services/concerns/payloadable.rb +++ b/app/services/concerns/payloadable.rb @@ -5,8 +5,9 @@ module Payloadable signer = options.delete(:signer) sign_with = options.delete(:sign_with) payload = ActiveModelSerializers::SerializableResource.new(record, options.merge(serializer: serializer, adapter: ActivityPub::Adapter)).as_json + object = record.respond_to?(:virtual_object) ? record.virtual_object : record - if (record.respond_to?(:sign?) && record.sign?) && signer && signing_enabled? + if (object.respond_to?(:sign?) && object.sign?) && signer && signing_enabled? ActivityPub::LinkedDataSignature.new(payload).sign!(signer, sign_with: sign_with) else payload diff --git a/app/services/deliver_to_device_service.rb b/app/services/deliver_to_device_service.rb new file mode 100644 index 000000000..71711945c --- /dev/null +++ b/app/services/deliver_to_device_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class DeliverToDeviceService < BaseService + include Payloadable + + class EncryptedMessage < ActiveModelSerializers::Model + attributes :source_account, :target_account, :source_device, + :target_device_id, :type, :body, :digest, + :message_franking + end + + def call(source_account, source_device, options = {}) + @source_account = source_account + @source_device = source_device + @target_account = Account.find(options[:account_id]) + @target_device_id = options[:device_id] + @body = options[:body] + @type = options[:type] + @hmac = options[:hmac] + + set_message_franking! + + if @target_account.local? + deliver_to_local! + else + deliver_to_remote! + end + end + + private + + def set_message_franking! + @message_franking = message_franking.to_token + end + + def deliver_to_local! + target_device = @target_account.devices.find_by!(device_id: @target_device_id) + + target_device.encrypted_messages.create!( + from_account: @source_account, + from_device_id: @source_device.device_id, + type: @type, + body: @body, + digest: @hmac, + message_franking: @message_franking + ) + end + + def deliver_to_remote! + ActivityPub::DeliveryWorker.perform_async( + Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_encrypted_message(encrypted_message), ActivityPub::ActivitySerializer)), + @source_account.id, + @target_account.inbox_url + ) + end + + def message_franking + MessageFranking.new( + source_account_id: @source_account.id, + target_account_id: @target_account.id, + hmac: @hmac, + timestamp: Time.now.utc + ) + end + + def encrypted_message + EncryptedMessage.new( + source_account: @source_account, + target_account: @target_account, + source_device: @source_device, + target_device_id: @target_device_id, + type: @type, + body: @body, + digest: @hmac, + message_franking: @message_franking + ) + end +end diff --git a/app/services/import_service.rb b/app/services/import_service.rb index c0d741d57..4cad93767 100644 --- a/app/services/import_service.rb +++ b/app/services/import_service.rb @@ -81,7 +81,9 @@ class ImportService < BaseService end end - Import::RelationshipWorker.push_bulk(items) do |acct, extra| + head_items = items.uniq { |acct, _| acct.split('@')[1] } + tail_items = items - head_items + Import::RelationshipWorker.push_bulk(head_items + tail_items) do |acct, extra| [@account.id, acct, action, extra] end end diff --git a/app/services/keys/claim_service.rb b/app/services/keys/claim_service.rb new file mode 100644 index 000000000..672119130 --- /dev/null +++ b/app/services/keys/claim_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class Keys::ClaimService < BaseService + HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze + + class Result < ActiveModelSerializers::Model + attributes :account, :device_id, :key_id, + :key, :signature + + def initialize(account, device_id, key_attributes = {}) + @account = account + @device_id = device_id + @key_id = key_attributes[:key_id] + @key = key_attributes[:key] + @signature = key_attributes[:signature] + end + end + + def call(source_account, target_account_id, device_id) + @source_account = source_account + @target_account = Account.find(target_account_id) + @device_id = device_id + + if @target_account.local? + claim_local_key! + else + claim_remote_key! + end + rescue ActiveRecord::RecordNotFound + nil + end + + private + + def claim_local_key! + device = @target_account.devices.find_by(device_id: @device_id) + key = nil + + ApplicationRecord.transaction do + key = device.one_time_keys.order(Arel.sql('random()')).first! + key.destroy! + end + + @result = Result.new(@target_account, @device_id, key) + end + + def claim_remote_key! + query_result = QueryService.new.call(@target_account) + device = query_result.find(@device_id) + + return unless device.present? && device.valid_claim_url? + + json = fetch_resource_with_post(device.claim_url) + + return unless json.present? && json['publicKeyBase64'].present? + + @result = Result.new(@target_account, @device_id, key_id: json['id'], key: json['publicKeyBase64'], signature: json.dig('signature', 'signatureValue')) + rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e + Rails.logger.debug "Claiming one-time key for #{@target_account.acct}:#{@device_id} failed: #{e}" + nil + end + + def fetch_resource_with_post(uri) + build_post_request(uri).perform do |response| + raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) + + body_to_json(response.body_with_limit) if response.code == 200 + end + end + + def build_post_request(uri) + Request.new(:post, uri).tap do |request| + request.on_behalf_of(@source_account, :uri) + request.add_headers(HEADERS) + end + end +end diff --git a/app/services/keys/query_service.rb b/app/services/keys/query_service.rb new file mode 100644 index 000000000..286fbd834 --- /dev/null +++ b/app/services/keys/query_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class Keys::QueryService < BaseService + include JsonLdHelper + + class Result < ActiveModelSerializers::Model + attributes :account, :devices + + def initialize(account, devices) + @account = account + @devices = devices || [] + end + + def find(device_id) + @devices.find { |device| device.device_id == device_id } + end + end + + class Device < ActiveModelSerializers::Model + attributes :device_id, :name, :identity_key, :fingerprint_key + + def initialize(attributes = {}) + @device_id = attributes[:device_id] + @name = attributes[:name] + @identity_key = attributes[:identity_key] + @fingerprint_key = attributes[:fingerprint_key] + @claim_url = attributes[:claim_url] + end + + def valid_claim_url? + return false if @claim_url.blank? + + begin + parsed_url = Addressable::URI.parse(@claim_url).normalize + rescue Addressable::URI::InvalidURIError + return false + end + + %w(http https).include?(parsed_url.scheme) && parsed_url.host.present? + end + end + + def call(account) + @account = account + + if @account.local? + query_local_devices! + else + query_remote_devices! + end + + Result.new(@account, @devices) + end + + private + + def query_local_devices! + @devices = @account.devices.map { |device| Device.new(device) } + end + + def query_remote_devices! + return if @account.devices_url.blank? + + json = fetch_resource(@account.devices_url) + + return if json['items'].blank? + + @devices = json['items'].map do |device| + Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim']) + end + rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e + Rails.logger.debug "Querying devices for #{@account.acct} failed: #{e}" + nil + end +end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 3c257451c..65a3f64b8 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -65,7 +65,7 @@ class ProcessMentionsService < BaseService def activitypub_json return @activitypub_json if defined?(@activitypub_json) - @activitypub_json = Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @status.account)) + @activitypub_json = Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account)) end def resolve_account_service diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 0a46509f8..6cecb5ac4 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -60,6 +60,6 @@ class ReblogService < BaseService end def build_json(reblog) - Oj.dump(serialize_payload(reblog, ActivityPub::ActivitySerializer, signer: reblog.account)) + Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog), ActivityPub::ActivitySerializer, signer: reblog.account)) end end diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index 17ace100c..ba77552c6 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -112,6 +112,8 @@ class ResolveAccountService < BaseService end def webfinger_update_due? + return false if @options[:check_delivery_availability] && !DeliveryFailureTracker.available?(@domain) + @account.nil? || ((!@options[:skip_webfinger] || @account.ostatus?) && @account.possibly_stale?) end diff --git a/app/validators/ed25519_key_validator.rb b/app/validators/ed25519_key_validator.rb new file mode 100644 index 000000000..00a448d5a --- /dev/null +++ b/app/validators/ed25519_key_validator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Ed25519KeyValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.blank? + + key = Base64.decode64(value) + + record.errors[attribute] << I18n.t('crypto.errors.invalid_key') unless verified?(key) + end + + private + + def verified?(key) + Ed25519.validate_key_bytes(key) + rescue ArgumentError + false + end +end diff --git a/app/validators/ed25519_signature_validator.rb b/app/validators/ed25519_signature_validator.rb new file mode 100644 index 000000000..77a21b837 --- /dev/null +++ b/app/validators/ed25519_signature_validator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Ed25519SignatureValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.blank? + + verify_key = Ed25519::VerifyKey.new(Base64.decode64(option_to_value(record, :verify_key))) + signature = Base64.decode64(value) + message = option_to_value(record, :message) + + record.errors[attribute] << I18n.t('crypto.errors.invalid_signature') unless verified?(verify_key, signature, message) + end + + private + + def verified?(verify_key, signature, message) + verify_key.verify(signature, message) + rescue Ed25519::VerifyError, ArgumentError + false + end + + def option_to_value(record, key) + if options[key].is_a?(Proc) + options[key].call(record) + else + record.public_send(options[key]) + end + end +end diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index 03be3f423..0a12ab8d6 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -55,12 +55,15 @@ %p= t('about.unavailable_content_html') - if (blocks = @blocks.select(&:reject_media?)) && !blocks.empty? + %h3= t('about.unavailable_content_description.rejecting_media_title') %p= t('about.unavailable_content_description.rejecting_media') = render partial: 'domain_blocks', locals: { domain_blocks: blocks } - if (blocks = @blocks.select(&:silence?)) && !blocks.empty? + %h3= t('about.unavailable_content_description.silenced_title') %p= t('about.unavailable_content_description.silenced') = render partial: 'domain_blocks', locals: { domain_blocks: blocks } - if (blocks = @blocks.select(&:suspend?)) && !blocks.empty? + %h3= t('about.unavailable_content_description.suspended_title') %p= t('about.unavailable_content_description.suspended') = render partial: 'domain_blocks', locals: { domain_blocks: blocks } diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/admin/custom_emojis/index.html.haml index d3705a36e..45cb7bee0 100644 --- a/app/views/admin/custom_emojis/index.html.haml +++ b/app/views/admin/custom_emojis/index.html.haml @@ -1,8 +1,9 @@ - content_for :page_title do = t('admin.custom_emojis.title') -- content_for :heading_actions do - = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button' +- if can?(:create, :custom_emoji) + - content_for :heading_actions do + = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button' .filters .filter-subset @@ -55,9 +56,10 @@ = f.button safe_join([fa_icon('power-off'), t('admin.custom_emojis.disable')]), name: :disable, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + - if can?(:destroy, :custom_emoji) + = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - - unless params[:local] == '1' + - if can?(:copy, :custom_emoji) && params[:local] != '1' = f.button safe_join([fa_icon('copy'), t('admin.custom_emojis.copy')]), name: :copy, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - if params[:local] == '1' diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml index bd67eb4fc..a73b8dc92 100644 --- a/app/views/admin/instances/index.html.haml +++ b/app/views/admin/instances/index.html.haml @@ -1,6 +1,12 @@ - content_for :page_title do = t('admin.instances.title') +- content_for :heading_actions do + - if whitelist_mode? + = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button' + - else + = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button' + .filters .filter-subset %strong= t('admin.instances.moderation.title') @@ -10,12 +16,6 @@ - unless whitelist_mode? %li= filter_link_to t('admin.instances.moderation.limited'), limited: '1' - %div.special-action-button - - if whitelist_mode? - = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button' - - else - = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button' - - unless whitelist_mode? = form_tag admin_instances_url, method: 'GET', class: 'simple_form' do .fields-group diff --git a/app/views/auth/sessions/sign_in_token.html.haml b/app/views/auth/sessions/sign_in_token.html.haml new file mode 100644 index 000000000..8923203cd --- /dev/null +++ b/app/views/auth/sessions/sign_in_token.html.haml @@ -0,0 +1,14 @@ +- content_for :page_title do + = t('auth.login') + += simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| + %p.hint.otp-hint= t('users.suspicious_sign_in_confirmation') + + .fields-group + = f.input :sign_in_token_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.sign_in_token_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.sign_in_token_attempt'), :autocomplete => 'off' }, autofocus: true + + .actions + = f.button :button, t('auth.login'), type: :submit + + - if Setting.site_contact_email.present? + %p.hint.subtle-hint= t('users.generic_access_help_html', email: mail_to(Setting.site_contact_email, nil)) diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index 33b81c748..8e409846a 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -39,7 +39,7 @@ = react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do = 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 + = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json .detailed-status__meta %data.dt-published{ value: status.created_at.to_time.iso8601 } diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index b7a2b7116..7a0262c9d 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -43,7 +43,7 @@ = react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do = 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 + = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json - if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id = link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do diff --git a/app/views/user_mailer/sign_in_token.html.haml b/app/views/user_mailer/sign_in_token.html.haml new file mode 100644 index 000000000..826b34e7c --- /dev/null +++ b/app/views/user_mailer/sign_in_token.html.haml @@ -0,0 +1,105 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td + = image_tag full_pack_url('media/images/mailer/icon_email.png'), alt: '' + + %h1= t 'user_mailer.sign_in_token.title' + %p.lead= t 'user_mailer.sign_in_token.explanation' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.content-start + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.input-cell + %table.input{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td= @resource.sign_in_token + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center + %p= t 'user_mailer.sign_in_token.details' + %tr + %td.column-cell.text-center + %p + %strong= "#{t('sessions.ip')}:" + = @remote_ip + %br/ + %strong= "#{t('sessions.browser')}:" + %span{ title: @user_agent }= t 'sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}") + %br/ + = l(@timestamp) + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center + %p= t 'user_mailer.sign_in_token.further_actions' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.button-cell + %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.button-primary + = link_to edit_user_registration_url do + %span= t 'settings.account_settings' diff --git a/app/views/user_mailer/sign_in_token.text.erb b/app/views/user_mailer/sign_in_token.text.erb new file mode 100644 index 000000000..2539ddaf6 --- /dev/null +++ b/app/views/user_mailer/sign_in_token.text.erb @@ -0,0 +1,17 @@ +<%= t 'user_mailer.sign_in_token.title' %> + +=== + +<%= t 'user_mailer.sign_in_token.explanation' %> + +=> <%= @resource.sign_in_token %> + +<%= t 'user_mailer.sign_in_token.details' %> + +<%= t('sessions.ip') %>: <%= @remote_ip %> +<%= t('sessions.browser') %>: <%= t('sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")) %> +<%= l(@timestamp) %> + +<%= t 'user_mailer.sign_in_token.further_actions' %> + +=> <%= edit_user_registration_url %> diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index 11b6a6111..e4997ba0e 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -43,7 +43,7 @@ class ActivityPub::DistributionWorker end def payload - @payload ||= Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @account)) + @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account)) end def relay! diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb index 1ff8a657e..d4d0148ac 100644 --- a/app/workers/activitypub/reply_distribution_worker.rb +++ b/app/workers/activitypub/reply_distribution_worker.rb @@ -29,6 +29,6 @@ class ActivityPub::ReplyDistributionWorker end def payload - @payload ||= Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @status.account)) + @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account)) end end diff --git a/app/workers/domain_block_worker.rb b/app/workers/domain_block_worker.rb index 35518d6b5..3c601cd83 100644 --- a/app/workers/domain_block_worker.rb +++ b/app/workers/domain_block_worker.rb @@ -4,8 +4,9 @@ class DomainBlockWorker include Sidekiq::Worker def perform(domain_block_id, update = false) - BlockDomainService.new.call(DomainBlock.find(domain_block_id), update) - rescue ActiveRecord::RecordNotFound - true + domain_block = DomainBlock.find_by(id: domain_block_id) + return true if domain_block.nil? + + BlockDomainService.new.call(domain_block, update) end end diff --git a/app/workers/domain_clear_media_worker.rb b/app/workers/domain_clear_media_worker.rb new file mode 100644 index 000000000..971934a08 --- /dev/null +++ b/app/workers/domain_clear_media_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class DomainClearMediaWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull' + + def perform(domain_block_id) + domain_block = DomainBlock.find_by(id: domain_block_id) + return true if domain_block.nil? + + ClearDomainMediaService.new.call(domain_block) + end +end diff --git a/app/workers/import/relationship_worker.rb b/app/workers/import/relationship_worker.rb index 616da6da9..4a455f3ae 100644 --- a/app/workers/import/relationship_worker.rb +++ b/app/workers/import/relationship_worker.rb @@ -7,7 +7,8 @@ class Import::RelationshipWorker def perform(account_id, target_account_uri, relationship, options = {}) from_account = Account.find(account_id) - target_account = ResolveAccountService.new.call(target_account_uri) + target_domain = domain(target_account_uri) + target_account = stoplight_wrap_request(target_domain) { ResolveAccountService.new.call(target_account_uri, { check_delivery_availability: true }) } options.symbolize_keys! return if target_account.nil? @@ -29,4 +30,22 @@ class Import::RelationshipWorker rescue ActiveRecord::RecordNotFound true end + + def domain(uri) + domain = uri.is_a?(Account) ? uri.domain : uri.split('@')[1] + TagManager.instance.local_domain?(domain) ? nil : TagManager.instance.normalize_domain(domain) + end + + def stoplight_wrap_request(domain, &block) + if domain.present? + Stoplight("source:#{domain}", &block) + .with_fallback { nil } + .with_threshold(1) + .with_cool_off_time(5.minutes.seconds) + .with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) } + .run + else + block.call + end + end end diff --git a/app/workers/push_conversation_worker.rb b/app/workers/push_conversation_worker.rb index 16f538215..aa858f715 100644 --- a/app/workers/push_conversation_worker.rb +++ b/app/workers/push_conversation_worker.rb @@ -2,13 +2,14 @@ class PushConversationWorker include Sidekiq::Worker + include Redisable def perform(conversation_account_id) conversation = AccountConversation.find(conversation_account_id) message = InlineRenderer.render(conversation, conversation.account, :conversation) timeline_id = "timeline:direct:#{conversation.account_id}" - Redis.current.publish(timeline_id, Oj.dump(event: :conversation, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i)) + redis.publish(timeline_id, Oj.dump(event: :conversation, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i)) rescue ActiveRecord::RecordNotFound true end diff --git a/app/workers/push_encrypted_message_worker.rb b/app/workers/push_encrypted_message_worker.rb new file mode 100644 index 000000000..031230172 --- /dev/null +++ b/app/workers/push_encrypted_message_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class PushEncryptedMessageWorker + include Sidekiq::Worker + include Redisable + + def perform(encrypted_message_id) + encrypted_message = EncryptedMessage.find(encrypted_message_id) + message = InlineRenderer.render(encrypted_message, nil, :encrypted_message) + timeline_id = "timeline:#{encrypted_message.device.account_id}:#{encrypted_message.device.device_id}" + + redis.publish(timeline_id, Oj.dump(event: :encrypted_message, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i)) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb b/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb index 94788a85b..bb9dd49ca 100644 --- a/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb +++ b/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb @@ -8,5 +8,6 @@ class Scheduler::DoorkeeperCleanupScheduler def perform Doorkeeper::AccessToken.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all Doorkeeper::AccessGrant.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all + SystemKey.expired.delete_all end end |