Merge tag 'v3.2.0' into hometown-dev
This commit is contained in:
@ -31,9 +31,9 @@ class StatusesIndex < Chewy::Index
|
||||
},
|
||||
}
|
||||
|
||||
define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments), delete_if: ->(status) { status.searchable_by.empty? } do
|
||||
define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll) do
|
||||
crutch :mentions do |collection|
|
||||
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
|
||||
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
|
||||
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||
end
|
||||
|
||||
|
@ -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
|
||||
@ -10,7 +11,7 @@ class AccountsController < ApplicationController
|
||||
before_action :set_body_classes
|
||||
|
||||
skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) }
|
||||
skip_before_action :require_functional!
|
||||
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||
|
||||
def show
|
||||
respond_to do |format|
|
||||
@ -44,7 +45,8 @@ class AccountsController < ApplicationController
|
||||
format.rss do
|
||||
expires_in 1.minute, public: true
|
||||
|
||||
@statuses = filtered_statuses.without_reblogs.without_local_only.limit(PAGE_SIZE)
|
||||
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
|
||||
@statuses = filtered_statuses.without_reblogs.without_local_only.limit(limit)
|
||||
@statuses = cache_collection(@statuses, Status)
|
||||
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
|
||||
end
|
||||
|
21
app/controllers/activitypub/claims_controller.rb
Normal file
21
app/controllers/activitypub/claims_controller.rb
Normal file
@ -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
|
@ -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)
|
||||
end
|
||||
|
||||
def set_size
|
||||
def set_items
|
||||
case params[:id]
|
||||
when 'featured'
|
||||
@size = @account.pinned_statuses.count
|
||||
@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, Status)
|
||||
end
|
||||
end
|
||||
when 'devices'
|
||||
@items = @account.devices
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
def scope_for_collection
|
||||
def set_size
|
||||
case params[:id]
|
||||
when 'featured', 'devices'
|
||||
@size = @items.size
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
@ -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
|
||||
|
@ -7,7 +7,7 @@ class Api::BaseController < ApplicationController
|
||||
include RateLimitHeaders
|
||||
|
||||
skip_before_action :store_current_location
|
||||
skip_before_action :require_functional!
|
||||
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||
|
||||
before_action :require_authenticated_user!, if: :disallow_unauthenticated_api_access?
|
||||
before_action :set_cache_headers
|
||||
|
30
app/controllers/api/v1/accounts/notes_controller.rb
Normal file
30
app/controllers/api/v1/accounts/notes_controller.rb
Normal file
@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::NotesController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
|
||||
before_action :require_user!
|
||||
before_action :set_account
|
||||
|
||||
def create
|
||||
if params[:comment].blank?
|
||||
AccountNote.find_by(account: current_account, target_account: @account)&.destroy
|
||||
else
|
||||
@note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account)
|
||||
@note.comment = params[:comment]
|
||||
@note.save! if @note.changed?
|
||||
end
|
||||
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def relationships_presenter
|
||||
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
|
||||
end
|
||||
end
|
30
app/controllers/api/v1/crypto/deliveries_controller.rb
Normal file
30
app/controllers/api/v1/crypto/deliveries_controller.rb
Normal file
@ -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
|
@ -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
|
25
app/controllers/api/v1/crypto/keys/claims_controller.rb
Normal file
25
app/controllers/api/v1/crypto/keys/claims_controller.rb
Normal file
@ -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
|
17
app/controllers/api/v1/crypto/keys/counts_controller.rb
Normal file
17
app/controllers/api/v1/crypto/keys/counts_controller.rb
Normal file
@ -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
|
26
app/controllers/api/v1/crypto/keys/queries_controller.rb
Normal file
26
app/controllers/api/v1/crypto/keys/queries_controller.rb
Normal file
@ -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
|
29
app/controllers/api/v1/crypto/keys/uploads_controller.rb
Normal file
29
app/controllers/api/v1/crypto/keys/uploads_controller.rb
Normal file
@ -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
|
@ -39,7 +39,7 @@ class Api::V1::MediaController < Api::BaseController
|
||||
end
|
||||
|
||||
def media_attachment_params
|
||||
params.permit(:file, :description, :focus)
|
||||
params.permit(:file, :thumbnail, :description, :focus)
|
||||
end
|
||||
|
||||
def file_type_error
|
||||
|
@ -5,7 +5,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
|
||||
before_action :require_user!
|
||||
before_action :set_reblog
|
||||
before_action :set_reblog, only: [:create]
|
||||
|
||||
override_rate_limit_headers :create, family: :statuses
|
||||
|
||||
@ -16,15 +16,21 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@status = current_account.statuses.find_by(reblog_of_id: @reblog.id)
|
||||
@status = current_account.statuses.find_by(reblog_of_id: params[:status_id])
|
||||
|
||||
if @status
|
||||
authorize @status, :unreblog?
|
||||
@status.discard
|
||||
RemovalWorker.perform_async(@status.id)
|
||||
@reblog = @status.reblog
|
||||
else
|
||||
@reblog = Status.find(params[:status_id])
|
||||
authorize @reblog, :show?
|
||||
end
|
||||
|
||||
render json: @reblog, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false })
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -58,6 +58,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
|
||||
@status.discard
|
||||
RemovalWorker.perform_async(@status.id, redraft: true)
|
||||
@status.account.statuses_count = @status.account.statuses_count - 1
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, source_requested: true
|
||||
end
|
||||
|
@ -55,7 +55,7 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def store_current_location
|
||||
store_location_for(:user, request.url) unless request.format == :json
|
||||
store_location_for(:user, request.url) unless [:json, :rss].include?(request.format&.to_sym)
|
||||
end
|
||||
|
||||
def require_admin!
|
||||
|
@ -8,7 +8,8 @@ class Auth::SessionsController < Devise::SessionsController
|
||||
skip_before_action :require_no_authentication, only: [:create]
|
||||
skip_before_action :require_functional!
|
||||
|
||||
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
|
||||
@ -39,17 +40,18 @@ 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
|
||||
user ||= User.find_for_authentication(email: user_params[:email])
|
||||
user
|
||||
end
|
||||
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)
|
||||
@ -70,47 +72,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
|
||||
@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
|
||||
|
@ -7,8 +7,6 @@ module Localized
|
||||
around_action :set_locale
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_locale
|
||||
locale = current_user.locale if respond_to?(:user_signed_in?) && user_signed_in?
|
||||
locale ||= session[:locale] ||= default_locale
|
||||
@ -19,6 +17,8 @@ module Localized
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_locale
|
||||
if ENV['DEFAULT_LOCALE'].present?
|
||||
I18n.default_locale
|
||||
|
@ -0,0 +1,51 @@
|
||||
# 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
|
||||
|
||||
set_locale do
|
||||
session[:attempt_user_id] = user.id
|
||||
@body_classes = 'lighter'
|
||||
render :sign_in_token
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,49 @@
|
||||
# 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)
|
||||
set_locale do
|
||||
session[:attempt_user_id] = user.id
|
||||
@body_classes = 'lighter'
|
||||
render :two_factor
|
||||
end
|
||||
end
|
||||
end
|
@ -9,7 +9,7 @@ class DirectoriesController < ApplicationController
|
||||
before_action :set_tag, only: :show
|
||||
before_action :set_accounts
|
||||
|
||||
skip_before_action :require_functional!
|
||||
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||
|
||||
def index
|
||||
render :index
|
||||
|
@ -8,7 +8,7 @@ class FollowerAccountsController < ApplicationController
|
||||
before_action :set_cache_headers
|
||||
|
||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||
skip_before_action :require_functional!
|
||||
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||
|
||||
def index
|
||||
respond_to do |format|
|
||||
|
@ -8,7 +8,7 @@ class FollowingAccountsController < ApplicationController
|
||||
before_action :set_cache_headers
|
||||
|
||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||
skip_before_action :require_functional!
|
||||
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||
|
||||
def index
|
||||
respond_to do |format|
|
||||
|
@ -4,7 +4,7 @@ class MediaController < ApplicationController
|
||||
include Authorization
|
||||
|
||||
skip_before_action :store_current_location
|
||||
skip_before_action :require_functional!
|
||||
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||
|
||||
before_action :authenticate_user!, if: :whitelist_mode?
|
||||
before_action :set_media_attachment
|
||||
|
@ -31,8 +31,8 @@ class MediaProxyController < ApplicationController
|
||||
private
|
||||
|
||||
def redownload!
|
||||
@media_attachment.file_remote_url = @media_attachment.remote_url
|
||||
@media_attachment.created_at = Time.now.utc
|
||||
@media_attachment.download_file!
|
||||
@media_attachment.created_at = Time.now.utc
|
||||
@media_attachment.save!
|
||||
end
|
||||
|
||||
|
@ -10,7 +10,7 @@ class RemoteInteractionController < ApplicationController
|
||||
before_action :set_status
|
||||
before_action :set_body_classes
|
||||
|
||||
skip_before_action :require_functional!
|
||||
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||
|
||||
def new
|
||||
@remote_follow = RemoteFollow.new(session_params)
|
||||
|
@ -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
|
||||
|
@ -7,13 +7,8 @@ module Settings
|
||||
before_action :set_picture
|
||||
|
||||
def destroy
|
||||
if valid_picture
|
||||
account_params = {
|
||||
@picture => nil,
|
||||
(@picture + '_remote_url') => nil,
|
||||
}
|
||||
|
||||
msg = UpdateAccountService.new.call(@account, account_params) ? I18n.t('generic.changes_saved_msg') : nil
|
||||
if valid_picture?
|
||||
msg = I18n.t('generic.changes_saved_msg') if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' })
|
||||
redirect_to settings_profile_path, notice: msg, status: 303
|
||||
else
|
||||
bad_request
|
||||
@ -30,8 +25,8 @@ module Settings
|
||||
@picture = params[:id]
|
||||
end
|
||||
|
||||
def valid_picture
|
||||
@picture == 'avatar' || @picture == 'header'
|
||||
def valid_picture?
|
||||
%w(avatar header).include?(@picture)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -19,7 +19,7 @@ class StatusesController < ApplicationController
|
||||
before_action :set_autoplay, only: :embed
|
||||
|
||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||
skip_before_action :require_functional!, only: [:show, :embed]
|
||||
skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
|
||||
|
||||
content_security_policy only: :embed do |p|
|
||||
p.frame_ancestors(false)
|
||||
@ -42,7 +42,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
|
||||
|
@ -3,7 +3,8 @@
|
||||
class TagsController < ApplicationController
|
||||
include SignatureVerification
|
||||
|
||||
PAGE_SIZE = 20
|
||||
PAGE_SIZE = 20
|
||||
PAGE_SIZE_MAX = 200
|
||||
|
||||
layout 'public'
|
||||
|
||||
@ -14,7 +15,7 @@ class TagsController < ApplicationController
|
||||
before_action :set_body_classes
|
||||
before_action :set_instance_presenter
|
||||
|
||||
skip_before_action :require_functional!
|
||||
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||
|
||||
def show
|
||||
respond_to do |format|
|
||||
@ -25,7 +26,8 @@ class TagsController < ApplicationController
|
||||
format.rss do
|
||||
expires_in 0, public: true
|
||||
|
||||
@statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(PAGE_SIZE)
|
||||
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
|
||||
@statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(limit)
|
||||
@statuses = cache_collection(@statuses, Status)
|
||||
|
||||
render xml: RSS::TagSerializer.render(@tag, @statuses)
|
||||
|
@ -8,7 +8,8 @@ module WellKnown
|
||||
before_action :set_account
|
||||
before_action :check_account_suspension
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound, ActionController::ParameterMissing, with: :not_found
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||
rescue_from ActionController::ParameterMissing, WebfingerResource::InvalidRequest, with: :bad_request
|
||||
|
||||
def show
|
||||
expires_in 3.days, public: true
|
||||
@ -37,6 +38,10 @@ module WellKnown
|
||||
expires_in(3.minutes, public: true) && gone if @account.suspended?
|
||||
end
|
||||
|
||||
def bad_request
|
||||
head 400
|
||||
end
|
||||
|
||||
def not_found
|
||||
head 404
|
||||
end
|
||||
|
@ -77,6 +77,18 @@ module ApplicationHelper
|
||||
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
|
||||
end
|
||||
|
||||
def visibility_icon(status)
|
||||
if status.public_visibility?
|
||||
fa_icon('globe', title: I18n.t('statuses.visibilities.public'))
|
||||
elsif status.unlisted_visibility?
|
||||
fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted'))
|
||||
elsif status.private_visibility? || status.limited_visibility?
|
||||
fa_icon('lock', title: I18n.t('statuses.visibilities.private'))
|
||||
elsif status.direct_visibility?
|
||||
fa_icon('envelope', title: I18n.t('statuses.visibilities.direct'))
|
||||
end
|
||||
end
|
||||
|
||||
def custom_emoji_tag(custom_emoji, animate = true)
|
||||
if animate
|
||||
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
|
||||
@ -136,6 +148,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)
|
||||
|
@ -15,11 +15,13 @@ module StatusesHelper
|
||||
end
|
||||
|
||||
def media_summary(status)
|
||||
attachments = { image: 0, video: 0 }
|
||||
attachments = { image: 0, video: 0, audio: 0 }
|
||||
|
||||
status.media_attachments.each do |media|
|
||||
if media.video?
|
||||
attachments[:video] += 1
|
||||
elsif media.audio?
|
||||
attachments[:audio] += 1
|
||||
else
|
||||
attachments[:image] += 1
|
||||
end
|
||||
|
@ -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
|
||||
|
37
app/javascript/mastodon/actions/account_notes.js
Normal file
37
app/javascript/mastodon/actions/account_notes.js
Normal file
@ -0,0 +1,37 @@
|
||||
import api from '../api';
|
||||
|
||||
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
|
||||
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
|
||||
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
|
||||
|
||||
export function submitAccountNote(id, value) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(submitAccountNoteRequest());
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/note`, {
|
||||
comment: value,
|
||||
}).then(response => {
|
||||
dispatch(submitAccountNoteSuccess(response.data));
|
||||
}).catch(error => dispatch(submitAccountNoteFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteRequest() {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
relationship,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
@ -28,6 +28,11 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||
|
||||
export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST';
|
||||
export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS';
|
||||
export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL';
|
||||
export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
|
||||
|
||||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
|
||||
@ -160,7 +165,6 @@ export function submitCompose(routerHistory) {
|
||||
|
||||
// To make the app more responsive, immediately push the status
|
||||
// into the columns
|
||||
|
||||
const insertIfOnline = timelineId => {
|
||||
const timeline = getState().getIn(['timelines', timelineId]);
|
||||
|
||||
@ -176,6 +180,7 @@ export function submitCompose(routerHistory) {
|
||||
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
|
||||
insertIfOnline('community');
|
||||
insertIfOnline('public');
|
||||
insertIfOnline(`account:${response.data.account.id}`);
|
||||
}
|
||||
}).catch(function (error) {
|
||||
dispatch(submitComposeFail(error));
|
||||
@ -262,6 +267,49 @@ export function uploadCompose(files) {
|
||||
};
|
||||
};
|
||||
|
||||
export const uploadThumbnail = (id, file) => (dispatch, getState) => {
|
||||
dispatch(uploadThumbnailRequest());
|
||||
|
||||
const total = file.size;
|
||||
const data = new FormData();
|
||||
|
||||
data.append('thumbnail', file);
|
||||
|
||||
api(getState).put(`/api/v1/media/${id}`, data, {
|
||||
onUploadProgress: ({ loaded }) => {
|
||||
dispatch(uploadThumbnailProgress(loaded, total));
|
||||
},
|
||||
}).then(({ data }) => {
|
||||
dispatch(uploadThumbnailSuccess(data));
|
||||
}).catch(error => {
|
||||
dispatch(uploadThumbnailFail(id, error));
|
||||
});
|
||||
};
|
||||
|
||||
export const uploadThumbnailRequest = () => ({
|
||||
type: THUMBNAIL_UPLOAD_REQUEST,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const uploadThumbnailProgress = (loaded, total) => ({
|
||||
type: THUMBNAIL_UPLOAD_PROGRESS,
|
||||
loaded,
|
||||
total,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const uploadThumbnailSuccess = media => ({
|
||||
type: THUMBNAIL_UPLOAD_SUCCESS,
|
||||
media,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const uploadThumbnailFail = error => ({
|
||||
type: THUMBNAIL_UPLOAD_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export function changeUploadCompose(id, params) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(changeUploadComposeRequest());
|
||||
@ -280,6 +328,7 @@ export function changeUploadComposeRequest() {
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeUploadComposeSuccess(media) {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||
|
@ -1,8 +1,8 @@
|
||||
export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
|
||||
export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
|
||||
|
||||
export function openDropdownMenu(id, placement, keyboard) {
|
||||
return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard };
|
||||
export function openDropdownMenu(id, placement, keyboard, scroll_key) {
|
||||
return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard, scroll_key };
|
||||
}
|
||||
|
||||
export function closeDropdownMenu(id) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -1,30 +1,102 @@
|
||||
export const submitMarkers = () => (dispatch, getState) => {
|
||||
import api from '../api';
|
||||
import { debounce } from 'lodash';
|
||||
import compareId from '../compare_id';
|
||||
import { showAlertForError } from './alerts';
|
||||
|
||||
export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
|
||||
|
||||
export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
|
||||
const accessToken = getState().getIn(['meta', 'access_token'], '');
|
||||
const params = {};
|
||||
|
||||
const lastHomeId = getState().getIn(['timelines', 'home', 'items', 0]);
|
||||
const lastNotificationId = getState().getIn(['notifications', 'items', 0, 'id']);
|
||||
|
||||
if (lastHomeId) {
|
||||
params.home = {
|
||||
last_read_id: lastHomeId,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastNotificationId) {
|
||||
params.notifications = {
|
||||
last_read_id: lastNotificationId,
|
||||
};
|
||||
}
|
||||
const params = _buildParams(getState());
|
||||
|
||||
if (Object.keys(params).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new XMLHttpRequest();
|
||||
// The Fetch API allows us to perform requests that will be carried out
|
||||
// after the page closes. But that only works if the `keepalive` attribute
|
||||
// is supported.
|
||||
if (window.fetch && 'keepalive' in new Request('')) {
|
||||
fetch('/api/v1/markers', {
|
||||
keepalive: true,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return;
|
||||
} else if (navigator && navigator.sendBeacon) {
|
||||
// Failing that, we can use sendBeacon, but we have to encode the data as
|
||||
// FormData for DoorKeeper to recognize the token.
|
||||
const formData = new FormData();
|
||||
formData.append('bearer_token', accessToken);
|
||||
for (const [id, value] of Object.entries(params)) {
|
||||
formData.append(`${id}[last_read_id]`, value.last_read_id);
|
||||
}
|
||||
if (navigator.sendBeacon('/api/v1/markers', formData)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
client.open('POST', '/api/v1/markers', false);
|
||||
client.setRequestHeader('Content-Type', 'application/json');
|
||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||
client.send(JSON.stringify(params));
|
||||
// If neither Fetch nor sendBeacon worked, try to perform a synchronous
|
||||
// request.
|
||||
try {
|
||||
const client = new XMLHttpRequest();
|
||||
|
||||
client.open('POST', '/api/v1/markers', false);
|
||||
client.setRequestHeader('Content-Type', 'application/json');
|
||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||
client.SUBMIT(JSON.stringify(params));
|
||||
} catch (e) {
|
||||
// Do not make the BeforeUnload handler error out
|
||||
}
|
||||
};
|
||||
|
||||
const _buildParams = (state) => {
|
||||
const params = {};
|
||||
|
||||
const lastHomeId = state.getIn(['timelines', 'home', 'items', 0]);
|
||||
const lastNotificationId = state.getIn(['notifications', 'items', 0, 'id']);
|
||||
|
||||
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
|
||||
params.home = {
|
||||
last_read_id: lastHomeId,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastNotificationId && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) {
|
||||
params.notifications = {
|
||||
last_read_id: lastNotificationId,
|
||||
};
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const debouncedSubmitMarkers = debounce((dispatch, getState) => {
|
||||
const params = _buildParams(getState());
|
||||
|
||||
if (Object.keys(params).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
api().post('/api/v1/markers', params).then(() => {
|
||||
dispatch(submitMarkersSuccess(params));
|
||||
}).catch(error => {
|
||||
dispatch(showAlertForError(error));
|
||||
});
|
||||
}, 300000, { leading: true, trailing: true });
|
||||
|
||||
export function submitMarkersSuccess({ home, notifications }) {
|
||||
return {
|
||||
type: MARKERS_SUBMIT_SUCCESS,
|
||||
home: (home || {}).last_read_id,
|
||||
notifications: (notifications || {}).last_read_id,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitMarkers() {
|
||||
return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
|
||||
};
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
importFetchedStatus,
|
||||
importFetchedStatuses,
|
||||
} from './importer';
|
||||
import { submitMarkers } from './markers';
|
||||
import { saveSettings } from './settings';
|
||||
import { defineMessages } from 'react-intl';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
@ -70,6 +71,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
filtered = regex && regex.test(searchIndex);
|
||||
}
|
||||
|
||||
dispatch(submitMarkers());
|
||||
|
||||
if (showInColumn) {
|
||||
dispatch(importFetchedAccount(notification.account));
|
||||
|
||||
@ -157,6 +160,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
|
||||
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
dispatch(submitMarkers());
|
||||
}).catch(error => {
|
||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
||||
}).finally(() => {
|
||||
|
@ -3,7 +3,7 @@ import openDB from '../storage/db';
|
||||
import { evictStatus } from '../storage/modifier';
|
||||
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer';
|
||||
import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus, importFetchedAccount } from './importer';
|
||||
import { ensureComposeIsVisible } from './compose';
|
||||
|
||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||
@ -155,6 +155,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
|
||||
evictStatus(id);
|
||||
dispatch(deleteStatusSuccess(id));
|
||||
dispatch(deleteFromTimelines(id));
|
||||
dispatch(importFetchedAccount(response.data.account));
|
||||
|
||||
if (withRedraft) {
|
||||
dispatch(redraft(status, response.data.text));
|
||||
|
@ -74,6 +74,6 @@ const refreshHomeTimelineAndNotification = (dispatch, done) => {
|
||||
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
||||
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
|
||||
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
|
||||
export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
|
||||
export const connectHashtagStream = (id, tag, local, accept) => connectTimelineStream(`hashtag:${id}${local ? ':local' : ''}`, `hashtag${local ? ':local' : ''}&tag=${tag}`, null, accept);
|
||||
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
|
||||
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import { submitMarkers } from './markers';
|
||||
import api, { getLinks } from 'mastodon/api';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import compareId from 'mastodon/compare_id';
|
||||
@ -36,6 +37,10 @@ export function updateTimeline(timeline, status, accept) {
|
||||
status,
|
||||
usePendingItems: preferPendingItems,
|
||||
});
|
||||
|
||||
if (timeline === 'home') {
|
||||
dispatch(submitMarkers());
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@ -98,6 +103,10 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||
|
||||
if (timelineId === 'home') {
|
||||
dispatch(submitMarkers());
|
||||
}
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||
}).finally(() => {
|
||||
@ -114,7 +123,7 @@ export const expandAccountFeaturedTimeline = accountId => expandTimeline(`accoun
|
||||
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
||||
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
||||
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
|
||||
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||
max_id: maxId,
|
||||
any: parseTags(tags, 'any'),
|
||||
all: parseTags(tags, 'all'),
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { shallow } from 'enzyme';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import Button from '../button';
|
||||
@ -21,16 +21,16 @@ describe('<Button />', () => {
|
||||
|
||||
it('handles click events using the given handler', () => {
|
||||
const handler = jest.fn();
|
||||
const button = shallow(<Button onClick={handler} />);
|
||||
button.find('button').simulate('click');
|
||||
render(<Button onClick={handler}>button</Button>);
|
||||
fireEvent.click(screen.getByText('button'));
|
||||
|
||||
expect(handler.mock.calls.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('does not handle click events if props.disabled given', () => {
|
||||
const handler = jest.fn();
|
||||
const button = shallow(<Button onClick={handler} disabled />);
|
||||
button.find('button').simulate('click');
|
||||
render(<Button onClick={handler} disabled>button</Button>);
|
||||
fireEvent.click(screen.getByText('button'));
|
||||
|
||||
expect(handler.mock.calls.length).toEqual(0);
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
||||
import ShortNumber from 'mastodon/components/short_number';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class AutosuggestHashtag extends React.PureComponent {
|
||||
@ -13,14 +13,28 @@ export default class AutosuggestHashtag extends React.PureComponent {
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
render() {
|
||||
const { tag } = this.props;
|
||||
const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
|
||||
const weeklyUses = tag.history && (
|
||||
<ShortNumber
|
||||
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='autosuggest-hashtag'>
|
||||
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
|
||||
{tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>}
|
||||
<div className='autosuggest-hashtag__name'>
|
||||
#<strong>{tag.name}</strong>
|
||||
</div>
|
||||
{tag.history !== undefined && (
|
||||
<div className='autosuggest-hashtag__uses'>
|
||||
<FormattedMessage
|
||||
id='autosuggest_hashtag.per_week'
|
||||
defaultMessage='{count} per week'
|
||||
values={{ count: weeklyUses }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
|
65
app/javascript/mastodon/components/blurhash.js
Normal file
65
app/javascript/mastodon/components/blurhash.js
Normal file
@ -0,0 +1,65 @@
|
||||
// @ts-check
|
||||
|
||||
import { decode } from 'blurhash';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* @typedef BlurhashPropsBase
|
||||
* @property {string?} hash Hash to render
|
||||
* @property {number} width
|
||||
* Width of the blurred region in pixels. Defaults to 32
|
||||
* @property {number} [height]
|
||||
* Height of the blurred region in pixels. Defaults to width
|
||||
* @property {boolean} [dummy]
|
||||
* Whether dummy mode is enabled. If enabled, nothing is rendered
|
||||
* and canvas left untouched
|
||||
*/
|
||||
|
||||
/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
|
||||
|
||||
/**
|
||||
* Component that is used to render blurred of blurhash string
|
||||
*
|
||||
* @param {BlurhashProps} param1 Props of the component
|
||||
* @returns Canvas which will render blurred region element to embed
|
||||
*/
|
||||
function Blurhash({
|
||||
hash,
|
||||
width = 32,
|
||||
height = width,
|
||||
dummy = false,
|
||||
...canvasProps
|
||||
}) {
|
||||
const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef());
|
||||
|
||||
useEffect(() => {
|
||||
const { current: canvas } = canvasRef;
|
||||
canvas.width = canvas.width; // resets canvas
|
||||
|
||||
if (dummy || !hash) return;
|
||||
|
||||
try {
|
||||
const pixels = decode(hash, width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imageData = new ImageData(pixels, width, height);
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
} catch (err) {
|
||||
console.error('Blurhash decoding failure', { err, hash });
|
||||
}
|
||||
}, [dummy, hash, width, height]);
|
||||
|
||||
return (
|
||||
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
|
||||
);
|
||||
}
|
||||
|
||||
Blurhash.propTypes = {
|
||||
hash: PropTypes.string.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
dummy: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default React.memo(Blurhash);
|
62
app/javascript/mastodon/components/common_counter.js
Normal file
62
app/javascript/mastodon/components/common_counter.js
Normal file
@ -0,0 +1,62 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
/**
|
||||
* Returns custom renderer for one of the common counter types
|
||||
*
|
||||
* @param {"statuses" | "following" | "followers"} counterType
|
||||
* Type of the counter
|
||||
* @param {boolean} isBold Whether display number must be displayed in bold
|
||||
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
|
||||
* Renderer function
|
||||
* @throws If counterType is not covered by this function
|
||||
*/
|
||||
export function counterRenderer(counterType, isBold = true) {
|
||||
/**
|
||||
* @type {(displayNumber: JSX.Element) => JSX.Element}
|
||||
*/
|
||||
const renderCounter = isBold
|
||||
? (displayNumber) => <strong>{displayNumber}</strong>
|
||||
: (displayNumber) => displayNumber;
|
||||
|
||||
switch (counterType) {
|
||||
case 'statuses': {
|
||||
return (displayNumber, pluralReady) => (
|
||||
<FormattedMessage
|
||||
id='account.statuses_counter'
|
||||
defaultMessage='{count, plural, one {{counter} Toot} other {{counter} Toots}}'
|
||||
values={{
|
||||
count: pluralReady,
|
||||
counter: renderCounter(displayNumber),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'following': {
|
||||
return (displayNumber, pluralReady) => (
|
||||
<FormattedMessage
|
||||
id='account.following_counter'
|
||||
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
|
||||
values={{
|
||||
count: pluralReady,
|
||||
counter: renderCounter(displayNumber),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'followers': {
|
||||
return (displayNumber, pluralReady) => (
|
||||
<FormattedMessage
|
||||
id='account.followers_counter'
|
||||
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
|
||||
values={{
|
||||
count: pluralReady,
|
||||
counter: renderCounter(displayNumber),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
|
||||
}
|
||||
}
|
@ -1,26 +1,65 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Permalink from './permalink';
|
||||
import { shortNumberFormat } from '../utils/numbers';
|
||||
import ShortNumber from 'mastodon/components/short_number';
|
||||
|
||||
/**
|
||||
* Used to render counter of how much people are talking about hashtag
|
||||
*
|
||||
* @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
|
||||
*/
|
||||
const accountsCountRenderer = (displayNumber, pluralReady) => (
|
||||
<FormattedMessage
|
||||
id='trends.counter_by_accounts'
|
||||
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking'
|
||||
values={{
|
||||
count: pluralReady,
|
||||
counter: <strong>{displayNumber}</strong>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const Hashtag = ({ hashtag }) => (
|
||||
<div className='trends__item'>
|
||||
<div className='trends__item__name'>
|
||||
<Permalink href={hashtag.get('url')} to={`/timelines/tag/${hashtag.get('name')}`}>
|
||||
<Permalink
|
||||
href={hashtag.get('url')}
|
||||
to={`/timelines/tag/${hashtag.get('name')}`}
|
||||
>
|
||||
#<span>{hashtag.get('name')}</span>
|
||||
</Permalink>
|
||||
|
||||
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} />
|
||||
<ShortNumber
|
||||
value={
|
||||
hashtag.getIn(['history', 0, 'accounts']) * 1 +
|
||||
hashtag.getIn(['history', 1, 'accounts']) * 1
|
||||
}
|
||||
renderer={accountsCountRenderer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='trends__item__current'>
|
||||
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)}
|
||||
<ShortNumber
|
||||
value={
|
||||
hashtag.getIn(['history', 0, 'uses']) * 1 +
|
||||
hashtag.getIn(['history', 1, 'uses']) * 1
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='trends__item__sparkline'>
|
||||
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
|
||||
<Sparklines
|
||||
width={50}
|
||||
height={28}
|
||||
data={hashtag
|
||||
.get('history')
|
||||
.reverse()
|
||||
.map((day) => day.get('uses'))
|
||||
.toArray()}
|
||||
>
|
||||
<SparklinesCurve style={{ fill: 'none' }} />
|
||||
</Sparklines>
|
||||
</div>
|
||||
|
@ -7,10 +7,11 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { isIOS } from '../is_mobile';
|
||||
import classNames from 'classnames';
|
||||
import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state';
|
||||
import { decode } from 'blurhash';
|
||||
import { debounce } from 'lodash';
|
||||
import Blurhash from 'mastodon/components/blurhash';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide media' },
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide {number, plural, one {image} other {images}}' },
|
||||
});
|
||||
|
||||
class Item extends React.PureComponent {
|
||||
@ -73,36 +74,6 @@ class Item extends React.PureComponent {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
if (this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
_decode () {
|
||||
if (!useBlurhash) return;
|
||||
|
||||
const hash = this.props.attachment.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);
|
||||
}
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
}
|
||||
|
||||
handleImageLoad = () => {
|
||||
this.setState({ loaded: true });
|
||||
}
|
||||
@ -165,7 +136,11 @@ class Item extends React.PureComponent {
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} target='_blank' rel='noopener noreferrer'>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
||||
<Blurhash
|
||||
hash={attachment.get('blurhash')}
|
||||
className='media-gallery__preview'
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
@ -231,7 +206,13 @@ class Item extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
|
||||
<Blurhash
|
||||
hash={attachment.get('blurhash')}
|
||||
dummy={!useBlurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': visible && this.state.loaded,
|
||||
})}
|
||||
/>
|
||||
{visible && thumbnail}
|
||||
</div>
|
||||
);
|
||||
@ -266,6 +247,14 @@ class MediaGallery extends React.PureComponent {
|
||||
width: this.props.defaultWidth,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
|
||||
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
|
||||
@ -274,6 +263,14 @@ class MediaGallery extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleResize = debounce(() => {
|
||||
if (this.node) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}, 250, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
handleOpen = () => {
|
||||
if (this.props.onToggleVisibility) {
|
||||
this.props.onToggleVisibility();
|
||||
@ -286,17 +283,27 @@ class MediaGallery extends React.PureComponent {
|
||||
this.props.onOpenMedia(this.props.media, index);
|
||||
}
|
||||
|
||||
handleRef = (node) => {
|
||||
if (node) {
|
||||
// offsetWidth triggers a layout, so only calculate when we need to
|
||||
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
||||
handleRef = c => {
|
||||
this.node = c;
|
||||
|
||||
this.setState({
|
||||
width: node.offsetWidth,
|
||||
});
|
||||
if (this.node) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}
|
||||
|
||||
_setDimensions () {
|
||||
const width = this.node.offsetWidth;
|
||||
|
||||
// offsetWidth triggers a layout, so only calculate when we need to
|
||||
if (this.props.cacheWidth) {
|
||||
this.props.cacheWidth(width);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
width: width,
|
||||
});
|
||||
}
|
||||
|
||||
isFullSizeEligible() {
|
||||
const { media } = this.props;
|
||||
return media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
|
||||
@ -338,7 +345,7 @@ class MediaGallery extends React.PureComponent {
|
||||
</button>
|
||||
);
|
||||
} else if (visible) {
|
||||
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
||||
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
||||
} else {
|
||||
spoilerButton = (
|
||||
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||
|
@ -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);
|
||||
|
@ -10,10 +10,18 @@ import { List as ImmutableList } from 'immutable';
|
||||
import classNames from 'classnames';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
||||
import LoadingIndicator from './loading_indicator';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const MOUSE_IDLE_DELAY = 300;
|
||||
|
||||
export default class ScrollableList extends PureComponent {
|
||||
const mapStateToProps = (state, { scrollKey }) => {
|
||||
return {
|
||||
preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']),
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps, null, null, { forwardRef: true })
|
||||
class ScrollableList extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
@ -32,10 +40,12 @@ 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,
|
||||
bindToDocument: PropTypes.bool,
|
||||
preventScroll: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -128,7 +138,7 @@ export default class ScrollableList extends PureComponent {
|
||||
});
|
||||
|
||||
handleMouseIdle = () => {
|
||||
if (this.scrollToTopOnMouseIdle) {
|
||||
if (this.scrollToTopOnMouseIdle && !this.props.preventScroll) {
|
||||
this.setScrollTop(0);
|
||||
}
|
||||
|
||||
@ -178,7 +188,7 @@ export default class ScrollableList extends PureComponent {
|
||||
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
|
||||
const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0);
|
||||
|
||||
if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently)) {
|
||||
if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently || this.props.preventScroll)) {
|
||||
return this.getScrollHeight() - this.getScrollTop();
|
||||
} else {
|
||||
return null;
|
||||
@ -280,7 +290,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 +337,8 @@ export default class ScrollableList extends PureComponent {
|
||||
))}
|
||||
|
||||
{loadMore}
|
||||
|
||||
{!hasMore && append}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
117
app/javascript/mastodon/components/short_number.js
Normal file
117
app/javascript/mastodon/components/short_number.js
Normal file
@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
|
||||
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @callback ShortNumberRenderer
|
||||
* @param {JSX.Element} displayNumber Number to display
|
||||
* @param {number} pluralReady Number used for pluralization
|
||||
* @returns {JSX.Element} Final render of number
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ShortNumberProps
|
||||
* @property {number} value Number to display in short variant
|
||||
* @property {ShortNumberRenderer} [renderer]
|
||||
* Custom renderer for numbers, provided as a prop. If another renderer
|
||||
* passed as a child of this component, this prop won't be used.
|
||||
* @property {ShortNumberRenderer} [children]
|
||||
* Custom renderer for numbers, provided as a child. If another renderer
|
||||
* passed as a prop of this component, this one will be used instead.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Component that renders short big number to a shorter version
|
||||
*
|
||||
* @param {ShortNumberProps} param0 Props for the component
|
||||
* @returns {JSX.Element} Rendered number
|
||||
*/
|
||||
function ShortNumber({ value, renderer, children }) {
|
||||
const shortNumber = toShortNumber(value);
|
||||
const [, division] = shortNumber;
|
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (children != null && renderer != null) {
|
||||
console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
const customRenderer = children != null ? children : renderer;
|
||||
|
||||
const displayNumber = <ShortNumberCounter value={shortNumber} />;
|
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
return customRenderer != null
|
||||
? customRenderer(displayNumber, pluralReady(value, division))
|
||||
: displayNumber;
|
||||
}
|
||||
|
||||
ShortNumber.propTypes = {
|
||||
value: PropTypes.number.isRequired,
|
||||
renderer: PropTypes.func,
|
||||
children: PropTypes.func,
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} ShortNumberCounterProps
|
||||
* @property {import('../utils/number').ShortNumber} value Short number
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders short number into corresponding localizable react fragment
|
||||
*
|
||||
* @param {ShortNumberCounterProps} param0 Props for the component
|
||||
* @returns {JSX.Element} FormattedMessage ready to be embedded in code
|
||||
*/
|
||||
function ShortNumberCounter({ value }) {
|
||||
const [rawNumber, unit, maxFractionDigits = 0] = value;
|
||||
|
||||
const count = (
|
||||
<FormattedNumber
|
||||
value={rawNumber}
|
||||
maximumFractionDigits={maxFractionDigits}
|
||||
/>
|
||||
);
|
||||
|
||||
let values = { count, rawNumber };
|
||||
|
||||
switch (unit) {
|
||||
case DECIMAL_UNITS.THOUSAND: {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='units.short.thousand'
|
||||
defaultMessage='{count}K'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case DECIMAL_UNITS.MILLION: {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='units.short.million'
|
||||
defaultMessage='{count}M'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case DECIMAL_UNITS.BILLION: {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='units.short.billion'
|
||||
defaultMessage='{count}B'
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Not sure if we should go farther - @Sasha-Sorokin
|
||||
default: return count;
|
||||
}
|
||||
}
|
||||
|
||||
ShortNumberCounter.propTypes = {
|
||||
value: PropTypes.arrayOf(PropTypes.number),
|
||||
};
|
||||
|
||||
export default React.memo(ShortNumber);
|
@ -10,7 +10,7 @@ import StatusContent from './status_content';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import AttachmentList from './attachment_list';
|
||||
import Card from '../features/status/components/card';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
@ -51,6 +51,13 @@ export const defaultMediaVisibility = (status) => {
|
||||
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Status extends ImmutablePureComponent {
|
||||
|
||||
@ -87,6 +94,7 @@ class Status extends ImmutablePureComponent {
|
||||
updateScrollBottom: PropTypes.func,
|
||||
cacheMediaWidth: PropTypes.func,
|
||||
cachedMediaWidth: PropTypes.number,
|
||||
scrollKey: PropTypes.string,
|
||||
};
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
@ -257,7 +265,7 @@ class Status extends ImmutablePureComponent {
|
||||
let media = null;
|
||||
let statusAvatar, prepend, rebloggedByText;
|
||||
|
||||
const { intl, hidden, featured, otherAccounts, unread, showThread } = this.props;
|
||||
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey } = this.props;
|
||||
|
||||
let { status, account, ...other } = this.props;
|
||||
|
||||
@ -345,9 +353,14 @@ class Status extends ImmutablePureComponent {
|
||||
<Component
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
peaks={[0]}
|
||||
height={70}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={110}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
@ -401,6 +414,7 @@ class Status extends ImmutablePureComponent {
|
||||
compact
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
sensitive={status.get('sensitive')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -413,6 +427,15 @@ class Status extends ImmutablePureComponent {
|
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
|
||||
const visibilityIconInfo = {
|
||||
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
|
||||
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
|
||||
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
|
||||
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
|
||||
};
|
||||
|
||||
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-type-${status.get('activity_pub_type') || 'none'}` , `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||
@ -422,6 +445,7 @@ class Status extends ImmutablePureComponent {
|
||||
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
|
||||
<div className='status__info'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
|
||||
|
||||
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||
<div className='status__avatar'>
|
||||
@ -436,7 +460,7 @@ class Status extends ImmutablePureComponent {
|
||||
|
||||
{media}
|
||||
|
||||
<StatusActionBar status={status} account={account} {...other} />
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
@ -86,6 +86,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
onPin: PropTypes.func,
|
||||
onBookmark: PropTypes.func,
|
||||
withDismiss: PropTypes.bool,
|
||||
scrollKey: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
@ -230,7 +231,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl, withDismiss } = this.props;
|
||||
const { status, relationship, intl, withDismiss, scrollKey } = this.props;
|
||||
|
||||
const mutingConversation = status.get('muted');
|
||||
const anonymousAccess = !me;
|
||||
@ -239,9 +240,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
const federated = !status.get('local_only');
|
||||
|
||||
let menu = [];
|
||||
let reblogIcon = 'retweet';
|
||||
let replyIcon;
|
||||
let replyTitle;
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
|
||||
|
||||
@ -261,10 +259,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
if (status.getIn(['account', 'id']) === me) {
|
||||
if (publicStatus) {
|
||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
||||
} else {
|
||||
if (status.get('visibility') === 'private') {
|
||||
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
|
||||
}
|
||||
}
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
||||
@ -307,12 +301,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
if (status.get('visibility') === 'direct') {
|
||||
reblogIcon = 'envelope';
|
||||
} else if (status.get('visibility') === 'private') {
|
||||
reblogIcon = 'lock';
|
||||
}
|
||||
|
||||
let replyIcon;
|
||||
let replyTitle;
|
||||
if (status.get('in_reply_to_id', null) === null) {
|
||||
replyIcon = 'reply';
|
||||
replyTitle = intl.formatMessage(messages.reply);
|
||||
@ -321,6 +311,19 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
|
||||
|
||||
let reblogTitle = '';
|
||||
if (status.get('reblogged')) {
|
||||
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
||||
} else if (publicStatus) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog);
|
||||
} else if (reblogPrivate) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog_private);
|
||||
} else {
|
||||
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||
}
|
||||
|
||||
const shareButton = ('share' in navigator) && publicStatus && federated && (
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
|
||||
);
|
||||
@ -328,13 +331,22 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
|
||||
<IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button' disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
{shareButton}
|
||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} />
|
||||
<DropdownMenuContainer
|
||||
scrollKey={scrollKey}
|
||||
disabled={anonymousAccess}
|
||||
status={status}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
size={18}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
{ !federated &&
|
||||
<IconButton className='status__action-bar-button' disabled title={intl.formatMessage(messages.local_only)} icon='chain-broken' />
|
||||
|
@ -99,6 +99,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
scrollKey={this.props.scrollKey}
|
||||
showThread
|
||||
/>
|
||||
))
|
||||
|
18
app/javascript/mastodon/components/timeline_hint.js
Normal file
18
app/javascript/mastodon/components/timeline_hint.js
Normal file
@ -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;
|
@ -12,7 +12,7 @@ const mapStateToProps = state => ({
|
||||
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { status, items }) => ({
|
||||
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
|
||||
onOpen(id, onItemClick, dropdownPlacement, keyboard) {
|
||||
if (status) {
|
||||
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
|
||||
@ -22,7 +22,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({
|
||||
status,
|
||||
actions: items,
|
||||
onClick: onItemClick,
|
||||
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
||||
}) : openDropdownMenu(id, dropdownPlacement, keyboard, scrollKey));
|
||||
},
|
||||
|
||||
onClose(id) {
|
||||
|
@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import { is } from 'immutable';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
|
||||
});
|
||||
|
||||
class InlineAlert extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
show: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
mountMessage: false,
|
||||
};
|
||||
|
||||
static TRANSITION_DELAY = 200;
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (!this.props.show && nextProps.show) {
|
||||
this.setState({ mountMessage: true });
|
||||
} else if (this.props.show && !nextProps.show) {
|
||||
setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { show } = this.props;
|
||||
const { mountMessage } = this.state;
|
||||
|
||||
return (
|
||||
<span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
|
||||
{mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default @injectIntl
|
||||
class AccountNote extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
value: PropTypes.string,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
value: null,
|
||||
saving: false,
|
||||
saved: false,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this._reset();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const accountWillChange = !is(this.props.account, nextProps.account);
|
||||
const newState = {};
|
||||
|
||||
if (accountWillChange && this._isDirty()) {
|
||||
this._save(false);
|
||||
}
|
||||
|
||||
if (accountWillChange || nextProps.value === this.state.value) {
|
||||
newState.saving = false;
|
||||
}
|
||||
|
||||
if (this.props.value !== nextProps.value) {
|
||||
newState.value = nextProps.value;
|
||||
}
|
||||
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this._isDirty()) {
|
||||
this._save(false);
|
||||
}
|
||||
}
|
||||
|
||||
setTextareaRef = c => {
|
||||
this.textarea = c;
|
||||
}
|
||||
|
||||
handleChange = e => {
|
||||
this.setState({ value: e.target.value, saving: false });
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
this._save();
|
||||
|
||||
if (this.textarea) {
|
||||
this.textarea.blur();
|
||||
}
|
||||
} else if (e.keyCode === 27) {
|
||||
e.preventDefault();
|
||||
|
||||
this._reset(() => {
|
||||
if (this.textarea) {
|
||||
this.textarea.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
if (this._isDirty()) {
|
||||
this._save();
|
||||
}
|
||||
}
|
||||
|
||||
_save (showMessage = true) {
|
||||
this.setState({ saving: true }, () => this.props.onSave(this.state.value));
|
||||
|
||||
if (showMessage) {
|
||||
this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
|
||||
}
|
||||
}
|
||||
|
||||
_reset (callback) {
|
||||
this.setState({ value: this.props.value }, callback);
|
||||
}
|
||||
|
||||
_isDirty () {
|
||||
return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, intl } = this.props;
|
||||
const { value, saved } = this.state;
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<label htmlFor={`account-note-${account.get('id')}`}>
|
||||
<FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
|
||||
</label>
|
||||
|
||||
<Textarea
|
||||
id={`account-note-${account.get('id')}`}
|
||||
className='account__header__account-note__content'
|
||||
disabled={this.props.value === null || value === null}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value || ''}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onBlur={this.handleBlur}
|
||||
ref={this.setTextareaRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -8,9 +8,11 @@ import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import Avatar from 'mastodon/components/avatar';
|
||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
||||
import { counterRenderer } from 'mastodon/components/common_counter';
|
||||
import ShortNumber from 'mastodon/components/short_number';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import AccountNoteContainer from '../containers/account_note_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
@ -312,20 +314,31 @@ class Header extends ImmutablePureComponent {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{account.get('id') !== me && <AccountNoteContainer account={account} />}
|
||||
|
||||
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
|
||||
</div>
|
||||
|
||||
<div className='account__header__extra__links'>
|
||||
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
|
||||
<strong>{shortNumberFormat(account.get('statuses_count'))}</strong> <FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
<ShortNumber
|
||||
value={account.get('statuses_count')}
|
||||
renderer={counterRenderer('statuses')}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
|
||||
<strong>{shortNumberFormat(account.get('following_count'))}</strong> <FormattedMessage id='account.follows' defaultMessage='Follows' />
|
||||
<ShortNumber
|
||||
value={account.get('following_count')}
|
||||
renderer={counterRenderer('following')}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
|
||||
<strong>{shortNumberFormat(account.get('followers_count'))}</strong> <FormattedMessage id='account.followers' defaultMessage='Followers' />
|
||||
<ShortNumber
|
||||
value={account.get('followers_count')}
|
||||
renderer={counterRenderer('followers')}
|
||||
/>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -0,0 +1,17 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { submitAccountNote } from 'mastodon/actions/account_notes';
|
||||
import AccountNote from '../components/account_note';
|
||||
|
||||
const mapStateToProps = (state, { account }) => ({
|
||||
value: account.getIn(['relationship', 'note']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { account }) => ({
|
||||
|
||||
onSave (value) {
|
||||
dispatch(submitAccountNote(account.get('id'), value));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
|
@ -1,7 +1,7 @@
|
||||
import { decode } from 'blurhash';
|
||||
import Blurhash from 'mastodon/components/blurhash';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
|
||||
import { isIOS } from 'mastodon/is_mobile';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
@ -21,34 +21,6 @@ export default class MediaItem extends ImmutablePureComponent {
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
if (this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
_decode () {
|
||||
const hash = this.props.attachment.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);
|
||||
}
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
}
|
||||
|
||||
handleImageLoad = () => {
|
||||
this.setState({ loaded: true });
|
||||
}
|
||||
@ -89,57 +61,9 @@ export default class MediaItem extends ImmutablePureComponent {
|
||||
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
|
||||
const height = width;
|
||||
const status = attachment.get('status');
|
||||
const title = status.get('spoiler_text') || attachment.get('description');
|
||||
const title = status.get('spoiler_text') || attachment.get('description');
|
||||
|
||||
let thumbnail = '';
|
||||
let icon;
|
||||
|
||||
if (attachment.get('type') === 'unknown') {
|
||||
// Skip
|
||||
} else if (attachment.get('type') === 'audio') {
|
||||
thumbnail = (
|
||||
<span className='account-gallery__item__icons'>
|
||||
<Icon id='music' />
|
||||
</span>
|
||||
);
|
||||
} else if (attachment.get('type') === 'image') {
|
||||
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
||||
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
const y = ((focusY / -2) + .5) * 100;
|
||||
|
||||
thumbnail = (
|
||||
<img
|
||||
src={attachment.get('preview_url')}
|
||||
alt={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
);
|
||||
} else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
|
||||
const autoPlay = !isIOS() && autoPlayGif;
|
||||
const label = attachment.get('type') === 'video' ? <Icon id='play' /> : 'GIF';
|
||||
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
role='application'
|
||||
src={attachment.get('url')}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
autoPlay={autoPlay}
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
|
||||
<span className='media-gallery__gifv__label'>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
let thumbnail, label, icon, content;
|
||||
|
||||
if (!visible) {
|
||||
icon = (
|
||||
@ -147,14 +71,72 @@ export default class MediaItem extends ImmutablePureComponent {
|
||||
<Icon id='eye-slash' />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
if (['audio', 'video'].includes(attachment.get('type'))) {
|
||||
content = (
|
||||
<img
|
||||
src={attachment.get('preview_url') || attachment.getIn(['account', 'avatar_static'])}
|
||||
alt={attachment.get('description')}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
);
|
||||
|
||||
if (attachment.get('type') === 'audio') {
|
||||
label = <Icon id='music' />;
|
||||
} else {
|
||||
label = <Icon id='play' />;
|
||||
}
|
||||
} else if (attachment.get('type') === 'image') {
|
||||
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
||||
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
const y = ((focusY / -2) + .5) * 100;
|
||||
|
||||
content = (
|
||||
<img
|
||||
src={attachment.get('preview_url')}
|
||||
alt={attachment.get('description')}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
content = (
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.get('description')}
|
||||
role='application'
|
||||
src={attachment.get('url')}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
autoPlay={!isIOS() && autoPlayGif}
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
);
|
||||
|
||||
label = 'GIF';
|
||||
}
|
||||
|
||||
thumbnail = (
|
||||
<div className='media-gallery__gifv'>
|
||||
{content}
|
||||
|
||||
<span className='media-gallery__gifv__label'>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account-gallery__item' style={{ width, height }}>
|
||||
<a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
|
||||
{visible && thumbnail}
|
||||
{!visible && icon}
|
||||
<Blurhash
|
||||
hash={attachment.get('blurhash')}
|
||||
className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
|
||||
{visible ? thumbnail : icon}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
@ -101,9 +101,9 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
|
||||
handleOpenMedia = attachment => {
|
||||
if (attachment.get('type') === 'video') {
|
||||
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
|
||||
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } }));
|
||||
} else if (attachment.get('type') === 'audio') {
|
||||
this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status') }));
|
||||
this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } }));
|
||||
} else {
|
||||
const media = attachment.getIn(['status', 'media_attachments']);
|
||||
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
|
||||
|
@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
|
||||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
onEndorseToggle: PropTypes.func.isRequired,
|
||||
onAddToList: PropTypes.func.isRequired,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
hideTabs: PropTypes.bool,
|
||||
domain: PropTypes.string.isRequired,
|
||||
};
|
||||
@ -83,6 +84,10 @@ export default class Header extends ImmutablePureComponent {
|
||||
this.props.onAddToList(this.props.account);
|
||||
}
|
||||
|
||||
handleEditAccountNote = () => {
|
||||
this.props.onEditAccountNote(this.props.account);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, hideTabs, identity_proofs } = this.props;
|
||||
|
||||
@ -108,6 +113,7 @@ export default class Header extends ImmutablePureComponent {
|
||||
onUnblockDomain={this.handleUnblockDomain}
|
||||
onEndorseToggle={this.handleEndorseToggle}
|
||||
onAddToList={this.handleAddToList}
|
||||
onEditAccountNote={this.handleEditAccountNote}
|
||||
domain={this.props.domain}
|
||||
/>
|
||||
|
||||
|
@ -14,6 +14,9 @@ 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';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines';
|
||||
|
||||
const emptyList = ImmutableList();
|
||||
|
||||
@ -21,6 +24,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 +35,14 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
|
||||
};
|
||||
};
|
||||
|
||||
const RemoteHint = ({ url }) => (
|
||||
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older posts' />} />
|
||||
);
|
||||
|
||||
RemoteHint.propTypes = {
|
||||
url: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class AccountTimeline extends ImmutablePureComponent {
|
||||
|
||||
@ -44,32 +57,54 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||
withReplies: PropTypes.bool,
|
||||
blockedBy: PropTypes.bool,
|
||||
isAccount: PropTypes.bool,
|
||||
remote: PropTypes.bool,
|
||||
remoteUrl: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
const { params: { accountId }, withReplies } = this.props;
|
||||
const { params: { accountId }, withReplies, dispatch } = this.props;
|
||||
|
||||
this.props.dispatch(fetchAccount(accountId));
|
||||
this.props.dispatch(fetchAccountIdentityProofs(accountId));
|
||||
dispatch(fetchAccount(accountId));
|
||||
dispatch(fetchAccountIdentityProofs(accountId));
|
||||
|
||||
if (!withReplies) {
|
||||
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
|
||||
dispatch(expandAccountFeaturedTimeline(accountId));
|
||||
}
|
||||
|
||||
this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
|
||||
dispatch(expandAccountTimeline(accountId, { withReplies }));
|
||||
|
||||
if (accountId === me) {
|
||||
dispatch(connectTimeline(`account:${me}`));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
|
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId));
|
||||
this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
|
||||
dispatch(fetchAccount(nextProps.params.accountId));
|
||||
dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
|
||||
|
||||
if (!nextProps.withReplies) {
|
||||
this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
|
||||
dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
|
||||
}
|
||||
|
||||
this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
|
||||
dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
|
||||
}
|
||||
|
||||
if (nextProps.params.accountId === me && this.props.params.accountId !== me) {
|
||||
dispatch(connectTimeline(`account:${me}`));
|
||||
} else if (this.props.params.accountId === me && nextProps.params.accountId !== me) {
|
||||
dispatch(disconnectTimeline(`account:${me}`));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { dispatch, params: { accountId } } = this.props;
|
||||
|
||||
if (accountId === me) {
|
||||
dispatch(disconnectTimeline(`account:${me}`));
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,7 +113,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 +132,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 posts 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 posts here!' />;
|
||||
}
|
||||
|
||||
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
|
||||
|
||||
return (
|
||||
<Column>
|
||||
@ -106,6 +151,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}
|
||||
|
@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { formatTime } from 'mastodon/features/video';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import classNames from 'classnames';
|
||||
import { throttle } from 'lodash';
|
||||
import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
|
||||
import { debounce } from 'lodash';
|
||||
import Visualizer from './visualizer';
|
||||
|
||||
const messages = defineMessages({
|
||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||
@ -15,131 +17,155 @@ const messages = defineMessages({
|
||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||
});
|
||||
|
||||
const TICK_SIZE = 10;
|
||||
const PADDING = 180;
|
||||
|
||||
export default @injectIntl
|
||||
class Audio extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string,
|
||||
poster: PropTypes.string,
|
||||
duration: PropTypes.number,
|
||||
peaks: PropTypes.arrayOf(PropTypes.number),
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
preload: PropTypes.bool,
|
||||
editable: PropTypes.bool,
|
||||
fullscreen: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
cacheWidth: PropTypes.func,
|
||||
backgroundColor: PropTypes.string,
|
||||
foregroundColor: PropTypes.string,
|
||||
accentColor: PropTypes.string,
|
||||
autoPlay: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
width: this.props.width,
|
||||
currentTime: 0,
|
||||
buffer: 0,
|
||||
duration: null,
|
||||
paused: true,
|
||||
muted: false,
|
||||
volume: 0.5,
|
||||
dragging: false,
|
||||
};
|
||||
|
||||
// Hard coded in components.scss
|
||||
// Any way to get ::before values programatically?
|
||||
volWidth = 50;
|
||||
volOffset = 70;
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.visualizer = new Visualizer(TICK_SIZE);
|
||||
}
|
||||
|
||||
volHandleOffset = v => {
|
||||
const offset = v * this.volWidth + this.volOffset;
|
||||
setPlayerRef = c => {
|
||||
this.player = c;
|
||||
|
||||
return (offset > 110) ? 110 : offset;
|
||||
if (this.player) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}
|
||||
|
||||
_setDimensions () {
|
||||
const width = this.player.offsetWidth;
|
||||
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
|
||||
|
||||
if (this.props.cacheWidth) {
|
||||
this.props.cacheWidth(width);
|
||||
}
|
||||
|
||||
this.setState({ width, height });
|
||||
}
|
||||
|
||||
setSeekRef = c => {
|
||||
this.seek = c;
|
||||
}
|
||||
|
||||
setVolumeRef = c => {
|
||||
this.volume = c;
|
||||
}
|
||||
|
||||
setWaveformRef = c => {
|
||||
this.waveform = c;
|
||||
setAudioRef = c => {
|
||||
this.audio = c;
|
||||
|
||||
if (this.audio) {
|
||||
this.setState({ volume: this.audio.volume, muted: this.audio.muted });
|
||||
}
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
|
||||
this.visualizer.setCanvas(c);
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
if (this.waveform) {
|
||||
this._updateWaveform();
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (this.waveform && prevProps.src !== this.props.src) {
|
||||
this._updateWaveform();
|
||||
componentDidUpdate (prevProps, prevState) {
|
||||
if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
|
||||
this._clear();
|
||||
this._draw();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
|
||||
if (this.wavesurfer) {
|
||||
this.wavesurfer.destroy();
|
||||
this.wavesurfer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_updateWaveform () {
|
||||
const { src, height, duration, peaks, preload } = this.props;
|
||||
|
||||
const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
|
||||
const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');
|
||||
|
||||
if (this.wavesurfer) {
|
||||
this.wavesurfer.destroy();
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
const wavesurfer = WaveSurfer.create({
|
||||
container: this.waveform,
|
||||
height,
|
||||
barWidth: 3,
|
||||
cursorWidth: 0,
|
||||
progressColor,
|
||||
waveColor,
|
||||
backend: 'MediaElement',
|
||||
interact: preload,
|
||||
});
|
||||
|
||||
wavesurfer.setVolume(this.state.volume);
|
||||
|
||||
if (preload) {
|
||||
wavesurfer.load(src);
|
||||
this.loaded = true;
|
||||
} else {
|
||||
wavesurfer.load(src, peaks, 'none', duration);
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
|
||||
wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
|
||||
wavesurfer.on('pause', () => this.setState({ paused: true }));
|
||||
wavesurfer.on('play', () => this.setState({ paused: false }));
|
||||
wavesurfer.on('volume', volume => this.setState({ volume }));
|
||||
wavesurfer.on('mute', muted => this.setState({ muted }));
|
||||
|
||||
this.wavesurfer = wavesurfer;
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
togglePlay = () => {
|
||||
if (this.state.paused) {
|
||||
if (!this.props.preload && !this.loaded) {
|
||||
this.wavesurfer.createBackend();
|
||||
this.wavesurfer.createPeakCache();
|
||||
this.wavesurfer.load(this.props.src);
|
||||
this.wavesurfer.toggleInteraction();
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
this.setState({ paused: false }, () => this.wavesurfer.play());
|
||||
this.setState({ paused: false }, () => this.audio.play());
|
||||
} else {
|
||||
this.setState({ paused: true }, () => this.wavesurfer.pause());
|
||||
this.setState({ paused: true }, () => this.audio.pause());
|
||||
}
|
||||
}
|
||||
|
||||
handleResize = debounce(() => {
|
||||
if (this.player) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}, 250, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
handlePlay = () => {
|
||||
this.setState({ paused: false });
|
||||
|
||||
if (this.canvas && !this.audioContext) {
|
||||
this._initAudioContext();
|
||||
}
|
||||
|
||||
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume();
|
||||
}
|
||||
|
||||
this._renderCanvas();
|
||||
}
|
||||
|
||||
handlePause = () => {
|
||||
this.setState({ paused: true });
|
||||
|
||||
if (this.audioContext) {
|
||||
this.audioContext.suspend();
|
||||
}
|
||||
}
|
||||
|
||||
handleProgress = () => {
|
||||
const lastTimeRange = this.audio.buffered.length - 1;
|
||||
|
||||
if (lastTimeRange > -1) {
|
||||
this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
|
||||
}
|
||||
}
|
||||
|
||||
toggleMute = () => {
|
||||
const muted = !this.state.muted;
|
||||
this.setState({ muted }, () => this.wavesurfer.setMute(muted));
|
||||
|
||||
this.setState({ muted }, () => {
|
||||
this.audio.muted = muted;
|
||||
});
|
||||
}
|
||||
|
||||
handleVolumeMouseDown = e => {
|
||||
@ -161,86 +187,239 @@ class Audio extends React.PureComponent {
|
||||
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
|
||||
}
|
||||
|
||||
handleMouseDown = e => {
|
||||
document.addEventListener('mousemove', this.handleMouseMove, true);
|
||||
document.addEventListener('mouseup', this.handleMouseUp, true);
|
||||
document.addEventListener('touchmove', this.handleMouseMove, true);
|
||||
document.addEventListener('touchend', this.handleMouseUp, true);
|
||||
|
||||
this.setState({ dragging: true });
|
||||
this.audio.pause();
|
||||
this.handleMouseMove(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', this.handleMouseMove, true);
|
||||
document.removeEventListener('mouseup', this.handleMouseUp, true);
|
||||
document.removeEventListener('touchmove', this.handleMouseMove, true);
|
||||
document.removeEventListener('touchend', this.handleMouseUp, true);
|
||||
|
||||
this.setState({ dragging: false });
|
||||
this.audio.play();
|
||||
}
|
||||
|
||||
handleMouseMove = throttle(e => {
|
||||
const { x } = getPointerPosition(this.seek, e);
|
||||
const currentTime = this.audio.duration * x;
|
||||
|
||||
if (!isNaN(currentTime)) {
|
||||
this.setState({ currentTime }, () => {
|
||||
this.audio.currentTime = currentTime;
|
||||
});
|
||||
}
|
||||
}, 15);
|
||||
|
||||
handleTimeUpdate = () => {
|
||||
this.setState({
|
||||
currentTime: this.audio.currentTime,
|
||||
duration: Math.floor(this.audio.duration),
|
||||
});
|
||||
}
|
||||
|
||||
handleMouseVolSlide = throttle(e => {
|
||||
const rect = this.volume.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / this.volWidth; // x position within the element.
|
||||
const { x } = getPointerPosition(this.volume, e);
|
||||
|
||||
if(!isNaN(x)) {
|
||||
let slideamt = x;
|
||||
|
||||
if (x > 1) {
|
||||
slideamt = 1;
|
||||
} else if(x < 0) {
|
||||
slideamt = 0;
|
||||
}
|
||||
|
||||
this.wavesurfer.setVolume(slideamt);
|
||||
this.setState({ volume: x }, () => {
|
||||
this.audio.volume = x;
|
||||
});
|
||||
}
|
||||
}, 60);
|
||||
}, 15);
|
||||
|
||||
handleScroll = throttle(() => {
|
||||
if (!this.waveform || !this.wavesurfer) {
|
||||
if (!this.canvas || !this.audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, height } = this.waveform.getBoundingClientRect();
|
||||
const { top, height } = this.canvas.getBoundingClientRect();
|
||||
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
|
||||
|
||||
if (!this.state.paused && !inView) {
|
||||
this.setState({ paused: true }, () => this.wavesurfer.pause());
|
||||
this.setState({ paused: true }, () => this.audio.pause());
|
||||
}
|
||||
}, 150, { trailing: true })
|
||||
}, 150, { trailing: true });
|
||||
|
||||
handleMouseEnter = () => {
|
||||
this.setState({ hovered: true });
|
||||
}
|
||||
|
||||
handleMouseLeave = () => {
|
||||
this.setState({ hovered: false });
|
||||
}
|
||||
|
||||
handleLoadedData = () => {
|
||||
const { autoPlay } = this.props;
|
||||
|
||||
if (autoPlay) {
|
||||
this.audio.play();
|
||||
}
|
||||
}
|
||||
|
||||
_initAudioContext () {
|
||||
const context = new AudioContext();
|
||||
const source = context.createMediaElementSource(this.audio);
|
||||
|
||||
this.visualizer.setAudioContext(context, source);
|
||||
source.connect(context.destination);
|
||||
|
||||
this.audioContext = context;
|
||||
}
|
||||
|
||||
handleDownload = () => {
|
||||
fetch(this.props.src).then(res => res.blob()).then(blob => {
|
||||
const element = document.createElement('a');
|
||||
const objectURL = URL.createObjectURL(blob);
|
||||
|
||||
element.setAttribute('href', objectURL);
|
||||
element.setAttribute('download', fileNameFromURL(this.props.src));
|
||||
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
|
||||
URL.revokeObjectURL(objectURL);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
_renderCanvas () {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.audio) return;
|
||||
|
||||
this.handleTimeUpdate();
|
||||
this._clear();
|
||||
this._draw();
|
||||
|
||||
if (!this.state.paused) {
|
||||
this._renderCanvas();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_clear() {
|
||||
this.visualizer.clear(this.state.width, this.state.height);
|
||||
}
|
||||
|
||||
_draw() {
|
||||
this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
|
||||
}
|
||||
|
||||
_getRadius () {
|
||||
return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
|
||||
}
|
||||
|
||||
_getScaleCoefficient () {
|
||||
return (this.state.height || this.props.height) / 982;
|
||||
}
|
||||
|
||||
_getCX() {
|
||||
return Math.floor(this.state.width / 2);
|
||||
}
|
||||
|
||||
_getCY() {
|
||||
return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
|
||||
}
|
||||
|
||||
_getAccentColor () {
|
||||
return this.props.accentColor || '#ffffff';
|
||||
}
|
||||
|
||||
_getBackgroundColor () {
|
||||
return this.props.backgroundColor || '#000000';
|
||||
}
|
||||
|
||||
_getForegroundColor () {
|
||||
return this.props.foregroundColor || '#ffffff';
|
||||
}
|
||||
|
||||
render () {
|
||||
const { height, intl, alt, editable } = this.props;
|
||||
const { paused, muted, volume, currentTime } = this.state;
|
||||
|
||||
const volumeWidth = muted ? 0 : volume * this.volWidth;
|
||||
const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);
|
||||
const { src, intl, alt, editable, autoPlay } = this.props;
|
||||
const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
|
||||
const progress = (currentTime / duration) * 100;
|
||||
|
||||
return (
|
||||
<div className={classNames('audio-player', { editable })}>
|
||||
<div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
|
||||
<div className='audio-player__wave-placeholder' style={{ display: 'none' }} />
|
||||
|
||||
<div
|
||||
className='audio-player__waveform'
|
||||
aria-label={alt}
|
||||
title={alt}
|
||||
style={{ height }}
|
||||
ref={this.setWaveformRef}
|
||||
<div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<audio
|
||||
src={src}
|
||||
ref={this.setAudioRef}
|
||||
preload={autoPlay ? 'auto' : 'none'}
|
||||
onPlay={this.handlePlay}
|
||||
onPause={this.handlePause}
|
||||
onProgress={this.handleProgress}
|
||||
onLoadedData={this.handleLoadedData}
|
||||
crossOrigin='anonymous'
|
||||
/>
|
||||
|
||||
<canvas
|
||||
role='button'
|
||||
className='audio-player__canvas'
|
||||
width={this.state.width}
|
||||
height={this.state.height}
|
||||
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
|
||||
ref={this.setCanvasRef}
|
||||
onClick={this.togglePlay}
|
||||
title={alt}
|
||||
aria-label={alt}
|
||||
/>
|
||||
|
||||
<img
|
||||
src={this.props.poster}
|
||||
alt=''
|
||||
width={(this._getRadius() - TICK_SIZE) * 2}
|
||||
height={(this._getRadius() - TICK_SIZE) * 2}
|
||||
style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
|
||||
/>
|
||||
|
||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
||||
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
||||
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
|
||||
|
||||
<span
|
||||
className={classNames('video-player__seek__handle', { active: dragging })}
|
||||
tabIndex='0'
|
||||
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='video-player__controls active'>
|
||||
<div className='video-player__buttons-bar'>
|
||||
<div className='video-player__buttons left'>
|
||||
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
|
||||
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
|
||||
|
||||
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
||||
|
||||
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
|
||||
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
|
||||
<div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
|
||||
|
||||
<span
|
||||
className={classNames('video-player__volume__handle')}
|
||||
tabIndex='0'
|
||||
style={{ left: `${volumeHandleLoc}px` }}
|
||||
style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span>
|
||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||
<span className='video-player__time'>
|
||||
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
<button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}>
|
||||
<a className='video-player__download__icon' href={this.props.src} download>
|
||||
<Icon id={'download'} fixedWidth />
|
||||
</a>
|
||||
</button>
|
||||
<button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
136
app/javascript/mastodon/features/audio/visualizer.js
Normal file
136
app/javascript/mastodon/features/audio/visualizer.js
Normal file
@ -0,0 +1,136 @@
|
||||
/*
|
||||
Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
const hex2rgba = (hex, alpha = 1) => {
|
||||
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
|
||||
export default class Visualizer {
|
||||
|
||||
constructor (tickSize) {
|
||||
this.tickSize = tickSize;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this.canvas = canvas;
|
||||
if (canvas) {
|
||||
this.context = canvas.getContext('2d');
|
||||
}
|
||||
}
|
||||
|
||||
setAudioContext(context, source) {
|
||||
const analyser = context.createAnalyser();
|
||||
|
||||
analyser.smoothingTimeConstant = 0.6;
|
||||
analyser.fftSize = 2048;
|
||||
|
||||
source.connect(analyser);
|
||||
|
||||
this.analyser = analyser;
|
||||
}
|
||||
|
||||
getTickPoints (count) {
|
||||
const coords = [];
|
||||
|
||||
for(let i = 0; i < count; i++) {
|
||||
const rad = Math.PI * 2 * i / count;
|
||||
coords.push({ x: Math.cos(rad), y: -Math.sin(rad) });
|
||||
}
|
||||
|
||||
return coords;
|
||||
}
|
||||
|
||||
drawTick (cx, cy, mainColor, x1, y1, x2, y2) {
|
||||
const dx1 = Math.ceil(cx + x1);
|
||||
const dy1 = Math.ceil(cy + y1);
|
||||
const dx2 = Math.ceil(cx + x2);
|
||||
const dy2 = Math.ceil(cy + y2);
|
||||
|
||||
const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2);
|
||||
|
||||
const lastColor = hex2rgba(mainColor, 0);
|
||||
|
||||
gradient.addColorStop(0, mainColor);
|
||||
gradient.addColorStop(0.6, mainColor);
|
||||
gradient.addColorStop(1, lastColor);
|
||||
|
||||
this.context.beginPath();
|
||||
this.context.strokeStyle = gradient;
|
||||
this.context.lineWidth = 2;
|
||||
this.context.moveTo(dx1, dy1);
|
||||
this.context.lineTo(dx2, dy2);
|
||||
this.context.stroke();
|
||||
}
|
||||
|
||||
getTicks (count, size, radius, scaleCoefficient) {
|
||||
const ticks = this.getTickPoints(count);
|
||||
const lesser = 200;
|
||||
const m = [];
|
||||
const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
|
||||
const frequencyData = new Uint8Array(bufferLength);
|
||||
const allScales = [];
|
||||
|
||||
if (this.analyser) {
|
||||
this.analyser.getByteFrequencyData(frequencyData);
|
||||
}
|
||||
|
||||
ticks.forEach((tick, i) => {
|
||||
const coef = 1 - i / (ticks.length * 2.5);
|
||||
|
||||
let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
|
||||
|
||||
if (delta < 0) {
|
||||
delta = 0;
|
||||
}
|
||||
|
||||
const k = radius / (radius - (size + delta));
|
||||
|
||||
const x1 = tick.x * (radius - size);
|
||||
const y1 = tick.y * (radius - size);
|
||||
const x2 = x1 * k;
|
||||
const y2 = y1 * k;
|
||||
|
||||
m.push({ x1, y1, x2, y2 });
|
||||
|
||||
if (i < 20) {
|
||||
let scale = delta / (200 * scaleCoefficient);
|
||||
scale = scale < 1 ? 1 : scale;
|
||||
allScales.push(scale);
|
||||
}
|
||||
});
|
||||
|
||||
const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
|
||||
|
||||
return m.map(({ x1, y1, x2, y2 }) => ({
|
||||
x1: x1,
|
||||
y1: y1,
|
||||
x2: x2 * scale,
|
||||
y2: y2 * scale,
|
||||
}));
|
||||
}
|
||||
|
||||
clear (width, height) {
|
||||
this.context.clearRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
draw (cx, cy, color, radius, coefficient) {
|
||||
this.context.save();
|
||||
|
||||
const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient);
|
||||
|
||||
ticks.forEach(tick => {
|
||||
this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2);
|
||||
});
|
||||
|
||||
this.context.restore();
|
||||
}
|
||||
|
||||
}
|
@ -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 || event.metaKey)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
this.props.onPick(emoji);
|
||||
}
|
||||
|
||||
|
@ -7,11 +7,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
const messages = defineMessages({
|
||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media ({formats})' },
|
||||
upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
|
||||
});
|
||||
|
||||
const SUPPORTED_FORMATS = 'JPEG, PNG, GIF, WebM, MP4, MOV, OGG, WAV, MP3, FLAC';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const mapStateToProps = state => ({
|
||||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
|
||||
@ -60,11 +58,13 @@ class UploadButton extends ImmutablePureComponent {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = intl.formatMessage(messages.upload);
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload-button'>
|
||||
<IconButton icon='paperclip' title={intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
|
||||
<IconButton icon='paperclip' title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })}</span>
|
||||
<span style={{ display: 'none' }}>{message}</span>
|
||||
<input
|
||||
key={resetFileKey}
|
||||
ref={this.setRef}
|
||||
|
@ -36,6 +36,7 @@ class Conversation extends ImmutablePureComponent {
|
||||
accounts: ImmutablePropTypes.list.isRequired,
|
||||
lastStatus: ImmutablePropTypes.map,
|
||||
unread:PropTypes.bool.isRequired,
|
||||
scrollKey: PropTypes.string,
|
||||
onMoveUp: PropTypes.func,
|
||||
onMoveDown: PropTypes.func,
|
||||
markRead: PropTypes.func.isRequired,
|
||||
@ -127,7 +128,7 @@ class Conversation extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accounts, lastStatus, unread, intl } = this.props;
|
||||
const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
|
||||
|
||||
if (lastStatus === null) {
|
||||
return null;
|
||||
@ -194,7 +195,15 @@ class Conversation extends ImmutablePureComponent {
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReply} />
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer status={lastStatus} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} />
|
||||
<DropdownMenuContainer
|
||||
scrollKey={scrollKey}
|
||||
status={lastStatus}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
size={18}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,6 +10,7 @@ export default class ConversationsList extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
conversations: ImmutablePropTypes.list.isRequired,
|
||||
scrollKey: PropTypes.string.isRequired,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
onLoadMore: PropTypes.func,
|
||||
@ -58,13 +59,14 @@ export default class ConversationsList extends ImmutablePureComponent {
|
||||
const { conversations, onLoadMore, ...other } = this.props;
|
||||
|
||||
return (
|
||||
<ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}>
|
||||
<ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
|
||||
{conversations.map(item => (
|
||||
<ConversationContainer
|
||||
key={item.get('id')}
|
||||
conversationId={item.get('id')}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
scrollKey={this.props.scrollKey}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
@ -11,8 +11,14 @@ import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
|
||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
||||
import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
|
||||
import ShortNumber from 'mastodon/components/short_number';
|
||||
import {
|
||||
followAccount,
|
||||
unfollowAccount,
|
||||
blockAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||
|
||||
@ -22,7 +28,10 @@ const messages = defineMessages({
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
||||
unfollowConfirm: {
|
||||
id: 'confirmations.unfollow.confirm',
|
||||
defaultMessage: 'Unfollow',
|
||||
},
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
@ -36,15 +45,25 @@ const makeMapStateToProps = () => {
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
|
||||
onFollow (account) {
|
||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
||||
onFollow(account) {
|
||||
if (
|
||||
account.getIn(['relationship', 'following']) ||
|
||||
account.getIn(['relationship', 'requested'])
|
||||
) {
|
||||
if (unfollowModal) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||
}));
|
||||
dispatch(
|
||||
openModal('CONFIRM', {
|
||||
message: (
|
||||
<FormattedMessage
|
||||
id='confirmations.unfollow.message'
|
||||
defaultMessage='Are you sure you want to unfollow {name}?'
|
||||
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
||||
/>
|
||||
),
|
||||
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(unfollowAccount(account.get('id')));
|
||||
}
|
||||
@ -53,7 +72,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
}
|
||||
},
|
||||
|
||||
onBlock (account) {
|
||||
onBlock(account) {
|
||||
if (account.getIn(['relationship', 'blocking'])) {
|
||||
dispatch(unblockAccount(account.get('id')));
|
||||
} else {
|
||||
@ -61,17 +80,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
}
|
||||
},
|
||||
|
||||
onMute (account) {
|
||||
onMute(account) {
|
||||
if (account.getIn(['relationship', 'muting'])) {
|
||||
dispatch(unmuteAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
export default
|
||||
@injectIntl
|
||||
@connect(makeMapStateToProps, mapDispatchToProps)
|
||||
class AccountCard extends ImmutablePureComponent {
|
||||
|
||||
@ -83,7 +102,7 @@ class AccountCard extends ImmutablePureComponent {
|
||||
onMute: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
_updateEmojis () {
|
||||
_updateEmojis() {
|
||||
const node = this.node;
|
||||
|
||||
if (!node || autoPlayGif) {
|
||||
@ -104,68 +123,113 @@ class AccountCard extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
componentDidMount() {
|
||||
this._updateEmojis();
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
componentDidUpdate() {
|
||||
this._updateEmojis();
|
||||
}
|
||||
|
||||
handleEmojiMouseEnter = ({ target }) => {
|
||||
target.src = target.getAttribute('data-original');
|
||||
}
|
||||
};
|
||||
|
||||
handleEmojiMouseLeave = ({ target }) => {
|
||||
target.src = target.getAttribute('data-static');
|
||||
}
|
||||
};
|
||||
|
||||
handleFollow = () => {
|
||||
this.props.onFollow(this.props.account);
|
||||
}
|
||||
};
|
||||
|
||||
handleBlock = () => {
|
||||
this.props.onBlock(this.props.account);
|
||||
}
|
||||
};
|
||||
|
||||
handleMute = () => {
|
||||
this.props.onMute(this.props.account);
|
||||
}
|
||||
};
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
render() {
|
||||
const { account, intl } = this.props;
|
||||
|
||||
let buttons;
|
||||
|
||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
if (
|
||||
account.get('id') !== me &&
|
||||
account.get('relationship', null) !== null
|
||||
) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
|
||||
buttons = (
|
||||
<IconButton
|
||||
disabled
|
||||
icon='hourglass'
|
||||
title={intl.formatMessage(messages.requested)}
|
||||
/>
|
||||
);
|
||||
} else if (blocking) {
|
||||
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
||||
buttons = (
|
||||
<IconButton
|
||||
active
|
||||
icon='unlock'
|
||||
title={intl.formatMessage(messages.unblock, {
|
||||
name: account.get('username'),
|
||||
})}
|
||||
onClick={this.handleBlock}
|
||||
/>
|
||||
);
|
||||
} else if (muting) {
|
||||
buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
|
||||
buttons = (
|
||||
<IconButton
|
||||
active
|
||||
icon='volume-up'
|
||||
title={intl.formatMessage(messages.unmute, {
|
||||
name: account.get('username'),
|
||||
})}
|
||||
onClick={this.handleMute}
|
||||
/>
|
||||
);
|
||||
} else if (!account.get('moved') || following) {
|
||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
||||
buttons = (
|
||||
<IconButton
|
||||
icon={following ? 'user-times' : 'user-plus'}
|
||||
title={intl.formatMessage(
|
||||
following ? messages.unfollow : messages.follow,
|
||||
)}
|
||||
onClick={this.handleFollow}
|
||||
active={following}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='directory__card'>
|
||||
<div className='directory__card__img'>
|
||||
<img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' />
|
||||
<img
|
||||
src={
|
||||
autoPlayGif ? account.get('header') : account.get('header_static')
|
||||
}
|
||||
alt=''
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='directory__card__bar'>
|
||||
<Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
|
||||
<Permalink
|
||||
className='directory__card__bar__name'
|
||||
href={account.get('url')}
|
||||
to={`/accounts/${account.get('id')}`}
|
||||
>
|
||||
<Avatar account={account} size={48} />
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
@ -176,13 +240,44 @@ class AccountCard extends ImmutablePureComponent {
|
||||
</div>
|
||||
|
||||
<div className='directory__card__extra' ref={this.setRef}>
|
||||
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
|
||||
<div
|
||||
className='account__header__content'
|
||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='directory__card__extra'>
|
||||
<div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div>
|
||||
<div className='accounts-table__count'>{shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div>
|
||||
<div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div>
|
||||
<div className='accounts-table__count'>
|
||||
<ShortNumber value={account.get('statuses_count')} />
|
||||
<small>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Toots' />
|
||||
</small>
|
||||
</div>
|
||||
<div className='accounts-table__count'>
|
||||
<ShortNumber value={account.get('followers_count')} />{' '}
|
||||
<small>
|
||||
<FormattedMessage
|
||||
id='account.followers'
|
||||
defaultMessage='Followers'
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
<div className='accounts-table__count'>
|
||||
{account.get('last_status_at') === null ? (
|
||||
<FormattedMessage
|
||||
id='account.never_active'
|
||||
defaultMessage='Never'
|
||||
/>
|
||||
) : (
|
||||
<RelativeTimestamp timestamp={account.get('last_status_at')} />
|
||||
)}{' '}
|
||||
<small>
|
||||
<FormattedMessage
|
||||
id='account.last_status'
|
||||
defaultMessage='Last active'
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -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" />');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Toggle from 'react-toggle';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
import { NonceProvider } from 'react-select';
|
||||
import SettingToggle from '../../notifications/components/setting_toggle';
|
||||
|
||||
const messages = defineMessages({
|
||||
@ -58,18 +59,20 @@ class ColumnSettings extends React.PureComponent {
|
||||
{this.modeLabel(mode)}
|
||||
</span>
|
||||
|
||||
<AsyncSelect
|
||||
isMulti
|
||||
autoFocus
|
||||
value={this.tags(mode)}
|
||||
onChange={this.onSelect(mode)}
|
||||
loadOptions={this.props.onLoad}
|
||||
className='column-select__container'
|
||||
classNamePrefix='column-select'
|
||||
name='tags'
|
||||
placeholder={this.props.intl.formatMessage(messages.placeholder)}
|
||||
noOptionsMessage={this.noOptionsMessage}
|
||||
/>
|
||||
<NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content}>
|
||||
<AsyncSelect
|
||||
isMulti
|
||||
autoFocus
|
||||
value={this.tags(mode)}
|
||||
onChange={this.onSelect(mode)}
|
||||
loadOptions={this.props.onLoad}
|
||||
className='column-select__container'
|
||||
classNamePrefix='column-select'
|
||||
name='tags'
|
||||
placeholder={this.props.intl.formatMessage(messages.placeholder)}
|
||||
noOptionsMessage={this.noOptionsMessage}
|
||||
/>
|
||||
</NonceProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import { connectHashtagStream } from '../../actions/streaming';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
|
||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@ -76,13 +76,13 @@ class HashtagTimeline extends React.PureComponent {
|
||||
this.column.scrollTop();
|
||||
}
|
||||
|
||||
_subscribe (dispatch, id, tags = {}) {
|
||||
_subscribe (dispatch, id, tags = {}, local) {
|
||||
let any = (tags.any || []).map(tag => tag.value);
|
||||
let all = (tags.all || []).map(tag => tag.value);
|
||||
let none = (tags.none || []).map(tag => tag.value);
|
||||
|
||||
[id, ...any].map(tag => {
|
||||
this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
|
||||
this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => {
|
||||
let tags = status.tags.map(tag => tag.name);
|
||||
|
||||
return all.filter(tag => tags.includes(tag)).length === all.length &&
|
||||
@ -100,7 +100,7 @@ class HashtagTimeline extends React.PureComponent {
|
||||
const { dispatch } = this.props;
|
||||
const { id, tags, local } = this.props.params;
|
||||
|
||||
this._subscribe(dispatch, id, tags);
|
||||
this._subscribe(dispatch, id, tags, local);
|
||||
dispatch(expandHashtagTimeline(id, { tags, local }));
|
||||
}
|
||||
|
||||
@ -110,8 +110,8 @@ class HashtagTimeline extends React.PureComponent {
|
||||
|
||||
if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
|
||||
this._unsubscribe();
|
||||
this._subscribe(dispatch, id, tags);
|
||||
dispatch(clearTimeline(`hashtag:${id}`));
|
||||
this._subscribe(dispatch, id, tags, local);
|
||||
dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
|
||||
dispatch(expandHashtagTimeline(id, { tags, local }));
|
||||
}
|
||||
}
|
||||
@ -131,7 +131,7 @@ class HashtagTimeline extends React.PureComponent {
|
||||
|
||||
render () {
|
||||
const { shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props;
|
||||
const { id } = this.props.params;
|
||||
const { id, local } = this.props.params;
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
@ -153,7 +153,7 @@ class HashtagTimeline extends React.PureComponent {
|
||||
<StatusListContainer
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`hashtag_timeline-${columnId}`}
|
||||
timelineId={`hashtag:${id}`}
|
||||
timelineId={`hashtag:${id}${local ? ':local' : ''}`}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
|
@ -88,6 +88,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
||||
<td><kbd>alt</kbd>+<kbd>n</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new post' /></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>
|
||||
|
@ -203,10 +203,6 @@ class ActionBar extends React.PureComponent {
|
||||
if (me === status.getIn(['account', 'id'])) {
|
||||
if (publicStatus) {
|
||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
||||
} else {
|
||||
if (status.get('visibility') === 'private') {
|
||||
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
|
||||
}
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
@ -263,14 +259,23 @@ class ActionBar extends React.PureComponent {
|
||||
replyIcon = 'reply-all';
|
||||
}
|
||||
|
||||
let reblogIcon = 'retweet';
|
||||
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
|
||||
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
|
||||
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
|
||||
|
||||
let reblogTitle;
|
||||
if (status.get('reblogged')) {
|
||||
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
||||
} else if (publicStatus) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog);
|
||||
} else if (reblogPrivate) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog_private);
|
||||
} else {
|
||||
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='detailed-status__action-bar'>
|
||||
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton disabled={!publicStatus} active={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||
{shareButton}
|
||||
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
|
||||
|
@ -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 { useBlurhash } from 'mastodon/initial_state';
|
||||
import Blurhash from 'mastodon/components/blurhash';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
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,15 +77,46 @@ 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 () {
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
_setDimensions () {
|
||||
const width = this.node.offsetWidth;
|
||||
|
||||
if (this.props.cacheWidth) {
|
||||
this.props.cacheWidth(width);
|
||||
}
|
||||
|
||||
this.setState({ width });
|
||||
}
|
||||
|
||||
handleResize = debounce(() => {
|
||||
if (this.node) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}, 250, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
handlePhotoClick = () => {
|
||||
const { card, onOpenMedia } = this.props;
|
||||
|
||||
@ -113,12 +149,23 @@ export default class Card extends React.PureComponent {
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
if (c) {
|
||||
if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
|
||||
this.setState({ width: c.offsetWidth });
|
||||
this.node = c;
|
||||
|
||||
if (this.node) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}
|
||||
|
||||
handleImageLoad = () => {
|
||||
this.setState({ previewLoaded: true });
|
||||
}
|
||||
|
||||
handleReveal = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.setState({ revealed: true });
|
||||
}
|
||||
|
||||
renderVideo () {
|
||||
const { card } = this.props;
|
||||
const content = { __html: addAutoPlay(card.get('html')) };
|
||||
@ -138,7 +185,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;
|
||||
@ -161,7 +208,26 @@ 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 = (
|
||||
<Blurhash
|
||||
className={classnames('status-card__image-preview', {
|
||||
'status-card__image-preview--hidden': revealed && this.state.previewLoaded,
|
||||
})}
|
||||
hash={card.get('blurhash')}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
);
|
||||
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,20 +241,24 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} ref={this.setRef}>
|
||||
<div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
|
||||
{embed}
|
||||
{!compact && description}
|
||||
</div>
|
||||
@ -196,6 +266,7 @@ export default class Card extends React.PureComponent {
|
||||
} else if (card.get('image')) {
|
||||
embed = (
|
||||
<div className='status-card__image'>
|
||||
{canvas}
|
||||
{thumbnail}
|
||||
</div>
|
||||
);
|
||||
|
@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
import MediaGallery from '../../../components/media_gallery';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { defineMessages, injectIntl, FormattedDate } from 'react-intl';
|
||||
import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
|
||||
import Card from './card';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Video from '../../video';
|
||||
@ -18,10 +18,14 @@ import AnimatedNumber from 'mastodon/components/animated_number';
|
||||
|
||||
const messages = defineMessages({
|
||||
local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' },
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class DetailedStatus extends ImmutablePureComponent {
|
||||
export default @injectIntl
|
||||
class DetailedStatus extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
@ -96,9 +100,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||
|
||||
render () {
|
||||
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
||||
const intl = this.props.intl;
|
||||
const outerStyle = { boxSizing: 'border-box' };
|
||||
const { compact } = this.props;
|
||||
const { intl, compact } = this.props;
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
@ -124,8 +127,11 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={110}
|
||||
preload
|
||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
height={150}
|
||||
/>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
@ -160,38 +166,48 @@ 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')) {
|
||||
applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></span>;
|
||||
applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
|
||||
}
|
||||
|
||||
if (status.get('visibility') === 'direct') {
|
||||
reblogIcon = 'envelope';
|
||||
} else if (status.get('visibility') === 'private') {
|
||||
reblogIcon = 'lock';
|
||||
}
|
||||
const visibilityIconInfo = {
|
||||
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
|
||||
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
|
||||
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
|
||||
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
|
||||
};
|
||||
|
||||
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
||||
const visibilityLink = <React.Fragment> · <Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></React.Fragment>;
|
||||
|
||||
if (['private', 'direct'].includes(status.get('visibility'))) {
|
||||
reblogLink = <Icon id={reblogIcon} />;
|
||||
reblogLink = '';
|
||||
} else if (this.context.router) {
|
||||
reblogLink = (
|
||||
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
|
||||
<Icon id={reblogIcon} />
|
||||
<span className='detailed-status__reblogs'>
|
||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||
</span>
|
||||
</Link>
|
||||
<React.Fragment>
|
||||
<React.Fragment> · </React.Fragment>
|
||||
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
|
||||
<Icon id={reblogIcon} />
|
||||
<span className='detailed-status__reblogs'>
|
||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||
</span>
|
||||
</Link>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
reblogLink = (
|
||||
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
<Icon id={reblogIcon} />
|
||||
<span className='detailed-status__reblogs'>
|
||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||
</span>
|
||||
</a>
|
||||
<React.Fragment>
|
||||
<React.Fragment> · </React.Fragment>
|
||||
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
<Icon id={reblogIcon} />
|
||||
<span className='detailed-status__reblogs'>
|
||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||
</span>
|
||||
</a>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@ -221,7 +237,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div ref={this.setRef} className={classNames('detailed-status', { compact })}>
|
||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
|
||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||
@ -234,7 +250,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||
<div className='detailed-status__meta'>
|
||||
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
|
||||
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
||||
</a>{applicationLink} · {reblogLink} · {favouriteLink}{localOnly}
|
||||
</a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}{localOnly}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,25 +1,24 @@
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import Column from '../column';
|
||||
import ColumnHeader from '../column_header';
|
||||
|
||||
describe('<Column />', () => {
|
||||
describe('<ColumnHeader /> click handler', () => {
|
||||
it('runs the scroll animation if the column contains scrollable content', () => {
|
||||
const wrapper = mount(
|
||||
const scrollToMock = jest.fn();
|
||||
const { container } = render(
|
||||
<Column heading='notifications'>
|
||||
<div className='scrollable' />
|
||||
</Column>,
|
||||
);
|
||||
const scrollToMock = jest.fn();
|
||||
wrapper.find(Column).find('.scrollable').getDOMNode().scrollTo = scrollToMock;
|
||||
wrapper.find(ColumnHeader).find('button').simulate('click');
|
||||
container.querySelector('.scrollable').scrollTo = scrollToMock;
|
||||
fireEvent.click(screen.getByText('notifications'));
|
||||
expect(scrollToMock).toHaveBeenCalledWith({ behavior: 'smooth', top: 0 });
|
||||
});
|
||||
|
||||
it('does not try to scroll if there is no scrollable content', () => {
|
||||
const wrapper = mount(<Column heading='notifications' />);
|
||||
wrapper.find(ColumnHeader).find('button').simulate('click');
|
||||
render(<Column heading='notifications' />);
|
||||
fireEvent.click(screen.getByText('notifications'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -2,17 +2,27 @@ import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { previewState } from './video_modal';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
export default class AudioModal extends ImmutablePureComponent {
|
||||
const mapStateToProps = (state, { status }) => ({
|
||||
account: state.getIn(['accounts', status.get('account')]),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class AudioModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
options: PropTypes.shape({
|
||||
autoPlay: PropTypes.bool,
|
||||
}),
|
||||
account: ImmutablePropTypes.map,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@ -50,7 +60,8 @@ export default class AudioModal extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media, status } = this.props;
|
||||
const { media, status, account } = this.props;
|
||||
const options = this.props.options || {};
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal audio-modal'>
|
||||
@ -59,8 +70,12 @@ export default class AudioModal extends ImmutablePureComponent {
|
||||
src={media.get('url')}
|
||||
alt={media.get('description')}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={135}
|
||||
preload
|
||||
height={150}
|
||||
poster={media.get('preview_url') || account.get('avatar_static')}
|
||||
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={media.getIn(['meta', 'colors', 'accent'])}
|
||||
autoPlay={options.autoPlay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -10,10 +10,15 @@ import DisplayName from '../../../components/display_name';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import AttachmentList from 'mastodon/components/attachment_list';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
@ -55,14 +60,24 @@ class BoostModal extends ImmutablePureComponent {
|
||||
const { status, intl } = this.props;
|
||||
const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
|
||||
|
||||
const visibilityIconInfo = {
|
||||
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
|
||||
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
|
||||
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
|
||||
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
|
||||
};
|
||||
|
||||
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal boost-modal'>
|
||||
<div className='boost-modal__container'>
|
||||
<div className='status light'>
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, 'light')}>
|
||||
<div className='boost-modal__status-header'>
|
||||
<div className='boost-modal__status-time'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
</div>
|
||||
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
|
||||
|
||||
<a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
|
||||
<div className='status__avatar'>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import { changeUploadCompose } from '../../../actions/compose';
|
||||
import { changeUploadCompose, uploadThumbnail } from '../../../actions/compose';
|
||||
import { getPointerPosition } from '../../video';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
@ -17,15 +17,19 @@ import CharacterCounter from 'mastodon/features/compose/components/character_cou
|
||||
import { length } from 'stringz';
|
||||
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
||||
import GIFV from 'mastodon/components/gifv';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
|
||||
placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
|
||||
chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
account: state.getIn(['accounts', me]),
|
||||
isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
||||
@ -34,6 +38,10 @@ const mapDispatchToProps = (dispatch, { id }) => ({
|
||||
dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
|
||||
},
|
||||
|
||||
onSelectThumbnail: files => {
|
||||
dispatch(uploadThumbnail(id, files[0]));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
|
||||
@ -78,6 +86,10 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
isUploadingThumbnail: PropTypes.bool,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onSelectThumbnail: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
@ -232,13 +244,29 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||
}).catch(() => this.setState({ detecting: false }));
|
||||
}
|
||||
|
||||
handleThumbnailChange = e => {
|
||||
if (e.target.files.length > 0) {
|
||||
this.setState({ dirty: true });
|
||||
this.props.onSelectThumbnail(e.target.files);
|
||||
}
|
||||
}
|
||||
|
||||
setFileInputRef = c => {
|
||||
this.fileInput = c;
|
||||
}
|
||||
|
||||
handleFileInputClick = () => {
|
||||
this.fileInput.click();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media, intl, onClose } = this.props;
|
||||
const { media, intl, account, onClose, isUploadingThumbnail } = this.props;
|
||||
const { x, y, dragging, description, dirty, detecting, progress } = this.state;
|
||||
|
||||
const width = media.getIn(['meta', 'original', 'width']) || null;
|
||||
const height = media.getIn(['meta', 'original', 'height']) || null;
|
||||
const focals = ['image', 'gifv'].includes(media.get('type'));
|
||||
const thumbnailable = ['audio', 'video'].includes(media.get('type'));
|
||||
|
||||
const previewRatio = 16/9;
|
||||
const previewWidth = 200;
|
||||
@ -265,6 +293,30 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||
<div className='report-modal__comment'>
|
||||
{focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
|
||||
|
||||
{thumbnailable && (
|
||||
<React.Fragment>
|
||||
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
|
||||
|
||||
<Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
|
||||
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
|
||||
|
||||
<input
|
||||
id='upload-modal__thumbnail'
|
||||
ref={this.setFileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg'
|
||||
onChange={this.handleThumbnailChange}
|
||||
style={{ display: 'none' }}
|
||||
disabled={isUploadingThumbnail}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<hr className='setting-divider' />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<label className='setting-text-label' htmlFor='upload-modal__description'>
|
||||
{descriptionLabel}
|
||||
</label>
|
||||
@ -290,7 +342,7 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||
<CharacterCounter max={1500} text={detecting ? '' : description} />
|
||||
</div>
|
||||
|
||||
<Button disabled={!dirty || detecting || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
|
||||
<Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
|
||||
</div>
|
||||
|
||||
<div className='focal-point-modal__content'>
|
||||
@ -325,7 +377,10 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||
src={media.get('url')}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={150}
|
||||
preload
|
||||
poster={media.get('preview_url') || account.get('avatar_static')}
|
||||
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={media.getIn(['meta', 'colors', 'accent'])}
|
||||
editable
|
||||
/>
|
||||
)}
|
||||
|
@ -66,7 +66,7 @@ class LinkFooter extends React.PureComponent {
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='getting_started.hometown_open_source_notice'
|
||||
defaultMessage='Hometown is also open source, at {hometown} (v1.0.4).'
|
||||
defaultMessage='Hometown is also open source, at {hometown} (v1.0.5).'
|
||||
values={{ hometown: <span><a href='https://github.com/hometown-fork/hometown' rel='noopener' target='_blank'>hometown-fork/hometown</a></span> }}
|
||||
/>
|
||||
</p>
|
||||
|
@ -17,6 +17,8 @@ const makeGetStatusIds = (pending = false) => createSelector([
|
||||
const statusForId = statuses.get(id);
|
||||
let showStatus = true;
|
||||
|
||||
if (statusForId.get('account') === me) return true;
|
||||
|
||||
if (columnSettings.getIn(['shows', 'reblog']) === false) {
|
||||
showStatus = showStatus && statusForId.get('reblog') === null;
|
||||
}
|
||||
|
@ -10,13 +10,13 @@ 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';
|
||||
import { clearHeight } from '../../actions/height_cache';
|
||||
import { focusApp, unfocusApp } from 'mastodon/actions/app';
|
||||
import { submitMarkers } from 'mastodon/actions/markers';
|
||||
import { synchronouslySubmitMarkers } from 'mastodon/actions/markers';
|
||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||
import UploadArea from './components/upload_area';
|
||||
import ColumnsAreaContainer from './containers/columns_area_container';
|
||||
@ -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',
|
||||
@ -251,9 +252,10 @@ class UI extends React.PureComponent {
|
||||
handleBeforeUnload = e => {
|
||||
const { intl, dispatch, isComposing, hasComposingText, hasMediaAttachments } = this.props;
|
||||
|
||||
dispatch(submitMarkers());
|
||||
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,
|
||||
|
@ -2,12 +2,12 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { fromJS, is } from 'immutable';
|
||||
import { throttle } from 'lodash';
|
||||
import { throttle, debounce } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||
import { displayMedia, useBlurhash } from '../../initial_state';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { decode } from 'blurhash';
|
||||
import Blurhash from 'mastodon/components/blurhash';
|
||||
|
||||
const messages = defineMessages({
|
||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||
@ -19,7 +19,6 @@ const messages = defineMessages({
|
||||
close: { id: 'video.close', defaultMessage: 'Close video' },
|
||||
fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
|
||||
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
|
||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||
});
|
||||
|
||||
export const formatTime = secondsNum => {
|
||||
@ -87,6 +86,14 @@ export const getPointerPosition = (el, event) => {
|
||||
return position;
|
||||
};
|
||||
|
||||
export const fileNameFromURL = str => {
|
||||
const url = new URL(str);
|
||||
const pathname = url.pathname;
|
||||
const index = pathname.lastIndexOf('/');
|
||||
|
||||
return pathname.substring(index + 1);
|
||||
};
|
||||
|
||||
export default @injectIntl
|
||||
class Video extends React.PureComponent {
|
||||
|
||||
@ -126,29 +133,26 @@ class Video extends React.PureComponent {
|
||||
revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
|
||||
};
|
||||
|
||||
// Hard-coded in components.scss
|
||||
// Any way to get ::before values programatically?
|
||||
volWidth = 50;
|
||||
volOffset = 70;
|
||||
|
||||
volHandleOffset = v => {
|
||||
const offset = v * this.volWidth + this.volOffset;
|
||||
|
||||
return (offset > 110) ? 110 : offset;
|
||||
}
|
||||
|
||||
setPlayerRef = c => {
|
||||
this.player = c;
|
||||
|
||||
if (c) {
|
||||
if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
|
||||
|
||||
this.setState({
|
||||
containerWidth: c.offsetWidth,
|
||||
});
|
||||
if (this.player) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}
|
||||
|
||||
_setDimensions () {
|
||||
const width = this.player.offsetWidth;
|
||||
|
||||
if (this.props.cacheWidth) {
|
||||
this.props.cacheWidth(width);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
containerWidth: width,
|
||||
});
|
||||
}
|
||||
|
||||
setVideoRef = c => {
|
||||
this.video = c;
|
||||
|
||||
@ -165,23 +169,32 @@ class Video extends React.PureComponent {
|
||||
this.volume = c;
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
}
|
||||
|
||||
handleClickRoot = e => e.stopPropagation();
|
||||
|
||||
handlePlay = () => {
|
||||
this.setState({ paused: false });
|
||||
this._updateTime();
|
||||
}
|
||||
|
||||
handlePause = () => {
|
||||
this.setState({ paused: true });
|
||||
}
|
||||
|
||||
_updateTime () {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.video) return;
|
||||
|
||||
this.handleTimeUpdate();
|
||||
|
||||
if (!this.state.paused) {
|
||||
this._updateTime();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleTimeUpdate = () => {
|
||||
this.setState({
|
||||
currentTime: Math.floor(this.video.currentTime),
|
||||
currentTime: this.video.currentTime,
|
||||
duration: Math.floor(this.video.duration),
|
||||
});
|
||||
}
|
||||
@ -206,22 +219,14 @@ class Video extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleMouseVolSlide = throttle(e => {
|
||||
const rect = this.volume.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
|
||||
const { x } = getPointerPosition(this.volume, e);
|
||||
|
||||
if(!isNaN(x)) {
|
||||
let slideamt = x;
|
||||
|
||||
if(x > 1) {
|
||||
slideamt = 1;
|
||||
} else if(x < 0) {
|
||||
slideamt = 0;
|
||||
}
|
||||
|
||||
this.video.volume = slideamt;
|
||||
this.setState({ volume: slideamt });
|
||||
this.setState({ volume: x }, () => {
|
||||
this.video.volume = x;
|
||||
});
|
||||
}
|
||||
}, 60);
|
||||
}, 15);
|
||||
|
||||
handleMouseDown = e => {
|
||||
document.addEventListener('mousemove', this.handleMouseMove, true);
|
||||
@ -249,13 +254,14 @@ class Video extends React.PureComponent {
|
||||
|
||||
handleMouseMove = throttle(e => {
|
||||
const { x } = getPointerPosition(this.seek, e);
|
||||
const currentTime = Math.floor(this.video.duration * x);
|
||||
const currentTime = this.video.duration * x;
|
||||
|
||||
if (!isNaN(currentTime)) {
|
||||
this.video.currentTime = currentTime;
|
||||
this.setState({ currentTime });
|
||||
this.setState({ currentTime }, () => {
|
||||
this.video.currentTime = currentTime;
|
||||
});
|
||||
}
|
||||
}, 60);
|
||||
}, 15);
|
||||
|
||||
togglePlay = () => {
|
||||
if (this.state.paused) {
|
||||
@ -280,14 +286,12 @@ class Video extends React.PureComponent {
|
||||
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
||||
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
|
||||
if (this.props.blurhash) {
|
||||
this._decode();
|
||||
}
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
|
||||
document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
|
||||
document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
|
||||
@ -305,25 +309,15 @@ class Video extends React.PureComponent {
|
||||
if (prevState.revealed && !this.state.revealed && this.video) {
|
||||
this.video.pause();
|
||||
}
|
||||
|
||||
if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
_decode () {
|
||||
if (!useBlurhash) return;
|
||||
|
||||
const hash = this.props.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);
|
||||
handleResize = debounce(() => {
|
||||
if (this.player) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}
|
||||
}, 250, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
handleScroll = throttle(() => {
|
||||
if (!this.video) {
|
||||
@ -381,8 +375,10 @@ class Video extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleProgress = () => {
|
||||
if (this.video.buffered.length > 0) {
|
||||
this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
|
||||
const lastTimeRange = this.video.buffered.length - 1;
|
||||
|
||||
if (lastTimeRange > -1) {
|
||||
this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) });
|
||||
}
|
||||
}
|
||||
|
||||
@ -418,12 +414,9 @@ class Video extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable } = this.props;
|
||||
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable, blurhash } = this.props;
|
||||
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
||||
const progress = (currentTime / duration) * 100;
|
||||
|
||||
const volumeWidth = (muted) ? 0 : volume * this.volWidth;
|
||||
const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);
|
||||
const playerStyle = {};
|
||||
|
||||
let { width, height } = this.props;
|
||||
@ -464,7 +457,13 @@ class Video extends React.PureComponent {
|
||||
onClick={this.handleClickRoot}
|
||||
tabIndex={0}
|
||||
>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': revealed,
|
||||
})}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
|
||||
{(revealed || editable) && <video
|
||||
ref={this.setVideoRef}
|
||||
@ -481,7 +480,6 @@ class Video extends React.PureComponent {
|
||||
onClick={this.togglePlay}
|
||||
onPlay={this.handlePlay}
|
||||
onPause={this.handlePause}
|
||||
onTimeUpdate={this.handleTimeUpdate}
|
||||
onLoadedData={this.handleLoadedData}
|
||||
onProgress={this.handleProgress}
|
||||
onVolumeChange={this.handleVolumeChange}
|
||||
@ -510,19 +508,19 @@ class Video extends React.PureComponent {
|
||||
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
|
||||
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
|
||||
|
||||
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
||||
|
||||
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
|
||||
<div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
||||
<div className='video-player__volume__current' style={{ width: `${volume * 100}%` }} />
|
||||
|
||||
<span
|
||||
className={classNames('video-player__volume__handle')}
|
||||
tabIndex='0'
|
||||
style={{ left: `${volumeHandleLoc}px` }}
|
||||
style={{ left: `${volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(detailed || fullscreen) && (
|
||||
<span>
|
||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||
<span className='video-player__time'>
|
||||
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>{formatTime(duration)}</span>
|
||||
</span>
|
||||
@ -535,7 +533,6 @@ class Video extends React.PureComponent {
|
||||
{(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
|
||||
{(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
|
||||
{onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
|
||||
<button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}><a className='video-player__download__icon' href={this.props.src} download><Icon id={'download'} fixedWidth /></a></button>
|
||||
<button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,10 +1,12 @@
|
||||
{
|
||||
"account.account_note_header": "ملاحظة",
|
||||
"account.add_or_remove_from_list": "أضفه أو أزله من القائمة",
|
||||
"account.badges.bot": "روبوت",
|
||||
"account.badges.group": "فريق",
|
||||
"account.block": "حظر @{name}",
|
||||
"account.block_domain": "إخفاء كل شيء قادم من اسم النطاق {domain}",
|
||||
"account.blocked": "محظور",
|
||||
"account.browse_more_on_origin_server": "تصفح المزيد على الملف التعريفي الأصلي",
|
||||
"account.cancel_follow_request": "إلغاء طلب المتابَعة",
|
||||
"account.direct": "رسالة خاصة إلى @{name}",
|
||||
"account.domain_blocked": "النطاق مخفي",
|
||||
@ -13,7 +15,8 @@
|
||||
"account.follow": "تابِع",
|
||||
"account.followers": "مُتابِعون",
|
||||
"account.followers.empty": "لا أحد يتبع هذا الحساب بعد.",
|
||||
"account.follows": "يتابع",
|
||||
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
|
||||
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
|
||||
"account.follows.empty": "هذا الحساب لا يتبع أحدًا بعد.",
|
||||
"account.follows_you": "يتابعك",
|
||||
"account.hide_reblogs": "إخفاء ترقيات @{name}",
|
||||
@ -33,12 +36,14 @@
|
||||
"account.requested": "في انتظار الموافقة. اضْغَطْ/ي لإلغاء طلب المتابعة",
|
||||
"account.share": "شارك ملف تعريف @{name}",
|
||||
"account.show_reblogs": "اعرض ترقيات @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
|
||||
"account.unblock": "إلغاء الحظر عن @{name}",
|
||||
"account.unblock_domain": "فك الخْفى عن {domain}",
|
||||
"account.unendorse": "أزل ترويجه مِن الملف التعريفي",
|
||||
"account.unfollow": "إلغاء المتابعة",
|
||||
"account.unmute": "إلغاء الكتم عن @{name}",
|
||||
"account.unmute_notifications": "إلغاء كتم إخطارات @{name}",
|
||||
"account_note.placeholder": "انقر لإضافة ملاحظة",
|
||||
"alert.rate_limited.message": "يرجى إعادة المحاولة بعد {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "المعدل محدود",
|
||||
"alert.unexpected.message": "لقد طرأ هناك خطأ غير متوقّع.",
|
||||
@ -74,9 +79,9 @@
|
||||
"column_header.show_settings": "عرض الإعدادات",
|
||||
"column_header.unpin": "فك التدبيس",
|
||||
"column_subheading.settings": "الإعدادات",
|
||||
"community.column_settings.local_only": "Local only",
|
||||
"community.column_settings.local_only": "المحلي فقط",
|
||||
"community.column_settings.media_only": "الوسائط فقط",
|
||||
"community.column_settings.remote_only": "Remote only",
|
||||
"community.column_settings.remote_only": "عن بُعد فقط",
|
||||
"compose_form.direct_message_warning": "لن يَظهر هذا التبويق إلا للمستخدمين المذكورين.",
|
||||
"compose_form.direct_message_warning_learn_more": "اقرأ المزيد",
|
||||
"compose_form.hashtag_warning": "هذا التبويق لن يُدرَج تحت أي وسم كان بما أنه غير مُدرَج. لا يُسمح بالبحث إلّا عن التبويقات العمومية عن طريق الوسوم.",
|
||||
@ -110,7 +115,7 @@
|
||||
"confirmations.logout.confirm": "خروج",
|
||||
"confirmations.logout.message": "متأكد من أنك تريد الخروج؟",
|
||||
"confirmations.mute.confirm": "أكتم",
|
||||
"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.explanation": "هذا سيخفي المنشورات عنهم وتلك المشار فيها إليهم، لكنه سيسمح لهم برؤية منشوراتك ومتابعتك.",
|
||||
"confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
|
||||
"confirmations.redraft.confirm": "إزالة و إعادة الصياغة",
|
||||
"confirmations.redraft.message": "هل أنت متأكد من أنك تريد حذف هذا المنشور و إعادة صياغته؟ سوف تفقد جميع الإعجابات و الترقيات أما الردود المتصلة به فستُصبِح يتيمة.",
|
||||
@ -160,7 +165,7 @@
|
||||
"empty_column.mutes": "لم تقم بكتم أي مستخدم بعد.",
|
||||
"empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.",
|
||||
"empty_column.public": "لا يوجد أي شيء هنا! قم بنشر شيء ما للعامة، أو اتبع المستخدمين الآخرين المتواجدين على الخوادم الأخرى لملء خيط المحادثات",
|
||||
"error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
|
||||
"error.unexpected_crash.explanation": "نظرا لوجود خطأ في التعليمات البرمجية أو مشكلة توافق مع المتصفّح، تعذر عرض هذه الصفحة بشكل صحيح.",
|
||||
"error.unexpected_crash.next_steps": "حاول إعادة إنعاش الصفحة. إن لم تُحلّ المشكلة ، يمكنك دائمًا استخدام ماستدون عبر متصفّح آخر أو تطبيق أصلي.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "انسخ تتبع الارتباطات إلى الحافظة",
|
||||
"errors.unexpected_crash.report_issue": "الإبلاغ عن خلل",
|
||||
@ -172,6 +177,7 @@
|
||||
"follow_request.authorize": "ترخيص",
|
||||
"follow_request.reject": "رفض",
|
||||
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
|
||||
"generic.saved": "تم الحفظ",
|
||||
"getting_started.developers": "المُطوِّرون",
|
||||
"getting_started.directory": "دليل الصفحات التعريفية",
|
||||
"getting_started.documentation": "الدليل",
|
||||
@ -241,6 +247,7 @@
|
||||
"keyboard_shortcuts.reply": "للردّ",
|
||||
"keyboard_shortcuts.requests": "لفتح قائمة طلبات المتابعة",
|
||||
"keyboard_shortcuts.search": "للتركيز على البحث",
|
||||
"keyboard_shortcuts.spoilers": "لإظهار/إخفاء حقلCW",
|
||||
"keyboard_shortcuts.start": "لفتح عمود \"هيا نبدأ\"",
|
||||
"keyboard_shortcuts.toggle_hidden": "لعرض أو إخفاء النص مِن وراء التحذير",
|
||||
"keyboard_shortcuts.toggle_sensitivity": "لعرض/إخفاء الوسائط",
|
||||
@ -418,9 +425,16 @@
|
||||
"time_remaining.minutes": "{number, plural, one {# دقيقة} other {# دقائق}} متبقية",
|
||||
"time_remaining.moments": "لحظات متبقية",
|
||||
"time_remaining.seconds": "{number, plural, one {# ثانية} other {# ثوانٍ}} متبقية",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, zero {} one {شخص واحد} two {شخصين} few {أشخاص} many {أشخاص} other {أشخاص}} تتحدّث",
|
||||
"timeline_hint.remote_resource_not_displayed": "{resource} من الخوادم الأخرى لا يتم عرضها.",
|
||||
"timeline_hint.resources.followers": "المتابِعون",
|
||||
"timeline_hint.resources.follows": "المتابَعون",
|
||||
"timeline_hint.resources.statuses": "التبويقات القديمة",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
|
||||
"trends.trending_now": "المتداولة الآن",
|
||||
"ui.beforeunload": "سوف تفقد مسودتك إن تركت ماستدون.",
|
||||
"units.short.billion": "{count} مليار",
|
||||
"units.short.million": "{count} مليون",
|
||||
"units.short.thousand": "{count} ألف",
|
||||
"upload_area.title": "اسحب ثم أفلت للرفع",
|
||||
"upload_button.label": "إضافة وسائط ({formats})",
|
||||
"upload_error.limit": "لقد تم بلوغ الحد الأقصى المسموح به لإرسال الملفات.",
|
||||
@ -428,10 +442,12 @@
|
||||
"upload_form.audio_description": "وصف للأشخاص ذي قِصر السمع",
|
||||
"upload_form.description": "وصف للمعاقين بصريا",
|
||||
"upload_form.edit": "تعديل",
|
||||
"upload_form.thumbnail": "غيّر الصورة المصغرة",
|
||||
"upload_form.undo": "حذف",
|
||||
"upload_form.video_description": "وصف للمعاقين بصريا أو لِذي قِصر السمع",
|
||||
"upload_modal.analyzing_picture": "جارٍ فحص الصورة…",
|
||||
"upload_modal.apply": "طبّق",
|
||||
"upload_modal.choose_image": "اختر صورة",
|
||||
"upload_modal.description_placeholder": "نصٌّ حكيمٌ لهُ سِرٌّ قاطِعٌ وَذُو شَأنٍ عَظيمٍ مكتوبٌ على ثوبٍ أخضرَ ومُغلفٌ بجلدٍ أزرق",
|
||||
"upload_modal.detect_text": "اكتشف النص مِن الصورة",
|
||||
"upload_modal.edit_media": "تعديل الوسائط",
|
||||
|
@ -1,10 +1,12 @@
|
||||
{
|
||||
"account.add_or_remove_from_list": "Add or Remove from lists",
|
||||
"account.account_note_header": "Note",
|
||||
"account.add_or_remove_from_list": "Amestar o desaniciar de les llistes",
|
||||
"account.badges.bot": "Robó",
|
||||
"account.badges.group": "Grupu",
|
||||
"account.block": "Bloquiar a @{name}",
|
||||
"account.block_domain": "Anubrir tolo de {domain}",
|
||||
"account.blocked": "Blocked",
|
||||
"account.blocked": "Bloquiada",
|
||||
"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",
|
||||
@ -13,12 +15,13 @@
|
||||
"account.follow": "Siguir",
|
||||
"account.followers": "Siguidores",
|
||||
"account.followers.empty": "Naide sigue a esti usuariu entá.",
|
||||
"account.follows": "Follows",
|
||||
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
|
||||
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
|
||||
"account.follows.empty": "Esti usuariu entá nun sigue a naide.",
|
||||
"account.follows_you": "Síguete",
|
||||
"account.hide_reblogs": "Anubrir les comparticiones de @{name}",
|
||||
"account.last_status": "Last active",
|
||||
"account.link_verified_on": "Ownership of this link was checked on {date}",
|
||||
"account.last_status": "Cabera actividá",
|
||||
"account.link_verified_on": "La propiedá d'esti enllaz foi comprobada'l {date}",
|
||||
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
|
||||
"account.media": "Media",
|
||||
"account.mention": "Mentar a @{name}",
|
||||
@ -33,12 +36,14 @@
|
||||
"account.requested": "Esperando pola aprobación. Calca pa encaboxar la solicitú de siguimientu",
|
||||
"account.share": "Share @{name}'s profile",
|
||||
"account.show_reblogs": "Amosar les comparticiones de @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
|
||||
"account.unblock": "Desbloquiar a @{name}",
|
||||
"account.unblock_domain": "Amosar {domain}",
|
||||
"account.unendorse": "Nun destacar nel perfil",
|
||||
"account.unfollow": "Dexar de siguir",
|
||||
"account.unmute": "Unmute @{name}",
|
||||
"account.unmute_notifications": "Unmute notifications from @{name}",
|
||||
"account_note.placeholder": "Click to add a note",
|
||||
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "Rate limited",
|
||||
"alert.unexpected.message": "Asocedió un fallu inesperáu.",
|
||||
@ -172,6 +177,7 @@
|
||||
"follow_request.authorize": "Autorizar",
|
||||
"follow_request.reject": "Refugar",
|
||||
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
|
||||
"generic.saved": "Saved",
|
||||
"getting_started.developers": "Desendolcadores",
|
||||
"getting_started.directory": "Direutoriu de perfiles",
|
||||
"getting_started.documentation": "Documentación",
|
||||
@ -241,6 +247,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",
|
||||
@ -417,20 +424,29 @@
|
||||
"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}}",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {persones}} falando",
|
||||
"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.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
|
||||
"trends.trending_now": "Trending now",
|
||||
"ui.beforeunload": "El borrador va perdese si coles de Mastodon.",
|
||||
"units.short.billion": "{count}B",
|
||||
"units.short.million": "{count}M",
|
||||
"units.short.thousand": "{count}K",
|
||||
"upload_area.title": "Arrastra y suelta pa xubir",
|
||||
"upload_button.label": "Add media ({formats})",
|
||||
"upload_button.label": "Add images, a video or an audio file",
|
||||
"upload_error.limit": "File upload limit exceeded.",
|
||||
"upload_error.poll": "La xuba de ficheros nun ta permitida con encuestes.",
|
||||
"upload_form.audio_description": "Descripción pa persones con perda auditiva",
|
||||
"upload_form.description": "Descripción pa discapacitaos visuales",
|
||||
"upload_form.edit": "Editar",
|
||||
"upload_form.thumbnail": "Change thumbnail",
|
||||
"upload_form.undo": "Desaniciar",
|
||||
"upload_form.video_description": "Descripción pa persones con perda auditiva o discapacidá visual",
|
||||
"upload_modal.analyzing_picture": "Analizando la semeya…",
|
||||
"upload_modal.apply": "Aplicar",
|
||||
"upload_modal.choose_image": "Choose image",
|
||||
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
|
||||
"upload_modal.detect_text": "Deteutar el testu de la semeya",
|
||||
"upload_modal.edit_media": "Edición",
|
||||
|
@ -1,10 +1,12 @@
|
||||
{
|
||||
"account.account_note_header": "Note",
|
||||
"account.add_or_remove_from_list": "Добави или премахни от списъците",
|
||||
"account.badges.bot": "бот",
|
||||
"account.badges.group": "Group",
|
||||
"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": "Скрит домейн",
|
||||
@ -13,7 +15,8 @@
|
||||
"account.follow": "Последвай",
|
||||
"account.followers": "Последователи",
|
||||
"account.followers.empty": "Все още никой не следва този потребител.",
|
||||
"account.follows": "Следвам",
|
||||
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
|
||||
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
|
||||
"account.follows.empty": "Този потребител все още не следва никого.",
|
||||
"account.follows_you": "Твой последовател",
|
||||
"account.hide_reblogs": "Hide boosts from @{name}",
|
||||
@ -33,12 +36,14 @@
|
||||
"account.requested": "В очакване на одобрение",
|
||||
"account.share": "Share @{name}'s profile",
|
||||
"account.show_reblogs": "Show boosts from @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
|
||||
"account.unblock": "Не блокирай",
|
||||
"account.unblock_domain": "Unhide {domain}",
|
||||
"account.unendorse": "Don't feature on profile",
|
||||
"account.unfollow": "Не следвай",
|
||||
"account.unmute": "Unmute @{name}",
|
||||
"account.unmute_notifications": "Unmute notifications from @{name}",
|
||||
"account_note.placeholder": "Click to add a note",
|
||||
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "Rate limited",
|
||||
"alert.unexpected.message": "An unexpected error occurred.",
|
||||
@ -172,6 +177,7 @@
|
||||
"follow_request.authorize": "Authorize",
|
||||
"follow_request.reject": "Reject",
|
||||
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
|
||||
"generic.saved": "Saved",
|
||||
"getting_started.developers": "Developers",
|
||||
"getting_started.directory": "Profile directory",
|
||||
"getting_started.documentation": "Documentation",
|
||||
@ -241,6 +247,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",
|
||||
@ -262,7 +269,7 @@
|
||||
"lists.subheading": "Your lists",
|
||||
"load_pending": "{count, plural, one {# new item} other {# new items}}",
|
||||
"loading_indicator.label": "Зареждане...",
|
||||
"media_gallery.toggle_visible": "Hide media",
|
||||
"media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
|
||||
"missing_indicator.label": "Not found",
|
||||
"missing_indicator.sublabel": "This resource could not be found",
|
||||
"mute_modal.hide_notifications": "Hide notifications from this user?",
|
||||
@ -418,9 +425,16 @@
|
||||
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
|
||||
"time_remaining.moments": "Moments remaining",
|
||||
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
|
||||
"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.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
|
||||
"trends.trending_now": "Trending now",
|
||||
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
|
||||
"units.short.billion": "{count}B",
|
||||
"units.short.million": "{count}M",
|
||||
"units.short.thousand": "{count}K",
|
||||
"upload_area.title": "Drag & drop to upload",
|
||||
"upload_button.label": "Добави медия",
|
||||
"upload_error.limit": "File upload limit exceeded.",
|
||||
@ -428,10 +442,12 @@
|
||||
"upload_form.audio_description": "Describe for people with hearing loss",
|
||||
"upload_form.description": "Describe for the visually impaired",
|
||||
"upload_form.edit": "Edit",
|
||||
"upload_form.thumbnail": "Change thumbnail",
|
||||
"upload_form.undo": "Отмяна",
|
||||
"upload_form.video_description": "Describe for people with hearing loss or visual impairment",
|
||||
"upload_modal.analyzing_picture": "Analyzing picture…",
|
||||
"upload_modal.apply": "Apply",
|
||||
"upload_modal.choose_image": "Choose image",
|
||||
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
|
||||
"upload_modal.detect_text": "Detect text from picture",
|
||||
"upload_modal.edit_media": "Edit media",
|
||||
|
@ -1,10 +1,12 @@
|
||||
{
|
||||
"account.account_note_header": "Note",
|
||||
"account.add_or_remove_from_list": "তালিকাতে যুক্ত বা অপসারণ করুন",
|
||||
"account.badges.bot": "বট",
|
||||
"account.badges.group": "Group",
|
||||
"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": "ডোমেন গোপন করুন",
|
||||
@ -13,7 +15,8 @@
|
||||
"account.follow": "অনুসরণ করুন",
|
||||
"account.followers": "অনুসরণকারী",
|
||||
"account.followers.empty": "এই সদস্যকে এখনো কেউ অনুসরণ করে না।.",
|
||||
"account.follows": "যাদেরকে অনুসরণ করেন",
|
||||
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
|
||||
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
|
||||
"account.follows.empty": "এই সদস্য কাওকে এখনো অনুসরণ করেন না.",
|
||||
"account.follows_you": "আপনাকে অনুসরণ করে",
|
||||
"account.hide_reblogs": "@{name}'র সমর্থনগুলি লুকিয়ে ফেলুন",
|
||||
@ -33,12 +36,14 @@
|
||||
"account.requested": "অনুমতির অপেক্ষা। অনুসরণ করার অনুরোধ বাতিল করতে এখানে ক্লিক করুন",
|
||||
"account.share": "@{name} র প্রোফাইল অন্যদের দেখান",
|
||||
"account.show_reblogs": "@{name} র সমর্থনগুলো দেখান",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
|
||||
"account.unblock": "@{name} র কার্যকলাপ দেখুন",
|
||||
"account.unblock_domain": "{domain} কে আবার দেখুন",
|
||||
"account.unendorse": "আপনার নিজের পাতায় এটা দেখবেন না",
|
||||
"account.unfollow": "অনুসরণ না করতে",
|
||||
"account.unmute": "@{name} র কার্যকলাপ আবার দেখুন",
|
||||
"account.unmute_notifications": "@{name} র প্রজ্ঞাপন দেখুন",
|
||||
"account_note.placeholder": "Click to add a note",
|
||||
"alert.rate_limited.message": "{retry_time, time, medium} -এর পরে আবার প্রচেষ্টা করুন।",
|
||||
"alert.rate_limited.title": "হার সীমিত",
|
||||
"alert.unexpected.message": "সমস্যা অপ্রত্যাশিত.",
|
||||
@ -172,6 +177,7 @@
|
||||
"follow_request.authorize": "অনুমতি দিন",
|
||||
"follow_request.reject": "প্রত্যাখ্যান করুন",
|
||||
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
|
||||
"generic.saved": "Saved",
|
||||
"getting_started.developers": "তৈরিকারকদের জন্য",
|
||||
"getting_started.directory": "নিজস্ব-পাতাগুলির তালিকা",
|
||||
"getting_started.documentation": "নথিপত্র",
|
||||
@ -241,6 +247,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,9 +424,16 @@
|
||||
"time_remaining.minutes": "{number, plural, one {# মিনিট} other {# মিনিট}} বাকি আছে",
|
||||
"time_remaining.moments": "সময় বাকি আছে",
|
||||
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} বাকি আছে",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} কথা বলছে",
|
||||
"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.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
|
||||
"trends.trending_now": "বর্তমানে জনপ্রিয়",
|
||||
"ui.beforeunload": "যে পর্যন্ত এটা লেখা হয়েছে, মাস্টাডন থেকে চলে গেলে এটা মুছে যাবে।",
|
||||
"units.short.billion": "{count}B",
|
||||
"units.short.million": "{count}M",
|
||||
"units.short.thousand": "{count}K",
|
||||
"upload_area.title": "টেনে এখানে ছেড়ে দিলে এখানে যুক্ত করা যাবে",
|
||||
"upload_button.label": "ছবি বা ভিডিও যুক্ত করতে (এসব ধরণের: JPEG, PNG, GIF, WebM, MP4, MOV)",
|
||||
"upload_error.limit": "যা যুক্ত করতে চাচ্ছেন সেটি বেশি বড়, এখানকার সর্বাধিকের মেমোরির উপরে চলে গেছে।",
|
||||
@ -427,10 +441,12 @@
|
||||
"upload_form.audio_description": "Describe for people with hearing loss",
|
||||
"upload_form.description": "যারা দেখতে পায়না তাদের জন্য এটা বর্ণনা করতে",
|
||||
"upload_form.edit": "সম্পাদন",
|
||||
"upload_form.thumbnail": "Change thumbnail",
|
||||
"upload_form.undo": "মুছে ফেলতে",
|
||||
"upload_form.video_description": "Describe for people with hearing loss or visual impairment",
|
||||
"upload_modal.analyzing_picture": "চিত্র বিশ্লেষণ করা হচ্ছে…",
|
||||
"upload_modal.apply": "প্রয়োগ করুন",
|
||||
"upload_modal.choose_image": "Choose image",
|
||||
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
|
||||
"upload_modal.detect_text": "ছবি থেকে পাঠ্য সনাক্ত করুন",
|
||||
"upload_modal.edit_media": "মিডিয়া সম্পাদনা করুন",
|
||||
|
@ -1,10 +1,12 @@
|
||||
{
|
||||
"account.account_note_header": "Notenn",
|
||||
"account.add_or_remove_from_list": "Ouzhpenn pe dilemel eus al listennadoù",
|
||||
"account.badges.bot": "Robot",
|
||||
"account.badges.group": "Strollad",
|
||||
"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",
|
||||
@ -13,7 +15,8 @@
|
||||
"account.follow": "Heuliañ",
|
||||
"account.followers": "Heulier·ezed·ien",
|
||||
"account.followers.empty": "Den na heul an implijer-mañ c'hoazh.",
|
||||
"account.follows": "Koumanantoù",
|
||||
"account.followers_counter": "{count, plural, other{{counter} Heulier}}",
|
||||
"account.following_counter": "{count, plural, other {{counter} Heuliañ}}",
|
||||
"account.follows.empty": "An implijer·ez-mañ na heul den ebet.",
|
||||
"account.follows_you": "Ho heul",
|
||||
"account.hide_reblogs": "Kuzh toudoù rannet gant @{name}",
|
||||
@ -33,12 +36,14 @@
|
||||
"account.requested": "O c'hortoz an asant. Klikit evit nullañ ar goulenn heuliañ",
|
||||
"account.share": "Skignañ profil @{name}",
|
||||
"account.show_reblogs": "Diskouez skignadennoù @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
|
||||
"account.unblock": "Diverzañ @{name}",
|
||||
"account.unblock_domain": "Diverzañ an domani {domain}",
|
||||
"account.unendorse": "Paouez da lakaat war-wel war ar profil",
|
||||
"account.unfollow": "Diheuliañ",
|
||||
"account.unmute": "Diguzhat @{name}",
|
||||
"account.unmute_notifications": "Diguzhat kemennoù a @{name}",
|
||||
"account_note.placeholder": "Klikit evit ouzhpenniñ un notenn",
|
||||
"alert.rate_limited.message": "Klaskit en-dro a-benn {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "Feur bevennet",
|
||||
"alert.unexpected.message": "Ur fazi dic'hortozet zo degouezhet.",
|
||||
@ -74,9 +79,9 @@
|
||||
"column_header.show_settings": "Diskouez an arventennoù",
|
||||
"column_header.unpin": "Dispilhennañ",
|
||||
"column_subheading.settings": "Arventennoù",
|
||||
"community.column_settings.local_only": "Local only",
|
||||
"community.column_settings.local_only": "Nemet lec'hel",
|
||||
"community.column_settings.media_only": "Nemet Mediaoù",
|
||||
"community.column_settings.remote_only": "Remote only",
|
||||
"community.column_settings.remote_only": "Nemet a-bell",
|
||||
"compose_form.direct_message_warning": "An toud-mañ a vo kaset nemet d'an implijer·ezed·ien meneget.",
|
||||
"compose_form.direct_message_warning_learn_more": "Gouzout hiroc'h",
|
||||
"compose_form.hashtag_warning": "Ne vo ket lakaet an toud-mañ er rolloù gerioù-klik dre mard eo anlistennet. N'eus nemet an toudoù foran a c'hall bezañ klasket dre c'her-klik.",
|
||||
@ -167,12 +172,13 @@
|
||||
"follow_request.authorize": "Aotren",
|
||||
"follow_request.reject": "Nac'hañ",
|
||||
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
|
||||
"generic.saved": "Enrollet",
|
||||
"getting_started.developers": "Diorroerien",
|
||||
"getting_started.directory": "Roll ar profiloù",
|
||||
"getting_started.documentation": "Teuliadur",
|
||||
"getting_started.heading": "Loc'hañ",
|
||||
"getting_started.invite": "Pediñ tud",
|
||||
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
|
||||
"getting_started.open_source_notice": "Mastodoñ zo ur meziant digor e darzh. Gallout a rit kenoberzhiañ dezhañ pe danevellañ kudennoù war GitHub e {github}.",
|
||||
"getting_started.security": "Arventennoù ar gont",
|
||||
"getting_started.terms": "Divizoù gwerzhañ hollek",
|
||||
"hashtag.column_header.tag_mode.all": "ha {additional}",
|
||||
@ -187,14 +193,14 @@
|
||||
"home.column_settings.basic": "Diazez",
|
||||
"home.column_settings.show_reblogs": "Diskouez ar skignadennoù",
|
||||
"home.column_settings.show_replies": "Diskouez ar respontoù",
|
||||
"home.hide_announcements": "Hide announcements",
|
||||
"home.show_announcements": "Show announcements",
|
||||
"home.hide_announcements": "Kuzhat ar c'hemennoù",
|
||||
"home.show_announcements": "Diskouez ar c'hemennoù",
|
||||
"intervals.full.days": "{number, plural, one {# devezh} other{# a zevezhioù}}",
|
||||
"intervals.full.hours": "{number, plural, one {# eurvezh} other{# eurvezh}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# munut} other{# a vunutoù}}",
|
||||
"introduction.federation.action": "Da-heul",
|
||||
"introduction.federation.federated.headline": "Kevreet",
|
||||
"introduction.federation.federated.text": "Public posts from other servers of the fediverse will appear in the federated timeline.",
|
||||
"introduction.federation.federated.text": "Embannadennoù publik eus dafariaded all ar c'hevrebed a yo war-wel er red-amzer kevredet.",
|
||||
"introduction.federation.home.headline": "Degemer",
|
||||
"introduction.federation.home.text": "Posts from people you follow will appear in your home feed. You can follow anyone on any server!",
|
||||
"introduction.federation.local.headline": "Lec'hel",
|
||||
@ -217,13 +223,13 @@
|
||||
"keyboard_shortcuts.description": "Deskrivadur",
|
||||
"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": "evit digeriñ un toud",
|
||||
"keyboard_shortcuts.favourite": "to favourite",
|
||||
"keyboard_shortcuts.favourites": "to open favourites list",
|
||||
"keyboard_shortcuts.federated": "to open federated timeline",
|
||||
"keyboard_shortcuts.heading": "Keyboard Shortcuts",
|
||||
"keyboard_shortcuts.home": "to open home timeline",
|
||||
"keyboard_shortcuts.hotkey": "Hotkey",
|
||||
"keyboard_shortcuts.hotkey": "Berradur",
|
||||
"keyboard_shortcuts.legend": "to display this legend",
|
||||
"keyboard_shortcuts.local": "to open local timeline",
|
||||
"keyboard_shortcuts.mention": "to mention author",
|
||||
@ -236,16 +242,17 @@
|
||||
"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",
|
||||
"keyboard_shortcuts.toot": "to start a brand new toot",
|
||||
"keyboard_shortcuts.toggle_sensitivity": "da guzhat/ziguzhat ur media",
|
||||
"keyboard_shortcuts.toot": "da gregiñ gant un toud nevez-flamm",
|
||||
"keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
|
||||
"keyboard_shortcuts.up": "to move up in the list",
|
||||
"lightbox.close": "Serriñ",
|
||||
"lightbox.next": "Next",
|
||||
"lightbox.previous": "Previous",
|
||||
"lightbox.view_context": "View context",
|
||||
"lightbox.next": "Da-heul",
|
||||
"lightbox.previous": "A-raok",
|
||||
"lightbox.view_context": "Diskouez ar c'hemperzh",
|
||||
"lists.account.add": "Ouzhpennañ d'al listenn",
|
||||
"lists.account.remove": "Lemel kuit eus al listenn",
|
||||
"lists.delete": "Dilemel al listenn",
|
||||
@ -291,14 +298,14 @@
|
||||
"notification.own_poll": "Your poll has ended",
|
||||
"notification.poll": "A poll you have voted in has ended",
|
||||
"notification.reblog": "{name} boosted your status",
|
||||
"notifications.clear": "Clear notifications",
|
||||
"notifications.clear": "Skarzhañ ar c'hemennoù",
|
||||
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
|
||||
"notifications.column_settings.alert": "Desktop notifications",
|
||||
"notifications.column_settings.alert": "Kemennoù war ar burev",
|
||||
"notifications.column_settings.favourite": "Ar re vuiañ-karet:",
|
||||
"notifications.column_settings.filter_bar.advanced": "Display all categories",
|
||||
"notifications.column_settings.filter_bar.advanced": "Skrammañ an-holl rummadoù",
|
||||
"notifications.column_settings.filter_bar.category": "Barrenn siloù prim",
|
||||
"notifications.column_settings.filter_bar.show": "Diskouez",
|
||||
"notifications.column_settings.follow": "New followers:",
|
||||
"notifications.column_settings.follow": "Heulierien nevez:",
|
||||
"notifications.column_settings.follow_request": "New follow requests:",
|
||||
"notifications.column_settings.mention": "Menegoù:",
|
||||
"notifications.column_settings.poll": "Disoc'hoù ar sontadeg:",
|
||||
@ -309,7 +316,7 @@
|
||||
"notifications.filter.all": "Pep tra",
|
||||
"notifications.filter.boosts": "Skignadennoù",
|
||||
"notifications.filter.favourites": "Muiañ-karet",
|
||||
"notifications.filter.follows": "Follows",
|
||||
"notifications.filter.follows": "Heuliañ",
|
||||
"notifications.filter.mentions": "Menegoù",
|
||||
"notifications.filter.polls": "Disoc'hoù ar sontadegoù",
|
||||
"notifications.group": "{count} a gemennoù",
|
||||
@ -343,8 +350,8 @@
|
||||
"report.forward": "Treuzkas da: {target}",
|
||||
"report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
|
||||
"report.hint": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:",
|
||||
"report.placeholder": "Additional comments",
|
||||
"report.submit": "Submit",
|
||||
"report.placeholder": "Askelennoù ouzhpenn",
|
||||
"report.submit": "Kinnig",
|
||||
"report.target": "Report {target}",
|
||||
"search.placeholder": "Klask",
|
||||
"search_popout.search_format": "Advanced search format",
|
||||
@ -360,24 +367,24 @@
|
||||
"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.block": "Block @{name}",
|
||||
"status.block": "Berzañ @{name}",
|
||||
"status.bookmark": "Ouzhpennañ d'ar sinedoù",
|
||||
"status.cancel_reblog_private": "Unboost",
|
||||
"status.cannot_reblog": "This post cannot be boosted",
|
||||
"status.copy": "Copy link to status",
|
||||
"status.copy": "Eilañ liamm an toud",
|
||||
"status.delete": "Dilemel",
|
||||
"status.detailed_status": "Detailed conversation view",
|
||||
"status.direct": "Kas ur c'hemennad da @{name}",
|
||||
"status.embed": "Enframmañ",
|
||||
"status.favourite": "Muiañ-karet",
|
||||
"status.filtered": "Filtered",
|
||||
"status.filtered": "Silet",
|
||||
"status.load_more": "Kargañ muioc'h",
|
||||
"status.media_hidden": "Media kuzhet",
|
||||
"status.mention": "Menegiñ @{name}",
|
||||
"status.more": "Muioc'h",
|
||||
"status.mute": "Kuzhat @{name}",
|
||||
"status.mute_conversation": "Kuzhat ar gaozeadenn",
|
||||
"status.open": "Expand this status",
|
||||
"status.open": "Kreskaat an toud-mañ",
|
||||
"status.pin": "Spilhennañ d'ar profil",
|
||||
"status.pinned": "Toud spilhennet",
|
||||
"status.read_more": "Lenn muioc'h",
|
||||
@ -386,23 +393,23 @@
|
||||
"status.reblogged_by": "{name} boosted",
|
||||
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
|
||||
"status.redraft": "Delete & re-draft",
|
||||
"status.remove_bookmark": "Remove bookmark",
|
||||
"status.remove_bookmark": "Dilemel ar sined",
|
||||
"status.reply": "Respont",
|
||||
"status.replyAll": "Reply to thread",
|
||||
"status.replyAll": "Respont d'ar gaozeadenn",
|
||||
"status.report": "Disklêriañ @{name}",
|
||||
"status.sensitive_warning": "Sensitive content",
|
||||
"status.share": "Rannañ",
|
||||
"status.show_less": "Show less",
|
||||
"status.show_less": "Diskouez nebeutoc'h",
|
||||
"status.show_less_all": "Show less for all",
|
||||
"status.show_more": "Show more",
|
||||
"status.show_more": "Diskouez muioc'h",
|
||||
"status.show_more_all": "Show more for all",
|
||||
"status.show_thread": "Show thread",
|
||||
"status.uncached_media_warning": "Not available",
|
||||
"status.show_thread": "Diskouez ar gaozeadenn",
|
||||
"status.uncached_media_warning": "Dihegerz",
|
||||
"status.unmute_conversation": "Diguzhat ar gaozeadenn",
|
||||
"status.unpin": "Dispilhennañ eus ar profil",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"suggestions.header": "You might be interested in…",
|
||||
"tabs_bar.federated_timeline": "Federated",
|
||||
"tabs_bar.federated_timeline": "Kevredet",
|
||||
"tabs_bar.home": "Degemer",
|
||||
"tabs_bar.local_timeline": "Lec'hel",
|
||||
"tabs_bar.notifications": "Kemennoù",
|
||||
@ -412,9 +419,16 @@
|
||||
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
|
||||
"time_remaining.moments": "Moments remaining",
|
||||
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
|
||||
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
|
||||
"timeline_hint.resources.followers": "Heulier·ezed·ien",
|
||||
"timeline_hint.resources.follows": "Heuliañ",
|
||||
"timeline_hint.resources.statuses": "Toudoù koshoc'h",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
|
||||
"trends.trending_now": "Luskad ar mare",
|
||||
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
|
||||
"units.short.billion": "{count}B",
|
||||
"units.short.million": "{count}M",
|
||||
"units.short.thousand": "{count}K",
|
||||
"upload_area.title": "Drag & drop to upload",
|
||||
"upload_button.label": "Ouzhpennañ ur media ({formats})",
|
||||
"upload_error.limit": "File upload limit exceeded.",
|
||||
@ -422,22 +436,24 @@
|
||||
"upload_form.audio_description": "Describe for people with hearing loss",
|
||||
"upload_form.description": "Describe for the visually impaired",
|
||||
"upload_form.edit": "Aozañ",
|
||||
"upload_form.thumbnail": "Kemmañ ar velvenn",
|
||||
"upload_form.undo": "Dilemel",
|
||||
"upload_form.video_description": "Describe for people with hearing loss or visual impairment",
|
||||
"upload_modal.analyzing_picture": "Analyzing picture…",
|
||||
"upload_modal.apply": "Apply",
|
||||
"upload_modal.apply": "Arloañ",
|
||||
"upload_modal.choose_image": "Dibab ur skeudenn",
|
||||
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
|
||||
"upload_modal.detect_text": "Detect text from picture",
|
||||
"upload_modal.edit_media": "Edit media",
|
||||
"upload_modal.detect_text": "Dinoiñ testenn diouzh ar skeudenn",
|
||||
"upload_modal.edit_media": "Embann ar media",
|
||||
"upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
|
||||
"upload_modal.preview_label": "Preview ({ratio})",
|
||||
"upload_modal.preview_label": "Rakwel ({ratio})",
|
||||
"upload_progress.label": "O pellgargañ...",
|
||||
"video.close": "Close video",
|
||||
"video.close": "Serriñ ar video",
|
||||
"video.download": "Pellgargañ ar restr",
|
||||
"video.exit_fullscreen": "Exit full screen",
|
||||
"video.exit_fullscreen": "Kuitaat ar mod skramm leun",
|
||||
"video.expand": "Expand video",
|
||||
"video.fullscreen": "Full screen",
|
||||
"video.hide": "Hide video",
|
||||
"video.fullscreen": "Skramm a-bezh",
|
||||
"video.hide": "Kuzhat ar video",
|
||||
"video.mute": "Mute sound",
|
||||
"video.pause": "Pause",
|
||||
"video.play": "Play",
|
||||
|
@ -1,10 +1,12 @@
|
||||
{
|
||||
"account.account_note_header": "La teva nota per a @{name}",
|
||||
"account.add_or_remove_from_list": "Afegir o Treure de les llistes",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Grup",
|
||||
"account.block": "Bloqueja @{name}",
|
||||
"account.block_domain": "Amaga-ho tot de {domain}",
|
||||
"account.blocked": "Bloquejat",
|
||||
"account.browse_more_on_origin_server": "Navega més en el perfil original",
|
||||
"account.cancel_follow_request": "Anul·la la sol·licitud de seguiment",
|
||||
"account.direct": "Missatge directe @{name}",
|
||||
"account.domain_blocked": "Domini ocult",
|
||||
@ -13,7 +15,8 @@
|
||||
"account.follow": "Segueix",
|
||||
"account.followers": "Seguidors",
|
||||
"account.followers.empty": "Encara ningú no segueix aquest usuari.",
|
||||
"account.follows": "Seguiments",
|
||||
"account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidors}}",
|
||||
"account.following_counter": "{count, plural, other {{counter} Seguint}}",
|
||||
"account.follows.empty": "Aquest usuari encara no segueix a ningú.",
|
||||
"account.follows_you": "Et segueix",
|
||||
"account.hide_reblogs": "Amaga els impulsos de @{name}",
|
||||
@ -33,12 +36,14 @@
|
||||
"account.requested": "Esperant aprovació. Clic per a cancel·lar la petició de seguiment",
|
||||
"account.share": "Comparteix el perfil de @{name}",
|
||||
"account.show_reblogs": "Mostra els impulsos de @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} Tut} other {{counter} Tuts}}",
|
||||
"account.unblock": "Desbloqueja @{name}",
|
||||
"account.unblock_domain": "Mostra {domain}",
|
||||
"account.unendorse": "No recomanar en el perfil",
|
||||
"account.unfollow": "Deixa de seguir",
|
||||
"account.unmute": "Treure silenci de @{name}",
|
||||
"account.unmute_notifications": "Activar notificacions de @{name}",
|
||||
"account_note.placeholder": "Sense comentaris",
|
||||
"alert.rate_limited.message": "Si us plau torna-ho a provar després de {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "Límit de freqüència",
|
||||
"alert.unexpected.message": "S'ha produït un error inesperat.",
|
||||
@ -74,9 +79,9 @@
|
||||
"column_header.show_settings": "Mostra la configuració",
|
||||
"column_header.unpin": "No fixis",
|
||||
"column_subheading.settings": "Configuració",
|
||||
"community.column_settings.local_only": "Local only",
|
||||
"community.column_settings.local_only": "Només local",
|
||||
"community.column_settings.media_only": "Només multimèdia",
|
||||
"community.column_settings.remote_only": "Remote only",
|
||||
"community.column_settings.remote_only": "Només remot",
|
||||
"compose_form.direct_message_warning": "Aquest tut només serà enviat als usuaris esmentats.",
|
||||
"compose_form.direct_message_warning_learn_more": "Aprèn més",
|
||||
"compose_form.hashtag_warning": "Aquesta tut no es mostrarà en cap etiqueta ja que no està llistat. Només els tuts públics poden ser cercats per etiqueta.",
|
||||
@ -172,6 +177,7 @@
|
||||
"follow_request.authorize": "Autoritzar",
|
||||
"follow_request.reject": "Rebutjar",
|
||||
"follow_requests.unlocked_explanation": "Tot i que el teu compte no està bloquejat, el personal de {domain} ha pensat que és possible que vulguis revisar les sol·licituds de seguiment d’aquests comptes de forma manual.",
|
||||
"generic.saved": "Guardat",
|
||||
"getting_started.developers": "Desenvolupadors",
|
||||
"getting_started.directory": "Directori de perfils",
|
||||
"getting_started.documentation": "Documentació",
|
||||
@ -241,6 +247,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": "mostrar/amagar el camp CW",
|
||||
"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",
|
||||
@ -353,7 +360,7 @@
|
||||
"report.target": "Informes {target}",
|
||||
"search.placeholder": "Cercar",
|
||||
"search_popout.search_format": "Format de cerca avançada",
|
||||
"search_popout.tips.full_text": "Text simple recupera publicacions que has escrit, les marcades com a favorites, les impulsades o en les que has estat esmentat, així com usuaris, noms d'usuari i etiquetes.",
|
||||
"search_popout.tips.full_text": "Text simple recupera publicacions que has escrit, les marcades com a preferides, les impulsades o en les que has estat esmentat, així com usuaris, noms d'usuari i etiquetes.",
|
||||
"search_popout.tips.hashtag": "etiqueta",
|
||||
"search_popout.tips.status": "tut",
|
||||
"search_popout.tips.text": "El text simple retorna coincidències amb els noms de visualització, els noms d'usuari i les etiquetes",
|
||||
@ -369,7 +376,7 @@
|
||||
"status.bookmark": "Marcador",
|
||||
"status.cancel_reblog_private": "Desfer l'impuls",
|
||||
"status.cannot_reblog": "Aquesta publicació no pot ser impulsada",
|
||||
"status.copy": "Copia l'enllaç al tut",
|
||||
"status.copy": "Copia l'enllaç a l'estat",
|
||||
"status.delete": "Esborrar",
|
||||
"status.detailed_status": "Visualització detallada de la conversa",
|
||||
"status.direct": "Missatge directe @{name}",
|
||||
@ -418,9 +425,16 @@
|
||||
"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",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {persones}} parlant-hi",
|
||||
"timeline_hint.remote_resource_not_displayed": "{resource} dels altres servidors no son mostrats.",
|
||||
"timeline_hint.resources.followers": "Seguidors",
|
||||
"timeline_hint.resources.follows": "Seguiments",
|
||||
"timeline_hint.resources.statuses": "Tuts més antics",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} persona} other {{counter} persones}} parlant-hi",
|
||||
"trends.trending_now": "Ara en tendència",
|
||||
"ui.beforeunload": "El teu esborrany es perdrà si surts de Mastodon.",
|
||||
"units.short.billion": "{count}B",
|
||||
"units.short.million": "{count}M",
|
||||
"units.short.thousand": "{count}K",
|
||||
"upload_area.title": "Arrossega i deixa anar per a carregar",
|
||||
"upload_button.label": "Afegir multimèdia (JPEG, PNG, GIF, WebM, MP4, MOV)",
|
||||
"upload_error.limit": "S'ha superat el límit de càrrega d'arxius.",
|
||||
@ -428,10 +442,12 @@
|
||||
"upload_form.audio_description": "Descriviu per a les persones amb pèrdua auditiva",
|
||||
"upload_form.description": "Descriure els problemes visuals",
|
||||
"upload_form.edit": "Edita",
|
||||
"upload_form.thumbnail": "Canvia la miniatura",
|
||||
"upload_form.undo": "Esborra",
|
||||
"upload_form.video_description": "Descriu per a les persones amb pèrdua auditiva o deficiència visual",
|
||||
"upload_modal.analyzing_picture": "Analitzant imatge…",
|
||||
"upload_modal.apply": "Aplica",
|
||||
"upload_modal.choose_image": "Tria imatge",
|
||||
"upload_modal.description_placeholder": "Uns salts ràpids de guineu marró sobre el gos gandul",
|
||||
"upload_modal.detect_text": "Detecta el text de l'imatge",
|
||||
"upload_modal.edit_media": "Editar multimèdia",
|
||||
|
@ -1,10 +1,12 @@
|
||||
{
|
||||
"account.account_note_header": "A vostra nota per @{name}",
|
||||
"account.add_or_remove_from_list": "Aghjunghje o toglie da e liste",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.group": "Gruppu",
|
||||
"account.block": "Bluccà @{name}",
|
||||
"account.block_domain": "Piattà u duminiu {domain}",
|
||||
"account.blocked": "Bluccatu",
|
||||
"account.browse_more_on_origin_server": "Vede di più nant'à u prufile uriginale",
|
||||
"account.cancel_follow_request": "Annullà a dumanda d'abbunamentu",
|
||||
"account.direct": "Missaghju direttu @{name}",
|
||||
"account.domain_blocked": "Duminiu piattatu",
|
||||
@ -13,7 +15,8 @@
|
||||
"account.follow": "Siguità",
|
||||
"account.followers": "Abbunati",
|
||||
"account.followers.empty": "Nisunu hè abbunatu à st'utilizatore.",
|
||||
"account.follows": "Abbunamenti",
|
||||
"account.followers_counter": "{count, plural, one {{counter} Abbunatu} other {{counter} Abbunati}}",
|
||||
"account.following_counter": "{count, plural, one {{counter} Abbunamentu} other {{counter} Abbunamenti}}",
|
||||
"account.follows.empty": "St'utilizatore ùn seguita nisunu.",
|
||||
"account.follows_you": "Vi seguita",
|
||||
"account.hide_reblogs": "Piattà spartere da @{name}",
|
||||
@ -33,12 +36,14 @@
|
||||
"account.requested": "In attesa d'apprubazione. Cliccate per annullà a dumanda",
|
||||
"account.share": "Sparte u prufile di @{name}",
|
||||
"account.show_reblogs": "Vede spartere da @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} Statutu} other {{counter} Statuti}}",
|
||||
"account.unblock": "Sbluccà @{name}",
|
||||
"account.unblock_domain": "Ùn piattà più {domain}",
|
||||
"account.unendorse": "Ùn fà figurà nant'à u prufilu",
|
||||
"account.unfollow": "Ùn siguità più",
|
||||
"account.unmute": "Ùn piattà più @{name}",
|
||||
"account.unmute_notifications": "Ùn piattà più nutificazione da @{name}",
|
||||
"account_note.placeholder": "Senza cummentariu",
|
||||
"alert.rate_limited.message": "Pruvate ancu dop'à {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "Ghjettu limitatu",
|
||||
"alert.unexpected.message": "Un prublemu inaspettatu hè accadutu.",
|
||||
@ -74,9 +79,9 @@
|
||||
"column_header.show_settings": "Mustrà i parametri",
|
||||
"column_header.unpin": "Spuntarulà",
|
||||
"column_subheading.settings": "Parametri",
|
||||
"community.column_settings.local_only": "Local only",
|
||||
"community.column_settings.local_only": "Solu lucale",
|
||||
"community.column_settings.media_only": "Solu media",
|
||||
"community.column_settings.remote_only": "Remote only",
|
||||
"community.column_settings.remote_only": "Solu distante",
|
||||
"compose_form.direct_message_warning": "Solu l'utilizatori mintuvati puderenu vede stu statutu.",
|
||||
"compose_form.direct_message_warning_learn_more": "Amparà di più",
|
||||
"compose_form.hashtag_warning": "Stu statutu ùn hè \"Micca listatu\" è ùn sarà micca listatu indè e circate da hashtag. Per esse vistu in quesse, u statutu deve esse \"Pubblicu\".",
|
||||
@ -172,6 +177,7 @@
|
||||
"follow_request.authorize": "Auturizà",
|
||||
"follow_request.reject": "Righjittà",
|
||||
"follow_requests.unlocked_explanation": "U vostru contu ùn hè micca privatu, ma a squadra d'amministrazione di {domain} pensa chì e dumande d'abbunamentu di questi conti anu bisognu d'esse verificate manualmente.",
|
||||
"generic.saved": "Salvatu",
|
||||
"getting_started.developers": "Sviluppatori",
|
||||
"getting_started.directory": "Annuariu di i prufili",
|
||||
"getting_started.documentation": "Ducumentazione",
|
||||
@ -241,6 +247,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": "per mustrà/piattà u campu CW",
|
||||
"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",
|
||||
@ -418,9 +425,16 @@
|
||||
"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}}",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} parlanu",
|
||||
"timeline_hint.remote_resource_not_displayed": "{resource} da l'altri servori ùn so micca affissati·e.",
|
||||
"timeline_hint.resources.followers": "Abbunati",
|
||||
"timeline_hint.resources.follows": "Abbunamenti",
|
||||
"timeline_hint.resources.statuses": "Statuti più anziani",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} persona chì parla} other {{counter} persone chì parlanu}}",
|
||||
"trends.trending_now": "Tindenze d'avà",
|
||||
"ui.beforeunload": "A bruttacopia sarà persa s'ellu hè chjosu Mastodon.",
|
||||
"units.short.billion": "{count}G",
|
||||
"units.short.million": "{count}M",
|
||||
"units.short.thousand": "{count}K",
|
||||
"upload_area.title": "Drag & drop per caricà un fugliale",
|
||||
"upload_button.label": "Aghjunghje un media ({formats})",
|
||||
"upload_error.limit": "Limita di caricamentu di fugliali trapassata.",
|
||||
@ -428,10 +442,12 @@
|
||||
"upload_form.audio_description": "Discrizzione per i ciochi",
|
||||
"upload_form.description": "Discrive per i malvistosi",
|
||||
"upload_form.edit": "Mudificà",
|
||||
"upload_form.thumbnail": "Cambià vignetta",
|
||||
"upload_form.undo": "Sguassà",
|
||||
"upload_form.video_description": "Discrizzione per i ciochi o cechi",
|
||||
"upload_modal.analyzing_picture": "Analisi di u ritrattu…",
|
||||
"upload_modal.apply": "Affettà",
|
||||
"upload_modal.choose_image": "Cambià ritrattu",
|
||||
"upload_modal.description_placeholder": "Chì tempi brevi ziu, quandu solfeghji",
|
||||
"upload_modal.detect_text": "Ditettà testu da u ritrattu",
|
||||
"upload_modal.edit_media": "Cambià media",
|
||||
|
@ -1,10 +1,12 @@
|
||||
{
|
||||
"account.account_note_header": "Note",
|
||||
"account.add_or_remove_from_list": "Přidat nebo odstranit ze seznamů",
|
||||
"account.badges.bot": "Robot",
|
||||
"account.badges.group": "Skupina",
|
||||
"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": "Více na původním profilu",
|
||||
"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",
|
||||
@ -13,7 +15,8 @@
|
||||
"account.follow": "Sledovat",
|
||||
"account.followers": "Sledující",
|
||||
"account.followers.empty": "Tohoto uživatele ještě nikdo nesleduje.",
|
||||
"account.follows": "Sledovaní",
|
||||
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
|
||||
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
|
||||
"account.follows.empty": "Tento uživatel ještě nikoho nesleduje.",
|
||||
"account.follows_you": "Sleduje vás",
|
||||
"account.hide_reblogs": "Skrýt boosty od uživatele @{name}",
|
||||
@ -33,12 +36,14 @@
|
||||
"account.requested": "Čeká na schválení. Kliknutím žádost o sledování zrušíte",
|
||||
"account.share": "Sdílet profil uživatele @{name}",
|
||||
"account.show_reblogs": "Zobrazit boosty od uživatele @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
|
||||
"account.unblock": "Odblokovat uživatele @{name}",
|
||||
"account.unblock_domain": "Odkrýt doménu {domain}",
|
||||
"account.unendorse": "Nezvýrazňovat na profilu",
|
||||
"account.unfollow": "Přestat sledovat",
|
||||
"account.unmute": "Odkrýt uživatele @{name}",
|
||||
"account.unmute_notifications": "Odkrýt oznámení od uživatele @{name}",
|
||||
"account_note.placeholder": "Click to add a note",
|
||||
"alert.rate_limited.message": "Zkuste to prosím znovu za {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "Rychlost omezena",
|
||||
"alert.unexpected.message": "Objevila se neočekávaná chyba.",
|
||||
@ -74,9 +79,9 @@
|
||||
"column_header.show_settings": "Zobrazit nastavení",
|
||||
"column_header.unpin": "Odepnout",
|
||||
"column_subheading.settings": "Nastavení",
|
||||
"community.column_settings.local_only": "Local only",
|
||||
"community.column_settings.local_only": "Pouze místní",
|
||||
"community.column_settings.media_only": "Pouze média",
|
||||
"community.column_settings.remote_only": "Remote only",
|
||||
"community.column_settings.remote_only": "Pouze vzdálené",
|
||||
"compose_form.direct_message_warning": "Tento toot bude odeslán pouze zmíněným uživatelům.",
|
||||
"compose_form.direct_message_warning_learn_more": "Zjistit více",
|
||||
"compose_form.hashtag_warning": "Tento toot nebude zobrazen pod žádným hashtagem, neboť je neuvedený. Pouze veřejné tooty mohou být vyhledány podle hashtagu.",
|
||||
@ -171,7 +176,8 @@
|
||||
"federation.local_only.short": "Local-only",
|
||||
"follow_request.authorize": "Autorizovat",
|
||||
"follow_request.reject": "Odmítnout",
|
||||
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
|
||||
"follow_requests.unlocked_explanation": "Přestože váš účet není uzamčen, {domain} si myslí, že budete chtít následující požadavky na sledování zkontrolovat ručně.",
|
||||
"generic.saved": "Saved",
|
||||
"getting_started.developers": "Vývojáři",
|
||||
"getting_started.directory": "Adresář profilů",
|
||||
"getting_started.documentation": "Dokumentace",
|
||||
@ -241,6 +247,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": "zobrazit/skrýt pole CW",
|
||||
"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í",
|
||||
@ -418,9 +425,16 @@
|
||||
"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}}",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {člověk} few {lidé} many {lidí} other {lidí}} hovoří",
|
||||
"timeline_hint.remote_resource_not_displayed": "{resource} z jiných serveru se nezobrazuje.",
|
||||
"timeline_hint.resources.followers": "Sledující",
|
||||
"timeline_hint.resources.follows": "Sleduje",
|
||||
"timeline_hint.resources.statuses": "Starší tooty",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
|
||||
"trends.trending_now": "Aktuální trendy",
|
||||
"ui.beforeunload": "Pokud Mastodon opustíte, váš koncept se ztratí.",
|
||||
"units.short.billion": "{count}B",
|
||||
"units.short.million": "{count}M",
|
||||
"units.short.thousand": "{count}K",
|
||||
"upload_area.title": "Nahrajte přetažením",
|
||||
"upload_button.label": "Přidat média ({formats})",
|
||||
"upload_error.limit": "Byl překročen limit nahraných souborů.",
|
||||
@ -428,10 +442,12 @@
|
||||
"upload_form.audio_description": "Popis pro sluchově postižené",
|
||||
"upload_form.description": "Popis pro zrakově postižené",
|
||||
"upload_form.edit": "Upravit",
|
||||
"upload_form.thumbnail": "Change thumbnail",
|
||||
"upload_form.undo": "Smazat",
|
||||
"upload_form.video_description": "Popis pro sluchově či zrakově postižené",
|
||||
"upload_modal.analyzing_picture": "Analyzuji obrázek…",
|
||||
"upload_modal.apply": "Použít",
|
||||
"upload_modal.choose_image": "Choose image",
|
||||
"upload_modal.description_placeholder": "Příliš žluťoučký kůň úpěl ďábelské ódy",
|
||||
"upload_modal.detect_text": "Detekovat text z obrázku",
|
||||
"upload_modal.edit_media": "Upravit média",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user