Merge tag 'v3.2.0' into hometown-dev

This commit is contained in:
Darius Kazemi
2020-08-11 13:56:04 -07:00
676 changed files with 20549 additions and 6955 deletions

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View 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

View File

@ -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

View 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

View 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

View 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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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!

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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|

View File

@ -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|

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View 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,
};
};

View File

@ -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,

View File

@ -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) {

View File

@ -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;
}

View File

@ -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);
};

View File

@ -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(() => {

View File

@ -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));

View File

@ -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}`);

View File

@ -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'),

View File

@ -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);
});

View File

@ -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>
);
}

View File

@ -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}

View 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);

View 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`);
}
}

View File

@ -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>

View File

@ -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'>

View File

@ -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);

View File

@ -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>
);

View 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);

View File

@ -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>

View File

@ -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' />

View File

@ -99,6 +99,7 @@ export default class StatusList extends ImmutablePureComponent {
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
scrollKey={this.props.scrollKey}
showThread
/>
))

View 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;

View File

@ -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) {

View File

@ -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>
);
}
}

View File

@ -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>

View File

@ -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);

View File

@ -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>
);

View File

@ -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'));

View File

@ -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}
/>

View File

@ -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}

View File

@ -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}>
&nbsp;
<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>

View 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();
}
}

View File

@ -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);
}

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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>
);

View File

@ -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" />');
});
});
});

View File

@ -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) {

View File

@ -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}
>

View File

@ -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}
>

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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>
);

View File

@ -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>

View File

@ -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'));
});
});
});

View File

@ -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>

View File

@ -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'>

View File

@ -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>
);
}
}

View File

@ -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
/>
)}

View File

@ -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>

View File

@ -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;
}

View File

@ -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,

View File

@ -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}>
&nbsp;
<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>

View File

@ -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": "تعديل الوسائط",

View File

@ -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",

View File

@ -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",

View File

@ -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": "মিডিয়া সম্পাদনা করুন",

View File

@ -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",

View File

@ -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 daquests 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",

View File

@ -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",

View File

@ -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