Merge upstream 2.0ish #165

th-downstream
kibigo! 7 years ago
commit 02827345ae

1
.gitignore vendored

@ -21,7 +21,6 @@ public/system
public/assets public/assets
public/packs public/packs
public/packs-test public/packs-test
public/500.html
.env .env
.env.production .env.production
node_modules/ node_modules/

@ -0,0 +1,46 @@
# test directories
__tests__
test
tests
powered-test
# asset directories
docs
doc
website
images
# assets
# examples
example
examples
# code coverage directories
coverage
.nyc_output
# build scripts
Makefile
Gulpfile.js
Gruntfile.js
# configs
.tern-project
.gitattributes
.editorconfig
.*ignore
.eslintrc
.jshintrc
.flowconfig
.documentup.json
.yarn-metadata.json
.*.yml
*.yml
# misc
*.gz
*.md
# for specific ignore
!.svgo.yml

@ -60,11 +60,12 @@ RUN apk -U upgrade \
&& cd /mastodon \ && cd /mastodon \
&& rm -rf /tmp/* /var/cache/apk/* && rm -rf /tmp/* /var/cache/apk/*
COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/ COPY Gemfile Gemfile.lock package.json yarn.lock .yarnclean /mastodon/
RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \
&& bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \ && bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \
&& yarn --pure-lockfile && yarn --pure-lockfile \
&& yarn cache clean
COPY . /mastodon COPY . /mastodon

@ -42,6 +42,7 @@ gem 'kaminari', '~> 1.0'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.1' gem 'mime-types', '~> 3.1'
gem 'nokogiri', '~> 1.7' gem 'nokogiri', '~> 1.7'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.0' gem 'oj', '~> 3.0'
gem 'ostatus2', '~> 2.0' gem 'ostatus2', '~> 2.0'
gem 'ox', '~> 2.5' gem 'ox', '~> 2.5'
@ -64,7 +65,7 @@ gem 'sidekiq-bulk', '~>0.1.1'
gem 'simple-navigation', '~> 4.0' gem 'simple-navigation', '~> 4.0'
gem 'simple_form', '~> 3.4' gem 'simple_form', '~> 3.4'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'statsd-instrument', '~> 2.1' gem 'strong_migrations'
gem 'twitter-text', '~> 1.14' gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2017' gem 'tzinfo-data', '~> 1.2017'
gem 'webpacker', '~> 3.0' gem 'webpacker', '~> 3.0'
@ -105,7 +106,6 @@ group :development do
gem 'brakeman', '~> 4.0', require: false gem 'brakeman', '~> 4.0', require: false
gem 'bundler-audit', '~> 0.6', require: false gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.53', require: false gem 'scss_lint', '~> 0.53', require: false
gem 'strong_migrations'
gem 'capistrano', '~> 3.8' gem 'capistrano', '~> 3.8'
gem 'capistrano-rails', '~> 1.2' gem 'capistrano-rails', '~> 1.2'

@ -289,6 +289,11 @@ GEM
mini_portile2 (~> 2.2.0) mini_portile2 (~> 2.2.0)
nokogumbo (1.4.13) nokogumbo (1.4.13)
nokogiri nokogiri
nsa (0.2.4)
activesupport (>= 4.2, < 6)
concurrent-ruby (~> 1.0.0)
sidekiq (>= 3.5.0)
statsd-ruby (~> 1.2.0)
oj (3.3.5) oj (3.3.5)
openssl (2.0.5) openssl (2.0.5)
orm_adapter (0.5.0) orm_adapter (0.5.0)
@ -483,7 +488,7 @@ GEM
sshkit (1.14.0) sshkit (1.14.0)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
statsd-instrument (2.1.4) statsd-ruby (1.2.1)
strong_migrations (0.1.9) strong_migrations (0.1.9)
activerecord (>= 3.2.0) activerecord (>= 3.2.0)
temple (0.8.0) temple (0.8.0)
@ -578,6 +583,7 @@ DEPENDENCIES
microformats (~> 4.0) microformats (~> 4.0)
mime-types (~> 3.1) mime-types (~> 3.1)
nokogiri (~> 1.7) nokogiri (~> 1.7)
nsa (~> 0.2)
oj (~> 3.0) oj (~> 3.0)
ostatus2 (~> 2.0) ostatus2 (~> 2.0)
ox (~> 2.5) ox (~> 2.5)
@ -617,7 +623,6 @@ DEPENDENCIES
simple_form (~> 3.4) simple_form (~> 3.4)
simplecov (~> 0.14) simplecov (~> 0.14)
sprockets-rails (~> 3.2) sprockets-rails (~> 3.2)
statsd-instrument (~> 2.1)
strong_migrations strong_migrations
twitter-text (~> 1.14) twitter-text (~> 1.14)
tzinfo-data (~> 1.2017) tzinfo-data (~> 1.2017)

@ -26,7 +26,10 @@ class AccountsController < ApplicationController
end end
format.json do format.json do
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: @account,
serializer: ActivityPub::ActorSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
end end
end end

@ -9,9 +9,9 @@ class ActivityPub::InboxesController < Api::BaseController
if signed_request_account if signed_request_account
upgrade_account upgrade_account
process_payload process_payload
head 201
else
head 202 head 202
else
[signature_verification_failure_reason, 401]
end end
end end
@ -32,6 +32,7 @@ class ActivityPub::InboxesController < Api::BaseController
end end
Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed?
DeliveryFailureTracker.track_inverse_success!(signed_request_account)
end end
def process_payload def process_payload

@ -0,0 +1,31 @@
# frozen_string_literal: true
class Admin::AccountModerationNotesController < Admin::BaseController
def create
@account_moderation_note = current_account.account_moderation_notes.new(resource_params)
if @account_moderation_note.save
@target_account = @account_moderation_note.target_account
redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.created_msg')
else
@account = @account_moderation_note.target_account
@moderation_notes = @account.targeted_moderation_notes.latest
render template: 'admin/accounts/show'
end
end
def destroy
@account_moderation_note = AccountModerationNote.find(params[:id])
@target_account = @account_moderation_note.target_account
@account_moderation_note.destroy
redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg')
end
private
def resource_params
params.require(:account_moderation_note).permit(
:content,
:target_account_id
)
end
end

@ -9,7 +9,10 @@ module Admin
@accounts = filtered_accounts.page(params[:page]) @accounts = filtered_accounts.page(params[:page])
end end
def show; end def show
@account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
@moderation_notes = @account.targeted_moderation_notes.latest
end
def subscribe def subscribe
Pubsubhubbub::SubscribeWorker.perform_async(@account.id) Pubsubhubbub::SubscribeWorker.perform_async(@account.id)

@ -2,8 +2,10 @@
module Admin module Admin
class CustomEmojisController < BaseController class CustomEmojisController < BaseController
before_action :set_custom_emoji, except: [:index, :new, :create]
def index def index
@custom_emojis = CustomEmoji.local @custom_emojis = filtered_custom_emojis.page(params[:page])
end end
def new def new
@ -21,14 +23,49 @@ module Admin
end end
def destroy def destroy
CustomEmoji.find(params[:id]).destroy @custom_emoji.destroy
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg') redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
end end
def copy
emoji = CustomEmoji.new(domain: nil, shortcode: @custom_emoji.shortcode, image: @custom_emoji.image)
if emoji.save
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.copied_msg')
else
redirect_to admin_custom_emojis_path, alert: I18n.t('admin.custom_emojis.copy_failed_msg')
end
end
def enable
@custom_emoji.update!(disabled: false)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg')
end
def disable
@custom_emoji.update!(disabled: true)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg')
end
private private
def set_custom_emoji
@custom_emoji = CustomEmoji.find(params[:id])
end
def resource_params def resource_params
params.require(:custom_emoji).permit(:shortcode, :image) params.require(:custom_emoji).permit(:shortcode, :image)
end end
def filtered_custom_emojis
CustomEmojiFilter.new(filter_params).results
end
def filter_params
params.permit(
:local,
:remote
)
end
end end
end end

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Admin
class EmailDomainBlocksController < BaseController
before_action :set_email_domain_block, only: [:show, :destroy]
def index
@email_domain_blocks = EmailDomainBlock.page(params[:page])
end
def new
@email_domain_block = EmailDomainBlock.new
end
def create
@email_domain_block = EmailDomainBlock.new(resource_params)
if @email_domain_block.save
redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg')
else
render :new
end
end
def destroy
@email_domain_block.destroy
redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg')
end
private
def set_email_domain_block
@email_domain_block = EmailDomainBlock.find(params[:id])
end
def resource_params
params.require(:email_domain_block).permit(:domain)
end
end
end

@ -7,9 +7,11 @@ class Api::SalmonController < Api::BaseController
def update def update
if verify_payload? if verify_payload?
process_salmon process_salmon
head 201
else
head 202 head 202
elsif payload.present?
[signature_verification_failure_reason, 401]
else
head 400
end end
end end

@ -7,7 +7,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
respond_to :json respond_to :json
def index def index
@accounts = Account.where(id: account_ids).select('id') accounts = Account.where(id: account_ids).select('id')
# .where doesn't guarantee that our results are in the same order
# we requested them, so return the "right" order to the requestor.
@accounts = accounts.index_by(&:id).values_at(*account_ids)
render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships
end end

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Api::V1::Apps::CredentialsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }
respond_to :json
def show
render json: doorkeeper_token.application, serializer: REST::StatusSerializer::ApplicationSerializer
end
end

@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::AppsController < Api::BaseController class Api::V1::AppsController < Api::BaseController
respond_to :json
def create def create
@app = Doorkeeper::Application.create!(application_options) @app = Doorkeeper::Application.create!(application_options)
render json: @app, serializer: REST::ApplicationSerializer render json: @app, serializer: REST::ApplicationSerializer

@ -15,19 +15,17 @@ class Api::V1::BlocksController < Api::BaseController
private private
def load_accounts def load_accounts
default_accounts.merge(paginated_blocks).to_a paginated_blocks.map(&:target_account)
end
def default_accounts
Account.includes(:blocked_by).references(:blocked_by)
end end
def paginated_blocks def paginated_blocks
Block.where(account: current_account).paginate_by_max_id( @paginated_blocks ||= Block.eager_load(:target_account)
limit_param(DEFAULT_ACCOUNTS_LIMIT), .where(account: current_account)
params[:max_id], .paginate_by_max_id(
params[:since_id] limit_param(DEFAULT_ACCOUNTS_LIMIT),
) params[:max_id],
params[:since_id]
)
end end
def insert_pagination_headers def insert_pagination_headers
@ -41,21 +39,21 @@ class Api::V1::BlocksController < Api::BaseController
end end
def prev_path def prev_path
unless @accounts.empty? unless paginated_blocks.empty?
api_v1_blocks_url pagination_params(since_id: pagination_since_id) api_v1_blocks_url pagination_params(since_id: pagination_since_id)
end end
end end
def pagination_max_id def pagination_max_id
@accounts.last.blocked_by_ids.last paginated_blocks.last.id
end end
def pagination_since_id def pagination_since_id
@accounts.first.blocked_by_ids.first paginated_blocks.first.id
end end
def records_continue? def records_continue?
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) paginated_blocks.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end end
def pagination_params(core_params) def pagination_params(core_params)

@ -4,6 +4,6 @@ class Api::V1::CustomEmojisController < Api::BaseController
respond_to :json respond_to :json
def index def index
render json: CustomEmoji.local, each_serializer: REST::CustomEmojiSerializer render json: CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer
end end
end end

@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController
respond_to :json respond_to :json
def create def create
@media = current_account.media_attachments.create!(file: media_params[:file]) @media = current_account.media_attachments.create!(media_params)
render json: @media, serializer: REST::MediaAttachmentSerializer render json: @media, serializer: REST::MediaAttachmentSerializer
rescue Paperclip::Errors::NotIdentifiedByImageMagickError rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: file_type_error, status: 422 render json: file_type_error, status: 422
@ -18,10 +18,16 @@ class Api::V1::MediaController < Api::BaseController
render json: processing_error, status: 500 render json: processing_error, status: 500
end end
def update
@media = current_account.media_attachments.where(status_id: nil).find(params[:id])
@media.update!(media_params)
render json: @media, serializer: REST::MediaAttachmentSerializer
end
private private
def media_params def media_params
params.permit(:file) params.permit(:file, :description)
end end
def file_type_error def file_type_error

@ -6,6 +6,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :check_enabled_registrations, only: [:new, :create] before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create] before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update] before_action :set_sessions, only: [:edit, :update]
before_action :set_instance_presenter, only: [:new, :update]
def destroy def destroy
not_found not_found
@ -39,6 +40,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
private private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def determine_layout def determine_layout
%w(edit update).include?(action_name) ? 'admin' : 'auth' %w(edit update).include?(action_name) ? 'admin' : 'auth'
end end

@ -8,6 +8,7 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_no_authentication, only: [:create] skip_before_action :require_no_authentication, only: [:create]
skip_before_action :check_suspension, only: [:destroy] skip_before_action :check_suspension, only: [:destroy]
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
before_action :set_instance_presenter, only: [:new]
def create def create
super do |resource| super do |resource|
@ -84,6 +85,10 @@ class Auth::SessionsController < Devise::SessionsController
private private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def home_paths(resource) def home_paths(resource)
paths = [about_path] paths = [about_path]
if single_user_mode? && resource.is_a?(User) if single_user_mode? && resource.is_a?(User)

@ -9,10 +9,15 @@ module SignatureVerification
request.headers['Signature'].present? request.headers['Signature'].present?
end end
def signature_verification_failure_reason
return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason)
end
def signed_request_account def signed_request_account
return @signed_request_account if defined?(@signed_request_account) return @signed_request_account if defined?(@signed_request_account)
unless signed_request? unless signed_request?
@signature_verification_failure_reason = 'Request not signed'
@signed_request_account = nil @signed_request_account = nil
return return
end end
@ -27,6 +32,7 @@ module SignatureVerification
end end
if incompatible_signature?(signature_params) if incompatible_signature?(signature_params)
@signature_verification_failure_reason = 'Incompatible request signature'
@signed_request_account = nil @signed_request_account = nil
return return
end end
@ -34,6 +40,7 @@ module SignatureVerification
account = account_from_key_id(signature_params['keyId']) account = account_from_key_id(signature_params['keyId'])
if account.nil? if account.nil?
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
@signed_request_account = nil @signed_request_account = nil
return return
end end
@ -44,7 +51,18 @@ module SignatureVerification
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
@signed_request_account = account @signed_request_account = account
@signed_request_account @signed_request_account
elsif account.possibly_stale?
account = account.refresh!
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
@signed_request_account = account
@signed_request_account
else
@signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
@signed_request_account = nil
end
else else
@signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
@signed_request_account = nil @signed_request_account = nil
end end
end end
@ -99,7 +117,7 @@ module SignatureVerification
ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, ''))
elsif !ActivityPub::TagManager.instance.local_uri?(key_id) elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false)
account account
end end
end end

@ -0,0 +1,22 @@
# frozen_string_literal: true
class EmojisController < ApplicationController
before_action :set_emoji
def show
respond_to do |format|
format.json do
render json: @emoji,
serializer: ActivityPub::EmojiSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end
end
end
private
def set_emoji
@emoji = CustomEmoji.local.find(params[:id])
end
end

@ -10,7 +10,10 @@ class FollowerAccountsController < ApplicationController
format.html format.html
format.json do format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
end end
end end

@ -10,7 +10,10 @@ class FollowingAccountsController < ApplicationController
format.html format.html
format.json do format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
end end
end end

@ -1,11 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ManifestsController < ApplicationController class ManifestsController < ApplicationController
before_action :set_instance_presenter def show
render json: InstancePresenter.new, serializer: ManifestSerializer
def show; end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end end
end end

@ -9,7 +9,7 @@ class Settings::FollowerDomainsController < ApplicationController
def show def show
@account = current_account @account = current_account
@domains = current_account.followers.reorder(nil).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) @domains = current_account.followers.reorder('MIN(follows.id) DESC').group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10)
end end
def update def update

@ -0,0 +1,32 @@
# frozen_string_literal: true
class Settings::NotificationsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
def show; end
def update
user_settings.update(user_settings_params.to_h)
if current_user.save
redirect_to settings_notifications_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show
end
end
private
def user_settings
UserSettingsDecorator.new(current_user)
end
def user_settings_params
params.require(:user).permit(
notification_emails: %i(follow follow_request reblog favourite mention digest),
interactions: %i(must_be_follower must_be_following)
)
end
end

@ -21,13 +21,19 @@ class StatusesController < ApplicationController
end end
format.json do format.json do
render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: @status,
serializer: ActivityPub::NoteSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
end end
end end
def activity def activity
render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: @status,
serializer: ActivityPub::ActivitySerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
def embed def embed

@ -1,24 +1,40 @@
# frozen_string_literal: true # frozen_string_literal: true
class TagsController < ApplicationController class TagsController < ApplicationController
layout 'public' before_action :set_body_classes
before_action :set_instance_presenter
def show def show
@tag = Tag.find_by!(name: params[:id].downcase) @tag = Tag.find_by!(name: params[:id].downcase)
@statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
@statuses = cache_collection(@statuses, Status)
respond_to do |format| respond_to do |format|
format.html format.html do
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
end
format.json do format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
@statuses = cache_collection(@statuses, Status)
render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
end end
end end
private private
def set_body_classes
@body_classes = 'tag-body'
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def collection_presenter def collection_presenter
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: tag_url(@tag), id: tag_url(@tag),
@ -27,4 +43,11 @@ class TagsController < ApplicationController
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
) )
end end
def initial_state_params
{
settings: {},
token: current_session&.token,
}
end
end end

@ -0,0 +1,4 @@
# frozen_string_literal: true
module Admin::AccountModerationNotesHelper
end

@ -22,7 +22,18 @@ module JsonLdHelper
graph.dump(:normalize) graph.dump(:normalize)
end end
def fetch_resource(uri) def fetch_resource(uri, id)
unless id
json = fetch_resource_without_id_validation(uri)
return unless json
uri = json['id']
end
json = fetch_resource_without_id_validation(uri)
json.present? && json['id'] == uri ? json : nil
end
def fetch_resource_without_id_validation(uri)
response = build_request(uri).perform response = build_request(uri).perform
return if response.code != 200 return if response.code != 200
body_to_json(response.to_s) body_to_json(response.to_s)

@ -1,5 +1,7 @@
import api from '../api'; import api from '../api';
import { emojiIndex } from 'emoji-mart'; import { throttle } from 'lodash';
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
import { useEmoji } from './emojis';
import { import {
updateTimeline, updateTimeline,
@ -15,6 +17,7 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
@ -38,6 +41,10 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
export function changeCompose(text) { export function changeCompose(text) {
return { return {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
@ -64,6 +71,12 @@ export function cancelReplyCompose() {
}; };
}; };
export function resetCompose() {
return {
type: COMPOSE_RESET,
};
};
export function mentionCompose(account, router) { export function mentionCompose(account, router) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ dispatch({
@ -168,6 +181,40 @@ export function uploadCompose(files) {
}; };
}; };
export function changeUploadCompose(id, description) {
return (dispatch, getState) => {
dispatch(changeUploadComposeRequest());
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
dispatch(changeUploadComposeSuccess(response.data));
}).catch(error => {
dispatch(changeUploadComposeFail(id, error));
});
};
};
export function changeUploadComposeRequest() {
return {
type: COMPOSE_UPLOAD_CHANGE_REQUEST,
skipLoading: true,
};
};
export function changeUploadComposeSuccess(media) {
return {
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
media: media,
skipLoading: true,
};
};
export function changeUploadComposeFail(error) {
return {
type: COMPOSE_UPLOAD_CHANGE_FAIL,
error: error,
skipLoading: true,
};
};
export function uploadComposeRequest() { export function uploadComposeRequest() {
return { return {
type: COMPOSE_UPLOAD_REQUEST, type: COMPOSE_UPLOAD_REQUEST,
@ -212,23 +259,30 @@ export function clearComposeSuggestions() {
}; };
}; };
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
api(getState).get('/api/v1/accounts/search', {
params: {
q: token.slice(1),
resolve: false,
limit: 4,
},
}).then(response => {
dispatch(readyComposeSuggestionsAccounts(token, response.data));
});
}, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
dispatch(readyComposeSuggestionsEmojis(token, results));
};
export function fetchComposeSuggestions(token) { export function fetchComposeSuggestions(token) {
return (dispatch, getState) => { return (dispatch, getState) => {
if (token[0] === ':') { if (token[0] === ':') {
const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 }); fetchComposeSuggestionsEmojis(dispatch, getState, token);
dispatch(readyComposeSuggestionsEmojis(token, results)); } else {
return; fetchComposeSuggestionsAccounts(dispatch, getState, token);
} }
api(getState).get('/api/v1/accounts/search', {
params: {
q: token.slice(1),
resolve: false,
limit: 4,
},
}).then(response => {
dispatch(readyComposeSuggestionsAccounts(token, response.data));
});
}; };
}; };
@ -255,6 +309,8 @@ export function selectComposeSuggestion(position, token, suggestion) {
if (typeof suggestion === 'object' && suggestion.id) { if (typeof suggestion === 'object' && suggestion.id) {
completion = suggestion.native || suggestion.colons; completion = suggestion.native || suggestion.colons;
startPosition = position - 1; startPosition = position - 1;
dispatch(useEmoji(suggestion));
} else { } else {
completion = getState().getIn(['accounts', suggestion, 'acct']); completion = getState().getIn(['accounts', suggestion, 'acct']);
startPosition = position; startPosition = position;

@ -0,0 +1,14 @@
import { saveSettings } from './settings';
export const EMOJI_USE = 'EMOJI_USE';
export function useEmoji(emoji) {
return dispatch => {
dispatch({
type: EMOJI_USE,
emoji,
});
dispatch(saveSettings());
};
};

@ -1,6 +1,8 @@
import axios from 'axios'; import axios from 'axios';
import { debounce } from 'lodash';
export const SETTING_CHANGE = 'SETTING_CHANGE'; export const SETTING_CHANGE = 'SETTING_CHANGE';
export const SETTING_SAVE = 'SETTING_SAVE';
export function changeSetting(key, value) { export function changeSetting(key, value) {
return dispatch => { return dispatch => {
@ -14,10 +16,16 @@ export function changeSetting(key, value) {
}; };
}; };
const debouncedSave = debounce((dispatch, getState) => {
if (getState().getIn(['settings', 'saved'])) {
return;
}
const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
}, 5000, { trailing: true });
export function saveSettings() { export function saveSettings() {
return (_, getState) => { return (dispatch, getState) => debouncedSave(dispatch, getState);
axios.put('/api/web/settings', {
data: getState().get('settings').toJS(),
});
};
}; };

@ -17,6 +17,8 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
return { return {
type: TIMELINE_REFRESH_SUCCESS, type: TIMELINE_REFRESH_SUCCESS,
@ -30,6 +32,16 @@ export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
export function updateTimeline(timeline, status) { export function updateTimeline(timeline, status) {
return (dispatch, getState) => { return (dispatch, getState) => {
const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
const parents = [];
if (status.in_reply_to_id) {
let parent = getState().getIn(['statuses', status.in_reply_to_id]);
while (parent && parent.get('in_reply_to_id')) {
parents.push(parent.get('id'));
parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
}
}
dispatch({ dispatch({
type: TIMELINE_UPDATE, type: TIMELINE_UPDATE,
@ -37,6 +49,14 @@ export function updateTimeline(timeline, status) {
status, status,
references, references,
}); });
if (parents.length > 0) {
dispatch({
type: TIMELINE_CONTEXT_UPDATE,
status,
references: parents,
});
}
}; };
}; };

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { unicodeMapping } from '../emojione_light'; import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
const assetHost = process.env.CDN_HOST || ''; const assetHost = process.env.CDN_HOST || '';
@ -17,8 +17,13 @@ export default class AutosuggestEmoji extends React.PureComponent {
if (emoji.custom) { if (emoji.custom) {
url = emoji.imageUrl; url = emoji.imageUrl;
} else { } else {
const [ filename ] = unicodeMapping[emoji.native]; const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
url = `${assetHost}/emoji/${filename}.svg`;
if (!mapping) {
return null;
}
url = `${assetHost}/emoji/${mapping.filename}.svg`;
} }
return ( return (

@ -125,6 +125,16 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
this.props.onKeyDown(e); this.props.onKeyDown(e);
} }
onKeyUp = e => {
if (e.key === 'Escape' && this.state.suggestionsHidden) {
document.querySelector('.ui').parentElement.focus();
}
if (this.props.onKeyUp) {
this.props.onKeyUp(e);
}
}
onBlur = () => { onBlur = () => {
this.setState({ suggestionsHidden: true }); this.setState({ suggestionsHidden: true });
} }
@ -173,7 +183,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
render () { render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
const { suggestionsHidden } = this.state; const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' }; const style = { direction: 'ltr' };
@ -195,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
value={value} value={value}
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp} onKeyUp={this.onKeyUp}
onBlur={this.onBlur} onBlur={this.onBlur}
onPaste={this.onPaste} onPaste={this.onPaste}
style={style} style={style}

@ -173,7 +173,7 @@ export default class ColumnHeader extends React.PureComponent {
return ( return (
<div className={wrapperClassName}> <div className={wrapperClassName}>
<h1 tabIndex={focusable && '0'} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
<i className={`fa fa-fw fa-${icon} column-header__icon`} /> <i className={`fa fa-fw fa-${icon} column-header__icon`} />
{title} {title}
<div className='column-header__buttons'> <div className='column-header__buttons'>
@ -200,7 +200,7 @@ export default class ColumnHeader extends React.PureComponent {
</div> </div>
) : null} ) : null}
<div className={collapsibleClassName} tabIndex={collapsed && -1} onTransitionEnd={this.handleTransitionEnd}> <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
<div className='column-header__collapsible-inner'> <div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent} {(!collapsed || animating) && collapsedContent}
</div> </div>

@ -2,8 +2,9 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button'; import IconButton from './icon_button';
import { Overlay } from 'react-overlays'; import Overlay from 'react-overlays/lib/Overlay';
import { Motion, spring } from 'react-motion'; import Motion from 'react-motion/lib/Motion';
import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;

@ -5,6 +5,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
static propTypes = { static propTypes = {
src: PropTypes.string.isRequired, src: PropTypes.string.isRequired,
alt: PropTypes.string,
width: PropTypes.number, width: PropTypes.number,
height: PropTypes.number, height: PropTypes.number,
time: PropTypes.number, time: PropTypes.number,
@ -31,15 +32,20 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
} }
render () { render () {
const { src, muted, controls, alt } = this.props;
return ( return (
<div className='extended-video-player'> <div className='extended-video-player'>
<video <video
ref={this.setRef} ref={this.setRef}
src={this.props.src} src={src}
autoPlay autoPlay
muted={this.props.muted} role='button'
controls={this.props.controls} tabIndex='0'
loop={!this.props.controls} aria-label={alt}
muted={muted}
controls={controls}
loop={!controls}
/> />
</div> </div>
); );

@ -58,26 +58,31 @@ export default class IntersectionObserverArticle extends React.Component {
} }
handleIntersection = (entry) => { handleIntersection = (entry) => {
const { onHeightChange, saveHeightKey, id } = this.props; this.entry = entry;
if (this.node && this.node.children.length !== 0) { scheduleIdleTask(this.calculateHeight);
// save the height of the fully-rendered element this.setState(this.updateStateAfterIntersection);
this.height = getRectFromEntry(entry).height; }
if (onHeightChange && saveHeightKey) { updateStateAfterIntersection = (prevState) => {
onHeightChange(saveHeightKey, id, this.height); if (prevState.isIntersecting && !this.entry.isIntersecting) {
} scheduleIdleTask(this.hideIfNotIntersecting);
} }
return {
isIntersecting: this.entry.isIntersecting,
isHidden: false,
};
}
this.setState((prevState) => { calculateHeight = () => {
if (prevState.isIntersecting && !entry.isIntersecting) { const { onHeightChange, saveHeightKey, id } = this.props;
scheduleIdleTask(this.hideIfNotIntersecting); // save the height of the fully-rendered element (this is expensive
} // on Chrome, where we need to fall back to getBoundingClientRect)
return { this.height = getRectFromEntry(this.entry).height;
isIntersecting: entry.isIntersecting,
isHidden: false, if (onHeightChange && saveHeightKey) {
}; onHeightChange(saveHeightKey, id, this.height);
}); }
} }
hideIfNotIntersecting = () => { hideIfNotIntersecting = () => {

@ -9,7 +9,6 @@ import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile'; import { isIOS } from '../is_mobile';
import classNames from 'classnames'; import classNames from 'classnames';
import sizeMe from 'react-sizeme';
const messages = defineMessages({ const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@ -139,7 +138,7 @@ class Item extends React.PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
target='_blank' target='_blank'
> >
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' /> <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
</a> </a>
); );
} else if (attachment.get('type') === 'gifv') { } else if (attachment.get('type') === 'gifv') {
@ -149,6 +148,7 @@ class Item extends React.PureComponent {
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video <video
className='media-gallery__item-gifv-thumbnail' className='media-gallery__item-gifv-thumbnail'
aria-label={attachment.get('description')}
role='application' role='application'
src={attachment.get('url')} src={attachment.get('url')}
onClick={this.handleClick} onClick={this.handleClick}
@ -174,7 +174,6 @@ class Item extends React.PureComponent {
} }
@injectIntl @injectIntl
@sizeMe({})
export default class MediaGallery extends React.PureComponent { export default class MediaGallery extends React.PureComponent {
static propTypes = { static propTypes = {
@ -211,21 +210,42 @@ export default class MediaGallery extends React.PureComponent {
this.props.onOpenMedia(this.props.media, index); this.props.onOpenMedia(this.props.media, index);
} }
handleRef = (node) => {
if (node && this.isStandaloneEligible()) {
// offsetWidth triggers a layout, so only calculate when we need to
this.setState({
width: node.offsetWidth,
});
}
}
isStandaloneEligible() {
const { media, standalone } = this.props;
return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
}
render () { render () {
const { media, intl, sensitive, height, standalone, size } = this.props; const { media, intl, sensitive, height } = this.props;
const { width, visible } = this.state;
let children; let children;
const standaloneEligible = standalone && size.width && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
const style = {}; const style = {};
if (standaloneEligible) { if (this.isStandaloneEligible()) {
style.height = size.width / media.getIn([0, 'meta', 'small', 'aspect']); if (!visible && width) {
// only need to forcibly set the height in "sensitive" mode
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
} else {
// layout automatically, using image's natural aspect ratio
style.height = '';
}
} else { } else {
// crop the image
style.height = height; style.height = height;
} }
if (!this.state.visible) { if (!visible) {
let warning; let warning;
if (sensitive) { if (sensitive) {
@ -235,7 +255,7 @@ export default class MediaGallery extends React.PureComponent {
} }
children = ( children = (
<button className='media-spoiler' onClick={this.handleOpen} style={style}> <button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
<span className='media-spoiler__warning'>{warning}</span> <span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</button> </button>
@ -243,7 +263,7 @@ export default class MediaGallery extends React.PureComponent {
} else { } else {
const size = media.take(4).size; const size = media.take(4).size;
if (standaloneEligible) { if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />; children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />;
} else { } else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />); children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
@ -252,8 +272,8 @@ export default class MediaGallery extends React.PureComponent {
return ( return (
<div className='media-gallery' style={style}> <div className='media-gallery' style={style}>
<div className={classNames('spoiler-button', { 'spoiler-button--visible': this.state.visible })}> <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div> </div>
{children} {children}

@ -1,7 +1,15 @@
import React from 'react'; import React from 'react';
import { injectIntl, FormattedRelative } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const messages = defineMessages({
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
days: { id: 'relative_time.days', defaultMessage: '{number}d' },
});
const dateFormatOptions = { const dateFormatOptions = {
hour12: false, hour12: false,
year: 'numeric', year: 'numeric',
@ -11,6 +19,47 @@ const dateFormatOptions = {
minute: '2-digit', minute: '2-digit',
}; };
const shortDateFormatOptions = {
month: 'numeric',
day: 'numeric',
};
const SECOND = 1000;
const MINUTE = 1000 * 60;
const HOUR = 1000 * 60 * 60;
const DAY = 1000 * 60 * 60 * 24;
const MAX_DELAY = 2147483647;
const selectUnits = delta => {
const absDelta = Math.abs(delta);
if (absDelta < MINUTE) {
return 'second';
} else if (absDelta < HOUR) {
return 'minute';
} else if (absDelta < DAY) {
return 'hour';
}
return 'day';
};
const getUnitDelay = units => {
switch (units) {
case 'second':
return SECOND;
case 'minute':
return MINUTE;
case 'hour':
return HOUR;
case 'day':
return DAY;
default:
return MAX_DELAY;
}
};
@injectIntl @injectIntl
export default class RelativeTimestamp extends React.Component { export default class RelativeTimestamp extends React.Component {
@ -19,20 +68,74 @@ export default class RelativeTimestamp extends React.Component {
timestamp: PropTypes.string.isRequired, timestamp: PropTypes.string.isRequired,
}; };
shouldComponentUpdate (nextProps) { state = {
now: this.props.intl.now(),
};
shouldComponentUpdate (nextProps, nextState) {
// As of right now the locale doesn't change without a new page load, // As of right now the locale doesn't change without a new page load,
// but we might as well check in case that ever changes. // but we might as well check in case that ever changes.
return this.props.timestamp !== nextProps.timestamp || return this.props.timestamp !== nextProps.timestamp ||
this.props.intl.locale !== nextProps.intl.locale; this.props.intl.locale !== nextProps.intl.locale ||
this.state.now !== nextState.now;
}
componentWillReceiveProps (nextProps) {
if (this.props.timestamp !== nextProps.timestamp) {
this.setState({ now: this.props.intl.now() });
}
}
componentDidMount () {
this._scheduleNextUpdate(this.props, this.state);
}
componentWillUpdate (nextProps, nextState) {
this._scheduleNextUpdate(nextProps, nextState);
}
_scheduleNextUpdate (props, state) {
clearTimeout(this._timer);
const { timestamp } = props;
const delta = (new Date(timestamp)).getTime() - state.now;
const unitDelay = getUnitDelay(selectUnits(delta));
const unitRemainder = Math.abs(delta % unitDelay);
const updateInterval = 1000 * 10;
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
this._timer = setTimeout(() => {
this.setState({ now: this.props.intl.now() });
}, delay);
} }
render () { render () {
const { timestamp, intl } = this.props; const { timestamp, intl } = this.props;
const date = new Date(timestamp);
const date = new Date(timestamp);
const delta = this.state.now - date.getTime();
let relativeTime;
if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(messages.just_now);
} else if (delta < 3 * DAY) {
if (delta < MINUTE) {
relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
} else if (delta < HOUR) {
relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
} else if (delta < DAY) {
relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
} else {
relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
}
} else {
relativeTime = intl.formatDate(date, shortDateFormatOptions);
}
return ( return (
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
<FormattedRelative value={date} /> {relativeTime}
</time> </time>
); );
} }

@ -6,6 +6,8 @@ import LoadMore from './load_more';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import classNames from 'classnames';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
export default class ScrollableList extends PureComponent { export default class ScrollableList extends PureComponent {
@ -66,6 +68,7 @@ export default class ScrollableList extends PureComponent {
componentDidMount () { componentDidMount () {
this.attachScrollListener(); this.attachScrollListener();
this.attachIntersectionObserver(); this.attachIntersectionObserver();
attachFullscreenListener(this.onFullScreenChange);
// Handle initial scroll posiiton // Handle initial scroll posiiton
this.handleScroll(); this.handleScroll();
@ -92,6 +95,11 @@ export default class ScrollableList extends PureComponent {
componentWillUnmount () { componentWillUnmount () {
this.detachScrollListener(); this.detachScrollListener();
this.detachIntersectionObserver(); this.detachIntersectionObserver();
detachFullscreenListener(this.onFullScreenChange);
}
onFullScreenChange = () => {
this.setState({ fullscreen: isFullscreen() });
} }
attachIntersectionObserver () { attachIntersectionObserver () {
@ -137,34 +145,9 @@ export default class ScrollableList extends PureComponent {
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
} }
handleKeyDown = (e) => {
if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
const article = (() => {
switch (e.key) {
case 'PageDown':
return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
case 'PageUp':
return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
case 'End':
return this.node.querySelector('[role="feed"] > article:last-of-type');
case 'Home':
return this.node.querySelector('[role="feed"] > article:first-of-type');
default:
return null;
}
})();
if (article) {
e.preventDefault();
article.focus();
article.scrollIntoView();
}
}
}
render () { render () {
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
const { fullscreen } = this.state;
const childrenCount = React.Children.count(children); const childrenCount = React.Children.count(children);
const loadMore = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; const loadMore = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
@ -172,8 +155,8 @@ export default class ScrollableList extends PureComponent {
if (isLoading || childrenCount > 0 || !emptyMessage) { if (isLoading || childrenCount > 0 || !emptyMessage) {
scrollableArea = ( scrollableArea = (
<div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
<div role='feed' className='item-list' onKeyDown={this.handleKeyDown}> <div role='feed' className='item-list'>
{prepend} {prepend}
{React.Children.map(this.props.children, (child, index) => ( {React.Children.map(this.props.children, (child, index) => (

@ -13,6 +13,8 @@ import StatusActionBar from './status_action_bar';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from '../features/ui/util/async-components'; import { MediaGallery, Video } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames';
// We use the component (and not the container) since we do not want // We use the component (and not the container) since we do not want
// to use the progress bar to show download progress // to use the progress bar to show download progress
@ -42,6 +44,8 @@ export default class Status extends ImmutablePureComponent {
autoPlayGif: PropTypes.bool, autoPlayGif: PropTypes.bool,
muted: PropTypes.bool, muted: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
}; };
state = { state = {
@ -92,16 +96,62 @@ export default class Status extends ImmutablePureComponent {
} }
handleOpenVideo = startTime => { handleOpenVideo = startTime => {
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime);
}
handleHotkeyReply = e => {
e.preventDefault();
this.props.onReply(this._properStatus(), this.context.router.history);
}
handleHotkeyFavourite = () => {
this.props.onFavourite(this._properStatus());
}
handleHotkeyBoost = e => {
this.props.onReblog(this._properStatus(), e);
}
handleHotkeyMention = e => {
e.preventDefault();
this.props.onMention(this._properStatus().get('account'), this.context.router.history);
}
handleHotkeyOpen = () => {
this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
}
handleHotkeyOpenProfile = () => {
this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
}
handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.status.get('id'));
}
handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.status.get('id'));
}
_properStatus () {
const { status } = this.props;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
return status.get('reblog');
} else {
return status;
}
} }
render () { render () {
let media = null; let media = null;
let statusAvatar; let statusAvatar, prepend;
const { status, account, hidden, ...other } = this.props; const { hidden } = this.props;
const { isExpanded } = this.state; const { isExpanded } = this.state;
let { status, account, ...other } = this.props;
if (status === null) { if (status === null) {
return null; return null;
} }
@ -118,16 +168,15 @@ export default class Status extends ImmutablePureComponent {
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
return ( prepend = (
<div className='status__wrapper' data-id={status.get('id')} > <div className='status__prepend'>
<div className='status__prepend'> <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
</div>
<Status {...other} status={status.get('reblog')} account={status.get('account')} />
</div> </div>
); );
account = status.get('account');
status = status.get('reblog');
} }
if (status.get('media_attachments').size > 0 && !this.props.muted) { if (status.get('media_attachments').size > 0 && !this.props.muted) {
@ -163,26 +212,43 @@ export default class Status extends ImmutablePureComponent {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
} }
const handlers = this.props.muted ? {} : {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
return ( return (
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}> <HotKeys handlers={handlers}>
<div className='status__info'> <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> {prepend}
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'> <div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__avatar'> <div className='status__info'>
{statusAvatar} <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div>
<DisplayName account={status.get('account')} /> <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
</a> <div className='status__avatar'>
</div> {statusAvatar}
</div>
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> <DisplayName account={status.get('account')} />
</a>
</div>
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
{media} {media}
<StatusActionBar {...this.props} /> <StatusActionBar status={status} account={account} {...other} />
</div> </div>
</div>
</HotKeys>
); );
} }

@ -147,7 +147,7 @@ export default class StatusContent extends React.PureComponent {
} }
return ( return (
<div className={classNames} ref={this.setRef} tabIndex='0' aria-label={status.get('search_index')} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
<span dangerouslySetInnerHTML={spoilerContent} /> <span dangerouslySetInnerHTML={spoilerContent} />
{' '} {' '}
@ -164,7 +164,6 @@ export default class StatusContent extends React.PureComponent {
<div <div
ref={this.setRef} ref={this.setRef}
tabIndex='0' tabIndex='0'
aria-label={status.get('search_index')}
className={classNames} className={classNames}
style={directionStyle} style={directionStyle}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
@ -176,7 +175,6 @@ export default class StatusContent extends React.PureComponent {
return ( return (
<div <div
tabIndex='0' tabIndex='0'
aria-label={status.get('search_index')}
ref={this.setRef} ref={this.setRef}
className='status__content' className='status__content'
style={directionStyle} style={directionStyle}

@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: true, trackScroll: true,
}; };
handleMoveUp = id => {
const elementIndex = this.props.statusIds.indexOf(id) - 1;
this._selectChild(elementIndex);
}
handleMoveDown = id => {
const elementIndex = this.props.statusIds.indexOf(id) + 1;
this._selectChild(elementIndex);
}
_selectChild (index) {
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
element.focus();
}
}
setRef = c => {
this.node = c;
}
render () { render () {
const { statusIds, ...other } = this.props; const { statusIds, ...other } = this.props;
const { isLoading } = other; const { isLoading } = other;
const scrollableContent = (isLoading || statusIds.size > 0) ? ( const scrollableContent = (isLoading || statusIds.size > 0) ? (
statusIds.map((statusId) => ( statusIds.map((statusId) => (
<StatusContainer key={statusId} id={statusId} /> <StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
)) ))
) : null; ) : null;
return ( return (
<ScrollableList {...other}> <ScrollableList {...other} ref={this.setRef}>
{scrollableContent} {scrollableContent}
</ScrollableList> </ScrollableList>
); );

@ -1,207 +0,0 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/player
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
});
@injectIntl
export default class VideoPlayer extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
width: PropTypes.number,
height: PropTypes.number,
sensitive: PropTypes.bool,
intl: PropTypes.object.isRequired,
autoplay: PropTypes.bool,
onOpenVideo: PropTypes.func.isRequired,
};
static defaultProps = {
width: 239,
height: 110,
};
state = {
visible: !this.props.sensitive,
preview: true,
muted: true,
hasAudio: true,
videoError: false,
};
handleClick = () => {
this.setState({ muted: !this.state.muted });
}
handleVideoClick = (e) => {
e.stopPropagation();
const node = this.video;
if (node.paused) {
node.play();
} else {
node.pause();
}
}
handleOpen = () => {
this.setState({ preview: !this.state.preview });
}
handleVisibility = () => {
this.setState({
visible: !this.state.visible,
preview: true,
});
}
handleExpand = () => {
this.video.pause();
this.props.onOpenVideo(this.props.media, this.video.currentTime);
}
setRef = (c) => {
this.video = c;
}
handleLoadedData = () => {
if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError = () => {
this.setState({ videoError: true });
}
componentDidMount () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentDidUpdate () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentWillUnmount () {
if (!this.video) {
return;
}
this.video.removeEventListener('loadeddata', this.handleLoadedData);
this.video.removeEventListener('error', this.handleVideoError);
}
render () {
const { media, intl, width, height, sensitive, autoplay } = this.props;
let spoilerButton = (
<div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}>
<IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
</div>
);
let expandButton = '';
if (this.context.router) {
expandButton = (
<div className='status__video-player-expand'>
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
</div>
);
}
let muteButton = '';
if (this.state.hasAudio) {
muteButton = (
<div className='status__video-player-mute'>
<IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
</div>
);
}
if (!this.state.visible) {
if (sensitive) {
return (
<button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</button>
);
} else {
return (
<button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</button>
);
}
}
if (this.state.preview && !autoplay) {
return (
<button className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
{spoilerButton}
<div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
</button>
);
}
if (this.state.videoError) {
return (
<div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' >
<span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
</div>
);
}
return (
<div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}>
{spoilerButton}
{muteButton}
{expandButton}
<video
className='status__video-player-video'
role='button'
tabIndex='0'
ref={this.setRef}
src={media.get('url')}
autoPlay={!isIOS()}
loop
muted={this.state.muted}
onClick={this.handleVideoClick}
/>
</div>
);
}
}

@ -3,9 +3,8 @@ import { Provider } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import configureStore from '../store/configureStore'; import configureStore from '../store/configureStore';
import { showOnboardingOnce } from '../actions/onboarding'; import { showOnboardingOnce } from '../actions/onboarding';
import BrowserRouter from 'react-router-dom/BrowserRouter'; import { BrowserRouter, Route } from 'react-router-dom';
import Route from 'react-router-dom/Route'; import { ScrollContext } from 'react-router-scroll';
import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext';
import UI from '../features/ui'; import UI from '../features/ui';
import { hydrateStore } from '../actions/store'; import { hydrateStore } from '../actions/store';
import { connectUserStream } from '../actions/streaming'; import { connectUserStream } from '../actions/streaming';

@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
import PublicTimeline from '../features/standalone/public_timeline'; import PublicTimeline from '../features/standalone/public_timeline';
import HashtagTimeline from '../features/standalone/hashtag_timeline';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);
@ -22,15 +23,24 @@ export default class TimelineContainer extends React.PureComponent {
static propTypes = { static propTypes = {
locale: PropTypes.string.isRequired, locale: PropTypes.string.isRequired,
hashtag: PropTypes.string,
}; };
render () { render () {
const { locale } = this.props; const { locale, hashtag } = this.props;
let timeline;
if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} />;
} else {
timeline = <PublicTimeline />;
}
return ( return (
<IntlProvider locale={locale} messages={messages}> <IntlProvider locale={locale} messages={messages}>
<Provider store={store}> <Provider store={store}>
<PublicTimeline /> {timeline}
</Provider> </Provider>
</IntlProvider> </IntlProvider>
); );

@ -1,72 +0,0 @@
import { unicodeMapping } from './emojione_light';
import Trie from 'substring-trie';
const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
const emojify = (str, customEmojis = {}) => {
let rtn = '';
for (;;) {
let match, i = 0, tag;
while (i < str.length && (tag = '<&'.indexOf(str[i])) === -1 && str[i] !== ':' && !(match = trie.search(str.slice(i)))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
if (i === str.length)
break;
else if (tag >= 0) {
const tagend = str.indexOf('>;'[tag], i + 1) + 1;
if (!tagend)
break;
rtn += str.slice(0, tagend);
str = str.slice(tagend);
} else if (str[i] === ':') {
try {
// if replacing :shortname: succeed, exit this block with "continue"
const closeColon = str.indexOf(':', i + 1) + 1;
if (!closeColon) throw null; // no pair of ':'
const lt = str.indexOf('<', i + 1);
if (!(lt === -1 || lt >= closeColon)) throw null; // tag appeared before closing ':'
const shortname = str.slice(i, closeColon);
if (shortname in customEmojis) {
rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[shortname]}" />`;
str = str.slice(closeColon);
continue;
}
} catch (e) {}
// replacing :shortname: failed
rtn += str.slice(0, i + 1);
str = str.slice(i + 1);
} else {
const [filename, shortCode] = unicodeMapping[match];
rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="${assetHost}/emoji/${filename}.svg" />`;
str = str.slice(i + match.length);
}
}
return rtn + str;
};
export default emojify;
export const buildCustomEmojis = customEmojis => {
const emojis = [];
customEmojis.forEach(emoji => {
const shortcode = emoji.get('shortcode');
const url = emoji.get('url');
const name = shortcode.replace(':', '');
emojis.push({
id: name,
name,
short_names: [name],
text: '',
emoticons: [],
keywords: [name],
imageUrl: url,
custom: true,
});
});
return emojis;
};

@ -1,38 +0,0 @@
// @preval
// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
const emojis = require('./emoji_map.json');
const { emojiIndex } = require('emoji-mart');
const excluded = ['®', '©', '™'];
const skins = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {};
Object.keys(emojiIndex.emojis).forEach(key => {
shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
});
const stripModifiers = unicode => {
skins.forEach(tone => {
unicode = unicode.replace(tone, '');
});
return unicode;
};
Object.keys(emojis).forEach(key => {
if (excluded.includes(key)) {
delete emojis[key];
return;
}
const normalizedKey = stripModifiers(key);
let shortcode = shortcodeMap[normalizedKey];
if (!shortcode) {
shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
}
emojis[key] = [emojis[key], shortcode];
});
module.exports.unicodeMapping = emojis;

@ -2,7 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import Link from 'react-router-dom/Link'; import { Link } from 'react-router-dom';
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({

@ -5,7 +5,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import { debounce } from 'lodash';
import UploadButtonContainer from '../containers/upload_button_container'; import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import Collapsable from '../../../components/collapsable'; import Collapsable from '../../../components/collapsable';
@ -94,9 +93,9 @@ export default class ComposeForm extends ImmutablePureComponent {
this.props.onClearSuggestions(); this.props.onClearSuggestions();
} }
onSuggestionsFetchRequested = debounce((token) => { onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token); this.props.onFetchSuggestions(token);
}, 500, { trailing: true }) }
onSuggestionSelected = (tokenStart, token, value) => { onSuggestionSelected = (tokenStart, token, value) => {
this._restoreCaret = null; this._restoreCaret = null;

@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import { Picker, Emoji } from 'emoji-mart'; import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
import { Overlay } from 'react-overlays'; import Overlay from 'react-overlays/lib/Overlay';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
import { buildCustomEmojis } from '../../emoji/emoji';
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@ -25,9 +26,24 @@ const messages = defineMessages({
}); });
const assetHost = process.env.CDN_HOST || ''; const assetHost = process.env.CDN_HOST || '';
let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`; const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
const categoriesSort = [
'recent',
'custom',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
class ModifierPickerMenu extends React.PureComponent { class ModifierPickerMenu extends React.PureComponent {
static propTypes = { static propTypes = {
@ -36,9 +52,8 @@ class ModifierPickerMenu extends React.PureComponent {
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
}; };
handleClick = (e) => { handleClick = e => {
const modifier = [].slice.call(e.currentTarget.parentNode.children).indexOf(e.target) + 1; this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
this.props.onSelect(modifier);
} }
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
@ -78,12 +93,12 @@ class ModifierPickerMenu extends React.PureComponent {
return ( return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button> <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button> <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button> <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button> <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button> <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button> <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
</div> </div>
); );
} }
@ -131,6 +146,8 @@ class EmojiPickerMenu extends React.PureComponent {
static propTypes = { static propTypes = {
custom_emojis: ImmutablePropTypes.list, custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
loading: PropTypes.bool,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired, onPick: PropTypes.func.isRequired,
style: PropTypes.object, style: PropTypes.object,
@ -138,16 +155,20 @@ class EmojiPickerMenu extends React.PureComponent {
arrowOffsetLeft: PropTypes.string, arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string, arrowOffsetTop: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
skinTone: PropTypes.number.isRequired,
onSkinTone: PropTypes.func.isRequired,
autoPlay: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
style: {}, style: {},
loading: true,
placement: 'bottom', placement: 'bottom',
frequentlyUsedEmojis: [],
}; };
state = { state = {
modifierOpen: false, modifierOpen: false,
modifier: 1,
}; };
handleDocumentClick = e => { handleDocumentClick = e => {
@ -210,35 +231,43 @@ class EmojiPickerMenu extends React.PureComponent {
} }
handleModifierChange = modifier => { handleModifierChange = modifier => {
if (modifier !== this.state.modifier) { this.props.onSkinTone(modifier);
this.setState({ modifier });
}
} }
render () { render () {
const { style, intl } = this.props; const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props;
if (loading) {
return <div style={{ width: 299 }} />;
}
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const { modifierOpen, modifier } = this.state; const { modifierOpen } = this.state;
return ( return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<Picker <EmojiPicker
perLine={8} perLine={8}
emojiSize={22} emojiSize={22}
sheetSize={32} sheetSize={32}
custom={buildCustomEmojis(custom_emojis, autoPlay)}
color='' color=''
emoji='' emoji=''
set='twitter' set='twitter'
title={title} title={title}
i18n={this.getI18n()} i18n={this.getI18n()}
onClick={this.handleClick} onClick={this.handleClick}
skin={modifier} include={categoriesSort}
recent={frequentlyUsedEmojis}
skin={skinTone}
showPreview={false}
backgroundImageFn={backgroundImageFn} backgroundImageFn={backgroundImageFn}
emojiTooltip
/> />
<ModifierPicker <ModifierPicker
active={modifierOpen} active={modifierOpen}
modifier={modifier} modifier={skinTone}
onOpen={this.handleModifierOpen} onOpen={this.handleModifierOpen}
onClose={this.handleModifierClose} onClose={this.handleModifierClose}
onChange={this.handleModifierChange} onChange={this.handleModifierChange}
@ -254,12 +283,17 @@ export default class EmojiPickerDropdown extends React.PureComponent {
static propTypes = { static propTypes = {
custom_emojis: ImmutablePropTypes.list, custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
autoPlay: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
}; };
state = { state = {
active: false, active: false,
loading: false,
}; };
setRef = (c) => { setRef = (c) => {
@ -268,6 +302,19 @@ export default class EmojiPickerDropdown extends React.PureComponent {
onShowDropdown = () => { onShowDropdown = () => {
this.setState({ active: true }); this.setState({ active: true });
if (!EmojiPicker) {
this.setState({ loading: true });
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
this.setState({ loading: false });
}).catch(() => {
this.setState({ loading: false });
});
}
} }
onHideDropdown = () => { onHideDropdown = () => {
@ -275,7 +322,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
} }
onToggle = (e) => { onToggle = (e) => {
if (!e.key || e.key === 'Enter') { if (!this.state.loading && (!e.key || e.key === 'Enter')) {
if (this.state.active) { if (this.state.active) {
this.onHideDropdown(); this.onHideDropdown();
} else { } else {
@ -299,15 +346,15 @@ export default class EmojiPickerDropdown extends React.PureComponent {
} }
render () { render () {
const { intl, onPickEmoji } = this.props; const { intl, onPickEmoji, autoPlay, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const { active } = this.state; const { active, loading } = this.state;
return ( return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
<img <img
className='emojione' className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂' alt='🙂'
src={`${assetHost}/emoji/1f602.svg`} src={`${assetHost}/emoji/1f602.svg`}
/> />
@ -316,8 +363,13 @@ export default class EmojiPickerDropdown extends React.PureComponent {
<Overlay show={active} placement='bottom' target={this.findTarget}> <Overlay show={active} placement='bottom' target={this.findTarget}>
<EmojiPickerMenu <EmojiPickerMenu
custom_emojis={this.props.custom_emojis} custom_emojis={this.props.custom_emojis}
loading={loading}
onClose={this.onHideDropdown} onClose={this.onHideDropdown}
onPick={onPickEmoji} onPick={onPickEmoji}
autoPlay={autoPlay}
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/> />
</Overlay> </Overlay>
</div> </div>

@ -2,7 +2,11 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'react-motion/lib/Motion';
import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@ -16,10 +20,77 @@ const messages = defineMessages({
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
}); });
const iconStyle = { const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
height: null,
lineHeight: '27px', class PrivacyDropdownMenu extends React.PureComponent {
};
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
handleClick = e => {
if (e.key === 'Escape') {
this.props.onClose();
} else if (!e.key || e.key === 'Enter') {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
}
}
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
render () {
const { style, items, value } = this.props;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
{items.map(item =>
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
<div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
)}
</div>
)}
</Motion>
);
}
}
@injectIntl @injectIntl
export default class PrivacyDropdown extends React.PureComponent { export default class PrivacyDropdown extends React.PureComponent {
@ -55,26 +126,30 @@ export default class PrivacyDropdown extends React.PureComponent {
handleModalActionClick = (e) => { handleModalActionClick = (e) => {
e.preventDefault(); e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')]; const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose(); this.props.onModalClose();
this.props.onChange(value); this.props.onChange(value);
} }
handleClick = (e) => { handleKeyDown = e => {
if (e.key === 'Escape') { switch(e.key) {
this.setState({ open: false }); case 'Enter':
} else if (!e.key || e.key === 'Enter') { this.handleToggle();
const value = e.currentTarget.getAttribute('data-index'); break;
e.preventDefault(); case 'Escape':
this.setState({ open: false }); this.handleClose();
this.props.onChange(value); break;
} }
} }
onGlobalClick = (e) => { handleClose = () => {
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { this.setState({ open: false });
this.setState({ open: false }); }
}
handleChange = value => {
this.props.onChange(value);
} }
componentWillMount () { componentWillMount () {
@ -88,20 +163,6 @@ export default class PrivacyDropdown extends React.PureComponent {
]; ];
} }
componentDidMount () {
window.addEventListener('click', this.onGlobalClick);
window.addEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
}
componentWillUnmount () {
window.removeEventListener('click', this.onGlobalClick);
window.removeEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
}
setRef = (c) => {
this.node = c;
}
render () { render () {
const { value, intl } = this.props; const { value, intl } = this.props;
const { open } = this.state; const { open } = this.state;
@ -109,19 +170,29 @@ export default class PrivacyDropdown extends React.PureComponent {
const valueOption = this.options.find(item => item.value === value); const valueOption = this.options.find(item => item.value === value);
return ( return (
<div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
<div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} expanded={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
<div className='privacy-dropdown__dropdown'> <IconButton
{open && this.options.map(item => className='privacy-dropdown__value-icon'
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> icon={valueOption.icon}
<div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> title={intl.formatMessage(messages.change_privacy)}
<div className='privacy-dropdown__option__content'> size={18}
<strong>{item.text}</strong> expanded={open}
{item.meta} active={open}
</div> inverted
</div> onClick={this.handleToggle}
)} style={{ height: null, lineHeight: '27px' }}
/>
</div> </div>
<Overlay show={open} placement='bottom' target={this}>
<PrivacyDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</Overlay>
</div> </div>
); );
} }

@ -1,11 +1,47 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'react-motion/lib/Motion';
import spring from 'react-motion/lib/spring';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
}); });
class SearchPopout extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
};
render () {
const { style } = this.props;
return (
<div style={{ ...style, position: 'absolute', width: 285 }}>
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
<ul>
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
</ul>
<FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
</div>
)}
</Motion>
</div>
);
}
}
@injectIntl @injectIntl
export default class Search extends React.PureComponent { export default class Search extends React.PureComponent {
@ -19,6 +55,10 @@ export default class Search extends React.PureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
state = {
expanded: false,
};
handleChange = (e) => { handleChange = (e) => {
this.props.onChange(e.target.value); this.props.onChange(e.target.value);
} }
@ -35,6 +75,8 @@ export default class Search extends React.PureComponent {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
this.props.onSubmit(); this.props.onSubmit();
} else if (e.key === 'Escape') {
document.querySelector('.ui').parentElement.focus();
} }
} }
@ -43,11 +85,17 @@ export default class Search extends React.PureComponent {
} }
handleFocus = () => { handleFocus = () => {
this.setState({ expanded: true });
this.props.onShow(); this.props.onShow();
} }
handleBlur = () => {
this.setState({ expanded: false });
}
render () { render () {
const { intl, value, submitted } = this.props; const { intl, value, submitted } = this.props;
const { expanded } = this.state;
const hasValue = value.length > 0 || submitted; const hasValue = value.length > 0 || submitted;
return ( return (
@ -62,6 +110,7 @@ export default class Search extends React.PureComponent {
onChange={this.handleChange} onChange={this.handleChange}
onKeyUp={this.handleKeyDown} onKeyUp={this.handleKeyDown}
onFocus={this.handleFocus} onFocus={this.handleFocus}
onBlur={this.handleBlur}
/> />
</label> </label>
@ -69,6 +118,10 @@ export default class Search extends React.PureComponent {
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
<i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} /> <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
</div> </div>
<Overlay show={expanded && !hasValue} placement='bottom' target={this}>
<SearchPopout />
</Overlay>
</div> </div>
); );
} }

@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container'; import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../../glitch/components/status/container'; import StatusContainer from '../../../../glitch/components/status/container';
import Link from 'react-router-dom/Link'; import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
export default class SearchResults extends ImmutablePureComponent { export default class SearchResults extends ImmutablePureComponent {

@ -0,0 +1,96 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from '../../../components/icon_button';
import Motion from 'react-motion/lib/Motion';
import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
const messages = defineMessages({
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
});
@injectIntl
export default class Upload extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onUndo: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
};
state = {
hovered: false,
focused: false,
dirtyDescription: null,
};
handleUndoClick = () => {
this.props.onUndo(this.props.media.get('id'));
}
handleInputChange = e => {
this.setState({ dirtyDescription: e.target.value });
}
handleMouseEnter = () => {
this.setState({ hovered: true });
}
handleMouseLeave = () => {
this.setState({ hovered: false });
}
handleInputFocus = () => {
this.setState({ focused: true });
}
handleInputBlur = () => {
const { dirtyDescription } = this.state;
this.setState({ focused: false, dirtyDescription: null });
if (dirtyDescription !== null) {
this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
}
}
render () {
const { intl, media } = this.props;
const active = this.state.hovered || this.state.focused;
const description = this.state.dirtyDescription || media.get('description') || '';
return (
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
<div className={classNames('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<input
placeholder={intl.formatMessage(messages.description)}
type='text'
value={description}
maxLength={420}
onFocus={this.handleInputFocus}
onChange={this.handleInputChange}
onBlur={this.handleInputBlur}
/>
</label>
</div>
</div>
)}
</Motion>
</div>
);
}
}

@ -1,49 +1,27 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import UploadProgressContainer from '../containers/upload_progress_container'; import UploadProgressContainer from '../containers/upload_progress_container';
import Motion from 'react-motion/lib/Motion'; import ImmutablePureComponent from 'react-immutable-pure-component';
import spring from 'react-motion/lib/spring'; import UploadContainer from '../containers/upload_container';
const messages = defineMessages({ export default class UploadForm extends ImmutablePureComponent {
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
});
@injectIntl
export default class UploadForm extends React.PureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.list.isRequired, mediaIds: ImmutablePropTypes.list.isRequired,
onRemoveFile: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}; };
onRemoveFile = (e) => {
const id = e.currentTarget.parentElement.getAttribute('data-id');
this.props.onRemoveFile(id);
}
render () { render () {
const { intl, media } = this.props; const { mediaIds } = this.props;
const uploads = media.map(attachment =>
<div className='compose-form__upload' key={attachment.get('id')}>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) =>
<div className='compose-form__upload-thumbnail' data-id={attachment.get('id')} style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.onRemoveFile} />
</div>
}
</Motion>
</div>
);
return ( return (
<div className='compose-form__upload-wrapper'> <div className='compose-form__upload-wrapper'>
<UploadProgressContainer /> <UploadProgressContainer />
<div className='compose-form__uploads-wrapper'>{uploads}</div>
<div className='compose-form__uploads-wrapper'>
{mediaIds.map(id => (
<UploadContainer id={id} key={id} />
))}
</div>
</div> </div>
); );
} }

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Motion from 'react-motion/lib/Motion';
import spring from 'react-motion/lib/spring';
export default class Warning extends React.PureComponent { export default class Warning extends React.PureComponent {
@ -11,9 +13,13 @@ export default class Warning extends React.PureComponent {
const { message } = this.props; const { message } = this.props;
return ( return (
<div className='compose-form__warning'> <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{message} {({ opacity, scaleX, scaleY }) => (
</div> <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
{message}
</div>
)}
</Motion>
); );
} }

@ -1,8 +1,83 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import EmojiPickerDropdown from '../components/emoji_picker_dropdown'; import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
import { changeSetting } from '../../../actions/settings';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
import { useEmoji } from '../../../actions/emojis';
const perLine = 8;
const lines = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < DEFAULTS.length) {
emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;
});
const getCustomEmojis = createSelector([
state => state.get('custom_emojis'),
], emojis => emojis.sort((a, b) => {
const aShort = a.get('shortcode').toLowerCase();
const bShort = b.get('shortcode').toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort ) {
return 1;
} else {
return 0;
}
}));
const mapStateToProps = state => ({ const mapStateToProps = state => ({
custom_emojis: state.get('custom_emojis'), custom_emojis: getCustomEmojis(state),
autoPlay: state.getIn(['meta', 'auto_play_gif']),
skinTone: state.getIn(['settings', 'skinTone']),
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
});
const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
onSkinTone: skinTone => {
dispatch(changeSetting(['skinTone'], skinTone));
},
onPickEmoji: emoji => {
dispatch(useEmoji(emoji));
if (onPickEmoji) {
onPickEmoji(emoji);
}
},
}); });
export default connect(mapStateToProps)(EmojiPickerDropdown); export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);

@ -0,0 +1,21 @@
import { connect } from 'react-redux';
import Upload from '../components/upload';
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
});
const mapDispatchToProps = dispatch => ({
onUndo: id => {
dispatch(undoUploadCompose(id));
},
onDescriptionChange: (id, description) => {
dispatch(changeUploadCompose(id, description));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Upload);

@ -1,17 +1,8 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import UploadForm from '../components/upload_form'; import UploadForm from '../components/upload_form';
import { undoUploadCompose } from '../../../actions/compose';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
media: state.getIn(['compose', 'media_attachments']), mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
}); });
const mapDispatchToProps = dispatch => ({ export default connect(mapStateToProps)(UploadForm);
onRemoveFile (media_id) {
dispatch(undoUploadCompose(media_id));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);

@ -7,7 +7,7 @@ import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from '../../actions/compose'; import { mountCompose, unmountCompose } from '../../actions/compose';
import { openModal } from '../../actions/modal'; import { openModal } from '../../actions/modal';
import { changeLocalSetting } from '../../../glitch/actions/local_settings'; import { changeLocalSetting } from '../../../glitch/actions/local_settings';
import Link from 'react-router-dom/Link'; import { Link } from 'react-router-dom';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import SearchContainer from './containers/search_container'; import SearchContainer from './containers/search_container';
import Motion from 'react-motion/lib/Motion'; import Motion from 'react-motion/lib/Motion';

@ -0,0 +1,77 @@
import unicodeMapping from './emoji_unicode_mapping_light';
import Trie from 'substring-trie';
const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
let allowAnimations = false;
const emojify = (str, customEmojis = {}) => {
let rtn = '';
for (;;) {
let match, i = 0, tag;
while (i < str.length && (tag = '<&:'.indexOf(str[i])) === -1 && !(match = trie.search(str.slice(i)))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
let rend, replacement = '';
if (i === str.length) {
break;
} else if (str[i] === ':') {
if (!(() => {
rend = str.indexOf(':', i + 1) + 1;
if (!rend) return false; // no pair of ':'
const lt = str.indexOf('<', i + 1);
if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':'
const shortname = str.slice(i, rend);
// now got a replacee as ':shortname:'
// if you want additional emoji handler, add statements below which set replacement and return true.
if (shortname in customEmojis) {
const filename = allowAnimations ? customEmojis[shortname].url : customEmojis[shortname].static_url;
replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
return true;
}
return false;
})()) rend = ++i;
} else if (tag >= 0) { // <, &
rend = str.indexOf('>;'[tag], i + 1) + 1;
if (!rend) break;
i = rend;
} 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" />`;
rend = i + match.length;
}
rtn += str.slice(0, i) + replacement;
str = str.slice(rend);
}
return rtn + str;
};
export default emojify;
export const buildCustomEmojis = (customEmojis, overrideAllowAnimations = false) => {
const emojis = [];
allowAnimations = overrideAllowAnimations;
customEmojis.forEach(emoji => {
const shortcode = emoji.get('shortcode');
const url = allowAnimations ? emoji.get('url') : emoji.get('static_url');
const name = shortcode.replace(':', '');
emojis.push({
id: name,
name,
short_names: [name],
text: '',
emoticons: [],
keywords: [name],
imageUrl: url,
custom: true,
});
});
return emojis;
};

@ -0,0 +1,92 @@
// @preval
// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
// This file contains the compressed version of the emoji data from
// both emoji_map.json and from emoji-mart's emojiIndex and data objects.
// It's designed to be emitted in an array format to take up less space
// over the wire.
const { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
const emojiMap = require('./emoji_map.json');
const { emojiIndex } = require('emoji-mart');
const emojiMartData = require('emoji-mart/dist/data').default;
const excluded = ['®', '©', '™'];
const skins = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {};
const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => {
shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
});
const stripModifiers = unicode => {
skins.forEach(tone => {
unicode = unicode.replace(tone, '');
});
return unicode;
};
Object.keys(emojiMap).forEach(key => {
if (excluded.includes(key)) {
delete emojiMap[key];
return;
}
const normalizedKey = stripModifiers(key);
let shortcode = shortcodeMap[normalizedKey];
if (!shortcode) {
shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
}
const filename = emojiMap[key];
const filenameData = [key];
if (unicodeToFilename(key) !== filename) {
// filename can't be derived using unicodeToFilename
filenameData.push(filename);
}
if (typeof shortcode === 'undefined') {
emojisWithoutShortCodes.push(filenameData);
} else {
if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
shortCodesToEmojiData[shortcode] = [[]];
}
shortCodesToEmojiData[shortcode][0].push(filenameData);
}
});
Object.keys(emojiIndex.emojis).forEach(key => {
const { native } = emojiIndex.emojis[key];
const { short_names, search, unified } = emojiMartData.emojis[key];
if (short_names[0] !== key) {
throw new Error('The compresser expects the first short_code to be the ' +
'key. It may need to be rewritten if the emoji change such that this ' +
'is no longer the case.');
}
short_names.splice(0, 1); // first short name can be inferred from the key
const searchData = [native, short_names, search];
if (unicodeToUnifiedName(native) !== unified) {
// unified name can't be derived from unicodeToUnifiedName
searchData.push(unified);
}
shortCodesToEmojiData[key].push(searchData);
});
// JSON.parse/stringify is to emulate what @preval is doing and avoid any
// inconsistent behavior in dev mode
module.exports = JSON.parse(JSON.stringify([
shortCodesToEmojiData,
emojiMartData.skins,
emojiMartData.categories,
emojiMartData.short_names,
emojisWithoutShortCodes,
]));

@ -0,0 +1,41 @@
// The output of this module is designed to mimic emoji-mart's
// "data" object, such that we can use it for a light version of emoji-mart's
// emojiIndex.search functionality.
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed');
const emojis = {};
// decompress
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
let [
filenameData, // eslint-disable-line no-unused-vars
searchData,
] = shortCodesToEmojiData[shortCode];
let [
native,
short_names,
search,
unified,
] = searchData;
if (!unified) {
// unified name can be derived from unicodeToUnifiedName
unified = unicodeToUnifiedName(native);
}
short_names = [shortCode].concat(short_names);
emojis[shortCode] = {
native,
search,
short_names,
unified,
};
});
module.exports = {
emojis,
skins,
categories,
short_names,
};

@ -0,0 +1,157 @@
// This code is largely borrowed from:
// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
import data from './emoji_mart_data_light';
import { getData, getSanitizedData, intersect } from './emoji_utils';
let originalPool = {};
let index = {};
let emojisList = {};
let emoticonsList = {};
for (let emoji in data.emojis) {
let emojiData = data.emojis[emoji];
let { short_names, emoticons } = emojiData;
let id = short_names[0];
if (emoticons) {
emoticons.forEach(emoticon => {
if (emoticonsList[emoticon]) {
return;
}
emoticonsList[emoticon] = id;
});
}
emojisList[id] = getSanitizedData(id);
originalPool[id] = emojiData;
}
function addCustomToPool(custom, pool) {
custom.forEach((emoji) => {
let emojiId = emoji.id || emoji.short_names[0];
if (emojiId && !pool[emojiId]) {
pool[emojiId] = getData(emoji);
emojisList[emojiId] = getSanitizedData(emoji);
}
});
}
function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) {
addCustomToPool(custom, originalPool);
maxResults = maxResults || 75;
include = include || [];
exclude = exclude || [];
let results = null,
pool = originalPool;
if (value.length) {
if (value === '-' || value === '-1') {
return [emojisList['-1']];
}
let values = value.toLowerCase().split(/[\s|,|\-|_]+/),
allResults = [];
if (values.length > 2) {
values = [values[0], values[1]];
}
if (include.length || exclude.length) {
pool = {};
data.categories.forEach(category => {
let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true;
let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false;
if (!isIncluded || isExcluded) {
return;
}
category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]);
});
if (custom.length) {
let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true;
let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false;
if (customIsIncluded && !customIsExcluded) {
addCustomToPool(custom, pool);
}
}
}
allResults = values.map((value) => {
let aPool = pool,
aIndex = index,
length = 0;
for (let charIndex = 0; charIndex < value.length; charIndex++) {
const char = value[charIndex];
length++;
aIndex[char] = aIndex[char] || {};
aIndex = aIndex[char];
if (!aIndex.results) {
let scores = {};
aIndex.results = [];
aIndex.pool = {};
for (let id in aPool) {
let emoji = aPool[id],
{ search } = emoji,
sub = value.substr(0, length),
subIndex = search.indexOf(sub);
if (subIndex !== -1) {
let score = subIndex + 1;
if (sub === id) score = 0;
aIndex.results.push(emojisList[id]);
aIndex.pool[id] = emoji;
scores[id] = score;
}
}
aIndex.results.sort((a, b) => {
let aScore = scores[a.id],
bScore = scores[b.id];
return aScore - bScore;
});
}
aPool = aIndex.pool;
}
return aIndex.results;
}).filter(a => a);
if (allResults.length > 1) {
results = intersect.apply(null, allResults);
} else if (allResults.length) {
results = allResults[0];
} else {
results = [];
}
}
if (results) {
if (emojisToShowFilter) {
results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified));
}
if (results && results.length > maxResults) {
results = results.slice(0, maxResults);
}
}
return results;
}
export { search };

@ -0,0 +1,7 @@
import Picker from 'emoji-mart/dist-es/components/picker';
import Emoji from 'emoji-mart/dist-es/components/emoji';
export {
Picker,
Emoji,
};

@ -0,0 +1,35 @@
// A mapping of unicode strings to an object containing the filename
// (i.e. the svg filename) and a shortCode intended to be shown
// as a "title" attribute in an HTML element (aka tooltip).
const [
shortCodesToEmojiData,
skins, // eslint-disable-line no-unused-vars
categories, // eslint-disable-line no-unused-vars
short_names, // eslint-disable-line no-unused-vars
emojisWithoutShortCodes,
] = require('./emoji_compressed');
const { unicodeToFilename } = require('./unicode_to_filename');
// decompress
const unicodeMapping = {};
function processEmojiMapData(emojiMapData, shortCode) {
let [ native, filename ] = emojiMapData;
if (!filename) {
// filename name can be derived from unicodeToFilename
filename = unicodeToFilename(native);
}
unicodeMapping[native] = {
shortCode: shortCode,
filename: filename,
};
}
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
let [ filenameData ] = shortCodesToEmojiData[shortCode];
filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode));
});
emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData));
module.exports = unicodeMapping;

@ -0,0 +1,258 @@
// This code is largely borrowed from:
// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js
import data from './emoji_mart_data_light';
const buildSearch = (data) => {
const search = [];
let addToSearch = (strings, split) => {
if (!strings) {
return;
}
(Array.isArray(strings) ? strings : [strings]).forEach((string) => {
(split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => {
s = s.toLowerCase();
if (search.indexOf(s) === -1) {
search.push(s);
}
});
});
};
addToSearch(data.short_names, true);
addToSearch(data.name, true);
addToSearch(data.keywords, false);
addToSearch(data.emoticons, false);
return search.join(',');
};
const _String = String;
const stringFromCodePoint = _String.fromCodePoint || function () {
let MAX_SIZE = 0x4000;
let codeUnits = [];
let highSurrogate;
let lowSurrogate;
let index = -1;
let length = arguments.length;
if (!length) {
return '';
}
let result = '';
while (++index < length) {
let codePoint = Number(arguments[index]);
if (
!isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
codePoint < 0 || // not a valid Unicode code point
codePoint > 0x10FFFF || // not a valid Unicode code point
Math.floor(codePoint) !== codePoint // not an integer
) {
throw RangeError('Invalid code point: ' + codePoint);
}
if (codePoint <= 0xFFFF) { // BMP code point
codeUnits.push(codePoint);
} else { // Astral code point; split in surrogate halves
// http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
codePoint -= 0x10000;
highSurrogate = (codePoint >> 10) + 0xD800;
lowSurrogate = (codePoint % 0x400) + 0xDC00;
codeUnits.push(highSurrogate, lowSurrogate);
}
if (index + 1 === length || codeUnits.length > MAX_SIZE) {
result += String.fromCharCode.apply(null, codeUnits);
codeUnits.length = 0;
}
}
return result;
};
const _JSON = JSON;
const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/;
const SKINS = [
'1F3FA', '1F3FB', '1F3FC',
'1F3FD', '1F3FE', '1F3FF',
];
function unifiedToNative(unified) {
let unicodes = unified.split('-'),
codePoints = unicodes.map((u) => `0x${u}`);
return stringFromCodePoint.apply(null, codePoints);
}
function sanitize(emoji) {
let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji,
id = emoji.id || short_names[0],
colons = `:${id}:`;
if (custom) {
return {
id,
name,
colons,
emoticons,
custom,
imageUrl,
};
}
if (skin_tone) {
colons += `:skin-tone-${skin_tone}:`;
}
return {
id,
name,
colons,
emoticons,
unified: unified.toLowerCase(),
skin: skin_tone || (skin_variations ? 1 : null),
native: unifiedToNative(unified),
};
}
function getSanitizedData() {
return sanitize(getData(...arguments));
}
function getData(emoji, skin, set) {
let emojiData = {};
if (typeof emoji === 'string') {
let matches = emoji.match(COLONS_REGEX);
if (matches) {
emoji = matches[1];
if (matches[2]) {
skin = parseInt(matches[2]);
}
}
if (data.short_names.hasOwnProperty(emoji)) {
emoji = data.short_names[emoji];
}
if (data.emojis.hasOwnProperty(emoji)) {
emojiData = data.emojis[emoji];
}
} else if (emoji.id) {
if (data.short_names.hasOwnProperty(emoji.id)) {
emoji.id = data.short_names[emoji.id];
}
if (data.emojis.hasOwnProperty(emoji.id)) {
emojiData = data.emojis[emoji.id];
skin = skin || emoji.skin;
}
}
if (!Object.keys(emojiData).length) {
emojiData = emoji;
emojiData.custom = true;
if (!emojiData.search) {
emojiData.search = buildSearch(emoji);
}
}
emojiData.emoticons = emojiData.emoticons || [];
emojiData.variations = emojiData.variations || [];
if (emojiData.skin_variations && skin > 1 && set) {
emojiData = JSON.parse(_JSON.stringify(emojiData));
let skinKey = SKINS[skin - 1],
variationData = emojiData.skin_variations[skinKey];
if (!variationData.variations && emojiData.variations) {
delete emojiData.variations;
}
if (variationData[`has_img_${set}`]) {
emojiData.skin_tone = skin;
for (let k in variationData) {
let v = variationData[k];
emojiData[k] = v;
}
}
}
if (emojiData.variations && emojiData.variations.length) {
emojiData = JSON.parse(_JSON.stringify(emojiData));
emojiData.unified = emojiData.variations.shift();
}
return emojiData;
}
function uniq(arr) {
return arr.reduce((acc, item) => {
if (acc.indexOf(item) === -1) {
acc.push(item);
}
return acc;
}, []);
}
function intersect(a, b) {
const uniqA = uniq(a);
const uniqB = uniq(b);
return uniqA.filter(item => uniqB.indexOf(item) >= 0);
}
function deepMerge(a, b) {
let o = {};
for (let key in a) {
let originalValue = a[key],
value = originalValue;
if (b.hasOwnProperty(key)) {
value = b[key];
}
if (typeof value === 'object') {
value = deepMerge(originalValue, value);
}
o[key] = value;
}
return o;
}
// https://github.com/sonicdoe/measure-scrollbar
function measureScrollbar() {
const div = document.createElement('div');
div.style.width = '100px';
div.style.height = '100px';
div.style.overflow = 'scroll';
div.style.position = 'absolute';
div.style.top = '-9999px';
document.body.appendChild(div);
const scrollbarWidth = div.offsetWidth - div.clientWidth;
document.body.removeChild(div);
return scrollbarWidth;
}
export {
getData,
getSanitizedData,
uniq,
intersect,
deepMerge,
unifiedToNative,
measureScrollbar,
};

@ -0,0 +1,26 @@
// taken from:
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
exports.unicodeToFilename = (str) => {
let result = '';
let charCode = 0;
let p = 0;
let i = 0;
while (i < str.length) {
charCode = str.charCodeAt(i++);
if (p) {
if (result.length > 0) {
result += '-';
}
result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16);
p = 0;
} else if (0xD800 <= charCode && charCode <= 0xDBFF) {
p = charCode;
} else {
if (result.length > 0) {
result += '-';
}
result += charCode.toString(16);
}
}
return result;
};

@ -0,0 +1,17 @@
function padLeft(str, num) {
while (str.length < num) {
str = '0' + str;
}
return str;
}
exports.unicodeToUnifiedName = (str) => {
let output = '';
for (let i = 0; i < str.length; i += 2) {
if (i > 0) {
output += '-';
}
output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4);
}
return output;
};

@ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import Link from 'react-router-dom/Link'; import { Link } from 'react-router-dom';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' }, title: { id: 'column.home', defaultMessage: 'Home' },

@ -9,61 +9,126 @@ import AccountContainer from '../../../containers/account_container';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Permalink from '../../../components/permalink'; import Permalink from '../../../components/permalink';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
export default class Notification extends ImmutablePureComponent { export default class Notification extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = { static propTypes = {
notification: ImmutablePropTypes.map.isRequired, notification: ImmutablePropTypes.map.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
onMoveUp: PropTypes.func.isRequired,
onMoveDown: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
}; };
handleMoveUp = () => {
const { notification, onMoveUp } = this.props;
onMoveUp(notification.get('id'));
}
handleMoveDown = () => {
const { notification, onMoveDown } = this.props;
onMoveDown(notification.get('id'));
}
handleOpen = () => {
const { notification } = this.props;
if (notification.get('status')) {
this.context.router.history.push(`/statuses/${notification.get('status')}`);
} else {
this.handleOpenProfile();
}
}
handleOpenProfile = () => {
const { notification } = this.props;
this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
}
handleMention = e => {
e.preventDefault();
const { notification, onMention } = this.props;
onMention(notification.get('account'), this.context.router.history);
}
getHandlers () {
return {
moveUp: this.handleMoveUp,
moveDown: this.handleMoveDown,
open: this.handleOpen,
openProfile: this.handleOpenProfile,
mention: this.handleMention,
reply: this.handleMention,
};
}
renderFollow (account, link) { renderFollow (account, link) {
return ( return (
<div className='notification notification-follow'> <HotKeys handlers={this.getHandlers()}>
<div className='notification__message'> <div className='notification notification-follow focusable' tabIndex='0'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__message'>
<i className='fa fa-fw fa-user-plus' /> <div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-user-plus' />
</div>
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
</div> </div>
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
</div> </div>
</HotKeys>
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
</div>
); );
} }
renderMention (notification) { renderMention (notification) {
return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />; return (
<StatusContainer
id={notification.get('status')}
withDismiss
hidden={this.props.hidden}
onMoveDown={this.handleMoveDown}
onMoveUp={this.handleMoveUp}
/>
);
} }
renderFavourite (notification, link) { renderFavourite (notification, link) {
return ( return (
<div className='notification notification-favourite'> <HotKeys handlers={this.getHandlers()}>
<div className='notification__message'> <div className='notification notification-favourite focusable' tabIndex='0'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__message'>
<i className='fa fa-fw fa-star star-icon' /> <div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-star star-icon' />
</div>
<FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
</div> </div>
<FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
</div>
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
</div> </div>
</HotKeys>
); );
} }
renderReblog (notification, link) { renderReblog (notification, link) {
return ( return (
<div className='notification notification-reblog'> <HotKeys handlers={this.getHandlers()}>
<div className='notification__message'> <div className='notification notification-reblog focusable' tabIndex='0'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__message'>
<i className='fa fa-fw fa-retweet' /> <div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-retweet' />
</div>
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
</div> </div>
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
</div>
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
</div> </div>
</HotKeys>
); );
} }

@ -4,6 +4,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeGetNotification } from '../../../selectors'; import { makeGetNotification } from '../../../selectors';
import Notification from '../components/notification'; import Notification from '../components/notification';
import { mentionCompose } from '../../../actions/compose';
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getNotification = makeGetNotification(); const getNotification = makeGetNotification();
@ -15,4 +16,10 @@ const makeMapStateToProps = () => {
return mapStateToProps; return mapStateToProps;
}; };
export default connect(makeMapStateToProps)(Notification); const mapDispatchToProps = dispatch => ({
onMention: (account, router) => {
dispatch(mentionCompose(account, router));
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);

@ -103,6 +103,24 @@ export default class Notifications extends React.PureComponent {
this.column = c; this.column = c;
} }
handleMoveUp = id => {
const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
this._selectChild(elementIndex);
}
handleMoveDown = id => {
const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
this._selectChild(elementIndex);
}
_selectChild (index) {
const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
element.focus();
}
}
render () { render () {
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
@ -113,7 +131,15 @@ export default class Notifications extends React.PureComponent {
if (isLoading && this.scrollableContent) { if (isLoading && this.scrollableContent) {
scrollableContent = this.scrollableContent; scrollableContent = this.scrollableContent;
} else if (notifications.size > 0 || hasMore) { } else if (notifications.size > 0 || hasMore) {
scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />); scrollableContent = notifications.map((item) => (
<NotificationContainer
key={item.get('id')}
notification={item}
accountId={item.get('account')}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
));
} else { } else {
scrollableContent = null; scrollableContent = null;
} }

@ -0,0 +1,70 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../../ui/containers/status_list_container';
import {
refreshHashtagTimeline,
expandHashtagTimeline,
} from '../../../actions/timelines';
import Column from '../../../components/column';
import ColumnHeader from '../../../components/column_header';
@connect()
export default class HashtagTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
hashtag: PropTypes.string.isRequired,
};
handleHeaderClick = () => {
this.column.scrollTop();
}
setRef = c => {
this.column = c;
}
componentDidMount () {
const { dispatch, hashtag } = this.props;
dispatch(refreshHashtagTimeline(hashtag));
this.polling = setInterval(() => {
dispatch(refreshHashtagTimeline(hashtag));
}, 10000);
}
componentWillUnmount () {
if (typeof this.polling !== 'undefined') {
clearInterval(this.polling);
this.polling = null;
}
}
handleLoadMore = () => {
this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
}
render () {
const { hashtag } = this.props;
return (
<Column ref={this.setRef}>
<ColumnHeader
icon='hashtag'
title={hashtag}
onClick={this.handleHeaderClick}
/>
<StatusListContainer
trackScroll={false}
scrollKey='standalone_hashtag_timeline'
timelineId={`hashtag:${hashtag}`}
loadMore={this.handleLoadMore}
/>
</Column>
);
}
}

@ -30,6 +30,10 @@ export default class Card extends React.PureComponent {
maxDescription: 50, maxDescription: 50,
}; };
state = {
width: 0,
};
renderLink () { renderLink () {
const { card, maxDescription } = this.props; const { card, maxDescription } = this.props;
@ -75,14 +79,25 @@ export default class Card extends React.PureComponent {
); );
} }
setRef = c => {
if (c) {
this.setState({ width: c.offsetWidth });
}
}
renderVideo () { renderVideo () {
const { card } = this.props; const { card } = this.props;
const content = { __html: card.get('html') }; const content = { __html: card.get('html') };
const { width } = this.state;
const ratio = card.get('width') / card.get('height');
const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
return ( return (
<div <div
ref={this.setRef}
className='status-card-video' className='status-card-video'
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
style={{ height }}
/> />
); );
} }

@ -7,7 +7,7 @@ import StatusContent from '../../../../glitch/components/status/content';
import StatusGallery from '../../../../glitch/components/status/gallery'; import StatusGallery from '../../../../glitch/components/status/gallery';
import StatusPlayer from '../../../../glitch/components/status/player'; import StatusPlayer from '../../../../glitch/components/status/player';
import AttachmentList from '../../../components/attachment_list'; import AttachmentList from '../../../components/attachment_list';
import Link from 'react-router-dom/Link'; import { Link } from 'react-router-dom';
import { FormattedDate, FormattedNumber } from 'react-intl'; import { FormattedDate, FormattedNumber } from 'react-intl';
import CardContainer from '../containers/card_container'; import CardContainer from '../containers/card_container';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';

@ -28,6 +28,7 @@ import StatusContainer from '../../../glitch/components/status/container';
import { openModal } from '../../actions/modal'; import { openModal } from '../../actions/modal';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
const messages = defineMessages({ const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -153,8 +154,100 @@ export default class Status extends ImmutablePureComponent {
this.props.dispatch(openModal('EMBED', { url: status.get('url') })); this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
} }
handleHotkeyMoveUp = () => {
this.handleMoveUp(this.props.status.get('id'));
}
handleHotkeyMoveDown = () => {
this.handleMoveDown(this.props.status.get('id'));
}
handleHotkeyReply = e => {
e.preventDefault();
this.handleReplyClick(this.props.status);
}
handleHotkeyFavourite = () => {
this.handleFavouriteClick(this.props.status);
}
handleHotkeyBoost = () => {
this.handleReblogClick(this.props.status);
}
handleHotkeyMention = e => {
e.preventDefault();
this.handleMentionClick(this.props.status);
}
handleHotkeyOpenProfile = () => {
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
handleMoveUp = id => {
const { status, ancestorsIds, descendantsIds } = this.props;
if (id === status.get('id')) {
this._selectChild(ancestorsIds.size - 1);
} else {
let index = ancestorsIds.indexOf(id);
if (index === -1) {
index = descendantsIds.indexOf(id);
this._selectChild(ancestorsIds.size + index);
} else {
this._selectChild(index - 1);
}
}
}
handleMoveDown = id => {
const { status, ancestorsIds, descendantsIds } = this.props;
if (id === status.get('id')) {
this._selectChild(ancestorsIds.size + 1);
} else {
let index = ancestorsIds.indexOf(id);
if (index === -1) {
index = descendantsIds.indexOf(id);
this._selectChild(ancestorsIds.size + index + 2);
} else {
this._selectChild(index + 1);
}
}
}
_selectChild (index) {
const element = this.node.querySelectorAll('.focusable')[index];
if (element) {
element.focus();
}
}
renderChildren (list) { renderChildren (list) {
return list.map(id => <StatusContainer key={id} id={id} />); return list.map(id => (
<StatusContainer
key={id}
id={id}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
));
}
setRef = c => {
this.node = c;
}
componentDidUpdate () {
const { status, ancestorsIds } = this.props;
if (status && ancestorsIds && ancestorsIds.size > 0) {
const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size];
element.scrollIntoView();
}
} }
render () { render () {
@ -178,35 +271,49 @@ export default class Status extends ImmutablePureComponent {
descendants = <div>{this.renderChildren(descendantsIds)}</div>; descendants = <div>{this.renderChildren(descendantsIds)}</div>;
} }
const handlers = {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
openProfile: this.handleHotkeyOpenProfile,
};
return ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton />
<ScrollContainer scrollKey='thread'> <ScrollContainer scrollKey='thread'>
<div className='scrollable detailed-status__wrapper'> <div className='scrollable detailed-status__wrapper' ref={this.setRef}>
{ancestors} {ancestors}
<DetailedStatus <HotKeys handlers={handlers}>
status={status} <div className='focusable' tabIndex='0'>
settings={settings} <DetailedStatus
autoPlayGif={autoPlayGif} status={status}
me={me} settings={settings}
onOpenVideo={this.handleOpenVideo} autoPlayGif={autoPlayGif}
onOpenMedia={this.handleOpenMedia} me={me}
/> onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
<ActionBar />
status={status}
me={me} <ActionBar
onReply={this.handleReplyClick} status={status}
onFavourite={this.handleFavouriteClick} me={me}
onReblog={this.handleReblogClick} onReply={this.handleReplyClick}
onDelete={this.handleDeleteClick} onFavourite={this.handleFavouriteClick}
onMention={this.handleMentionClick} onReblog={this.handleReblogClick}
onReport={this.handleReport} onDelete={this.handleDeleteClick}
onPin={this.handlePin} onMention={this.handleMentionClick}
onEmbed={this.handleEmbed} onReport={this.handleReport}
/> onPin={this.handlePin}
onEmbed={this.handleEmbed}
/>
</div>
</HotKeys>
{descendants} {descendants}
</div> </div>

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Link from 'react-router-dom/Link'; import { Link } from 'react-router-dom';
const ColumnLink = ({ icon, text, to, onClick, href, method }) => { const ColumnLink = ({ icon, text, to, onClick, href, method }) => {
if (href) { if (href) {

@ -29,7 +29,7 @@ export default class MediaModal extends ImmutablePureComponent {
}; };
handleSwipe = (index) => { handleSwipe = (index) => {
this.setState({ index: (index) % this.props.media.size }); this.setState({ index: index % this.props.media.size });
} }
handleNextClick = () => { handleNextClick = () => {
@ -40,6 +40,11 @@ export default class MediaModal extends ImmutablePureComponent {
this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size }); this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
} }
handleChangeIndex = (e) => {
const index = Number(e.currentTarget.getAttribute('data-index'));
this.setState({ index: index % this.props.media.size });
}
handleKeyUp = (e) => { handleKeyUp = (e) => {
switch(e.key) { switch(e.key) {
case 'ArrowLeft': case 'ArrowLeft':
@ -67,33 +72,51 @@ export default class MediaModal extends ImmutablePureComponent {
const { media, intl, onClose } = this.props; const { media, intl, onClose } = this.props;
const index = this.getIndex(); const index = this.getIndex();
let pagination = [];
const leftNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>; const leftNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>;
const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>; const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>;
if (media.size > 1) {
pagination = media.map((item, i) => {
const classes = ['media-modal__button'];
if (i === index) {
classes.push('media-modal__button--active');
}
return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
});
}
const content = media.map((image) => { const content = media.map((image) => {
const width = image.getIn(['meta', 'original', 'width']) || null; const width = image.getIn(['meta', 'original', 'width']) || null;
const height = image.getIn(['meta', 'original', 'height']) || null; const height = image.getIn(['meta', 'original', 'height']) || null;
if (image.get('type') === 'image') { if (image.get('type') === 'image') {
return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />; return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />;
} else if (image.get('type') === 'gifv') { } else if (image.get('type') === 'gifv') {
return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} />; return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />;
} }
return null; return null;
}).toArray(); }).toArray();
const containerStyle = {
alignItems: 'center', // center vertically
};
return ( return (
<div className='modal-root__modal media-modal'> <div className='modal-root__modal media-modal'>
{leftNav} {leftNav}
<div className='media-modal__content'> <div className='media-modal__content'>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
<ReactSwipeableViews onChangeIndex={this.handleSwipe} index={index} animateHeight> <ReactSwipeableViews containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}>
{content} {content}
</ReactSwipeableViews> </ReactSwipeableViews>
</div> </div>
<ul className='media-modal__pagination'>
{pagination}
</ul>
{rightNav} {rightNav}
</div> </div>

@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
import BundleModalError from './bundle_modal_error'; import BundleModalError from './bundle_modal_error';
import ModalLoading from './modal_loading'; import ModalLoading from './modal_loading';
@ -39,6 +37,10 @@ export default class ModalRoot extends React.PureComponent {
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
}; };
state = {
revealed: false,
};
handleKeyUp = (e) => { handleKeyUp = (e) => {
if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
&& !!this.props.type) { && !!this.props.type) {
@ -55,6 +57,8 @@ export default class ModalRoot extends React.PureComponent {
this.activeElement = document.activeElement; this.activeElement = document.activeElement;
this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
} else if (!nextProps.type) {
this.setState({ revealed: false });
} }
} }
@ -64,6 +68,11 @@ export default class ModalRoot extends React.PureComponent {
this.activeElement.focus(); this.activeElement.focus();
this.activeElement = null; this.activeElement = null;
} }
if (this.props.type) {
requestAnimationFrame(() => {
this.setState({ revealed: true });
});
}
} }
componentWillUnmount () { componentWillUnmount () {
@ -78,14 +87,6 @@ export default class ModalRoot extends React.PureComponent {
this.node = ref; this.node = ref;
} }
willEnter () {
return { opacity: 0, scale: 0.98 };
}
willLeave () {
return { opacity: spring(0), scale: spring(0.98) };
}
renderLoading = modalId => () => { renderLoading = modalId => () => {
return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
} }
@ -98,38 +99,30 @@ export default class ModalRoot extends React.PureComponent {
render () { render () {
const { type, props, onClose } = this.props; const { type, props, onClose } = this.props;
const { revealed } = this.state;
const visible = !!type; const visible = !!type;
const items = [];
if (visible) { if (!visible) {
items.push({ return (
key: type, <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
data: { type, props }, );
style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) },
});
} }
return ( return (
<TransitionMotion <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
styles={items} <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
willEnter={this.willEnter} <div role='presentation' className='modal-root__overlay' onClick={onClose} />
willLeave={this.willLeave} <div role='dialog' className='modal-root__container'>
> {
{interpolatedStyles => visible ?
<div className='modal-root' ref={this.setRef}> (<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
{interpolatedStyles.map(({ key, data: { type, props }, style }) => ( {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
<div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}> </BundleContainer>) :
<div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} /> null
<div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> }
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
{(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
</BundleContainer>
</div>
</div>
))}
</div> </div>
} </div>
</TransitionMotion> </div>
); );
} }

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import NavLink from 'react-router-dom/NavLink'; import { NavLink } from 'react-router-dom';
import { FormattedMessage, injectIntl } from 'react-intl'; import { FormattedMessage, injectIntl } from 'react-intl';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { isUserTouching } from '../../../is_mobile'; import { isUserTouching } from '../../../is_mobile';

@ -23,6 +23,7 @@ export default class VideoModal extends ImmutablePureComponent {
src={media.get('url')} src={media.get('url')}
startTime={time} startTime={time}
onCloseVideo={onClose} onCloseVideo={onClose}
description={media.get('description')}
/> />
</div> </div>
</div> </div>

@ -8,7 +8,7 @@ import { connect } from 'react-redux';
import { Redirect, withRouter } from 'react-router-dom'; import { Redirect, withRouter } from 'react-router-dom';
import { isMobile } from '../../is_mobile'; import { isMobile } from '../../is_mobile';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { uploadCompose } from '../../actions/compose'; import { uploadCompose, resetCompose } from '../../actions/compose';
import { refreshHomeTimeline } from '../../actions/timelines'; import { refreshHomeTimeline } from '../../actions/timelines';
import { refreshNotifications } from '../../actions/notifications'; import { refreshNotifications } from '../../actions/notifications';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
@ -38,6 +38,7 @@ import {
Mutes, Mutes,
PinnedStatuses, PinnedStatuses,
} from './util/async-components'; } from './util/async-components';
import { HotKeys } from 'react-hotkeys';
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles. // Without this it ends up in ~8 very commonly used bundles.
@ -48,12 +49,39 @@ const mapStateToProps = state => ({
layout: state.getIn(['local_settings', 'layout']), layout: state.getIn(['local_settings', 'layout']),
isWide: state.getIn(['local_settings', 'stretch']), isWide: state.getIn(['local_settings', 'stretch']),
navbarUnder: state.getIn(['local_settings', 'navbar_under']), navbarUnder: state.getIn(['local_settings', 'navbar_under']),
me: state.getIn(['meta', 'me']),
isComposing: state.getIn(['compose', 'is_composing']), isComposing: state.getIn(['compose', 'is_composing']),
}); });
const keyMap = {
new: 'n',
search: 's',
forceNew: 'option+n',
focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
reply: 'r',
favourite: 'f',
boost: 'b',
mention: 'm',
open: ['enter', 'o'],
openProfile: 'p',
moveDown: ['down', 'j'],
moveUp: ['up', 'k'],
back: 'backspace',
goToHome: 'g h',
goToNotifications: 'g n',
goToLocal: 'g l',
goToFederated: 'g t',
goToStart: 'g s',
goToFavourites: 'g f',
goToPinned: 'g p',
goToProfile: 'g u',
goToBlocked: 'g b',
goToMuted: 'g m',
};
@connect(mapStateToProps) @connect(mapStateToProps)
@withRouter @withRouter
export default class UI extends React.PureComponent { export default class UI extends React.Component {
static contextTypes = { static contextTypes = {
router: PropTypes.object.isRequired, router: PropTypes.object.isRequired,
@ -67,6 +95,7 @@ export default class UI extends React.PureComponent {
systemFontUi: PropTypes.bool, systemFontUi: PropTypes.bool,
navbarUnder: PropTypes.bool, navbarUnder: PropTypes.bool,
isComposing: PropTypes.bool, isComposing: PropTypes.bool,
me: PropTypes.string,
location: PropTypes.object, location: PropTypes.object,
}; };
@ -164,6 +193,12 @@ export default class UI extends React.PureComponent {
this.props.dispatch(refreshNotifications()); this.props.dispatch(refreshNotifications());
} }
componentDidMount () {
this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
};
}
shouldComponentUpdate (nextProps) { shouldComponentUpdate (nextProps) {
if (nextProps.isComposing !== this.props.isComposing) { if (nextProps.isComposing !== this.props.isComposing) {
// Avoid expensive update just to toggle a class // Avoid expensive update just to toggle a class
@ -201,8 +236,94 @@ export default class UI extends React.PureComponent {
this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance(); this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
} }
setOverlayRef = c => { handleHotkeyNew = e => {
this.overlay = c; e.preventDefault();
const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
if (element) {
element.focus();
}
}
handleHotkeySearch = e => {
e.preventDefault();
const element = this.node.querySelector('.search__input');
if (element) {
element.focus();
}
}
handleHotkeyForceNew = e => {
this.handleHotkeyNew(e);
this.props.dispatch(resetCompose());
}
handleHotkeyFocusColumn = e => {
const index = (e.key * 1) + 1; // First child is drawer, skip that
const column = this.node.querySelector(`.column:nth-child(${index})`);
if (column) {
const status = column.querySelector('.focusable');
if (status) {
status.focus();
}
}
}
handleHotkeyBack = () => {
if (window.history && window.history.length === 1) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
}
setHotkeysRef = c => {
this.hotkeys = c;
}
handleHotkeyGoToHome = () => {
this.context.router.history.push('/timelines/home');
}
handleHotkeyGoToNotifications = () => {
this.context.router.history.push('/notifications');
}
handleHotkeyGoToLocal = () => {
this.context.router.history.push('/timelines/public/local');
}
handleHotkeyGoToFederated = () => {
this.context.router.history.push('/timelines/public');
}
handleHotkeyGoToStart = () => {
this.context.router.history.push('/getting-started');
}
handleHotkeyGoToFavourites = () => {
this.context.router.history.push('/favourites');
}
handleHotkeyGoToPinned = () => {
this.context.router.history.push('/pinned');
}
handleHotkeyGoToProfile = () => {
this.context.router.history.push(`/accounts/${this.props.me}`);
}
handleHotkeyGoToBlocked = () => {
this.context.router.history.push('/blocks');
}
handleHotkeyGoToMuted = () => {
this.context.router.history.push('/mutes');
} }
render () { render () {
@ -226,45 +347,67 @@ export default class UI extends React.PureComponent {
'navbar-under': navbarUnder, 'navbar-under': navbarUnder,
}); });
const handlers = {
new: this.handleHotkeyNew,
search: this.handleHotkeySearch,
forceNew: this.handleHotkeyForceNew,
focusColumn: this.handleHotkeyFocusColumn,
back: this.handleHotkeyBack,
goToHome: this.handleHotkeyGoToHome,
goToNotifications: this.handleHotkeyGoToNotifications,
goToLocal: this.handleHotkeyGoToLocal,
goToFederated: this.handleHotkeyGoToFederated,
goToStart: this.handleHotkeyGoToStart,
goToFavourites: this.handleHotkeyGoToFavourites,
goToPinned: this.handleHotkeyGoToPinned,
goToProfile: this.handleHotkeyGoToProfile,
goToBlocked: this.handleHotkeyGoToBlocked,
goToMuted: this.handleHotkeyGoToMuted,
};
return ( return (
<div className={className} ref={this.setRef}> <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
{navbarUnder ? null : (<TabsBar />)} <div className={className} ref={this.setRef}>
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}> {navbarUnder ? null : (<TabsBar />)}
<WrappedSwitch>
<Redirect from='/' to='/getting-started' exact /> <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> <WrappedSwitch>
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> <Redirect from='/' to='/getting-started' exact />
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} /> <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
<WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} /> <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> <WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/statuses/new' component={Compose} content={children} /> <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> <WrappedRoute path='/statuses/new' component={Compose} content={children} />
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} /> <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
<WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} /> <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
<WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} /> <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
<WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} /> <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute component={GenericNotFound} content={children} /> <WrappedRoute path='/mutes' component={Mutes} content={children} />
</WrappedSwitch>
</ColumnsAreaContainer> <WrappedRoute component={GenericNotFound} content={children} />
<NotificationsContainer /> </WrappedSwitch>
{navbarUnder ? (<TabsBar />) : null} </ColumnsAreaContainer>
<LoadingBarContainer className='loading-bar' />
<ModalContainer /> <NotificationsContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} /> {navbarUnder ? (<TabsBar />) : null}
</div> <LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
</div>
</HotKeys>
); );
} }

@ -1,3 +1,7 @@
export function EmojiPicker () {
return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker');
}
export function Compose () { export function Compose () {
return import(/* webpackChunkName: "features/compose" */'../../compose'); return import(/* webpackChunkName: "features/compose" */'../../compose');
} }
@ -101,10 +105,6 @@ export function MediaGallery () {
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
} }
export function VideoPlayer () {
return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
}
export function Video () { export function Video () {
return import(/* webpackChunkName: "features/video" */'../../video'); return import(/* webpackChunkName: "features/video" */'../../video');
} }

@ -0,0 +1,46 @@
// APIs for normalizing fullscreen operations. Note that Edge uses
// the WebKit-prefixed APIs currently (as of Edge 16).
export const isFullscreen = () => document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement;
export const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
}
};
export const requestFullscreen = el => {
if (el.requestFullscreen) {
el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
el.webkitRequestFullscreen();
} else if (el.mozRequestFullScreen) {
el.mozRequestFullScreen();
}
};
export const attachFullscreenListener = (listener) => {
if ('onfullscreenchange' in document) {
document.addEventListener('fullscreenchange', listener);
} else if ('onwebkitfullscreenchange' in document) {
document.addEventListener('webkitfullscreenchange', listener);
} else if ('onmozfullscreenchange' in document) {
document.addEventListener('mozfullscreenchange', listener);
}
};
export const detachFullscreenListener = (listener) => {
if ('onfullscreenchange' in document) {
document.removeEventListener('fullscreenchange', listener);
} else if ('onwebkitfullscreenchange' in document) {
document.removeEventListener('webkitfullscreenchange', listener);
} else if ('onmozfullscreenchange' in document) {
document.removeEventListener('mozfullscreenchange', listener);
}
};

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Switch from 'react-router-dom/Switch'; import { Switch, Route } from 'react-router-dom';
import Route from 'react-router-dom/Route';
import ColumnLoading from '../components/column_loading'; import ColumnLoading from '../components/column_loading';
import BundleColumnError from '../components/bundle_column_error'; import BundleColumnError from '../components/bundle_column_error';

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
const messages = defineMessages({ const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' }, play: { id: 'video.play', defaultMessage: 'Play' },
@ -69,41 +70,13 @@ const getPointerPosition = (el, event) => {
return position; return position;
}; };
const isFullscreen = () => document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement;
const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
};
const requestFullscreen = el => {
if (el.requestFullscreen) {
el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
el.webkitRequestFullscreen();
} else if (el.mozRequestFullScreen) {
el.mozRequestFullScreen();
} else if (el.msRequestFullscreen) {
el.msRequestFullscreen();
}
};
@injectIntl @injectIntl
export default class Video extends React.PureComponent { export default class Video extends React.PureComponent {
static propTypes = { static propTypes = {
preview: PropTypes.string, preview: PropTypes.string,
src: PropTypes.string.isRequired, src: PropTypes.string.isRequired,
alt: PropTypes.string,
width: PropTypes.number, width: PropTypes.number,
height: PropTypes.number, height: PropTypes.number,
sensitive: PropTypes.bool, sensitive: PropTypes.bool,
@ -236,6 +209,12 @@ export default class Video extends React.PureComponent {
} }
} }
handleProgress = () => {
if (this.video.buffered.length > 0) {
this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
}
}
handleOpenVideo = () => { handleOpenVideo = () => {
this.video.pause(); this.video.pause();
this.props.onOpenVideo(this.video.currentTime); this.props.onOpenVideo(this.video.currentTime);
@ -247,8 +226,8 @@ export default class Video extends React.PureComponent {
} }
render () { render () {
const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props; const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt } = this.props;
const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const { progress, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
return ( return (
<div className={classNames('video-player', { inactive: !revealed, inline: width && height && !fullscreen, fullscreen })} style={{ width, height }} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames('video-player', { inactive: !revealed, inline: width && height && !fullscreen, fullscreen })} style={{ width, height }} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
@ -256,10 +235,11 @@ export default class Video extends React.PureComponent {
ref={this.setVideoRef} ref={this.setVideoRef}
src={src} src={src}
poster={preview} poster={preview}
preload={!!startTime} preload={startTime ? 'auto' : 'none'}
loop loop
role='button' role='button'
tabIndex='0' tabIndex='0'
aria-label={alt}
width={width} width={width}
height={height} height={height}
onClick={this.togglePlay} onClick={this.togglePlay}
@ -267,6 +247,7 @@ export default class Video extends React.PureComponent {
onPause={this.handlePause} onPause={this.handlePause}
onTimeUpdate={this.handleTimeUpdate} onTimeUpdate={this.handleTimeUpdate}
onLoadedData={this.handleLoadedData} onLoadedData={this.handleLoadedData}
onProgress={this.handleProgress}
/> />
<button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}> <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
@ -276,6 +257,7 @@ export default class Video extends React.PureComponent {
<div className={classNames('video-player__controls', { active: paused || hovered })}> <div className={classNames('video-player__controls', { active: paused || hovered })}>
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}> <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}%` }} /> <div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
<span <span

@ -1,31 +1,31 @@
{ {
"account.block": "حظر @{name}", "account.block": "حظر @{name}",
"account.block_domain": "Hide everything from {domain}", "account.block_domain": "إخفاء كل شيئ قادم من إسم النطاق {domain}",
"account.disclaimer_full": "Information below may reflect the user's profile incompletely.", "account.disclaimer_full": "قد لا تعكس المعلومات أدناه الملف الشخصي الكامل للمستخدم.",
"account.edit_profile": "تعديل الملف الشخصي", "account.edit_profile": "تعديل الملف الشخصي",
"account.follow": "تابِع", "account.follow": "تابِع",
"account.followers": "المتابعون", "account.followers": "المتابعون",
"account.follows": "يتبع", "account.follows": "يتبع",
"account.follows_you": "يتابعك", "account.follows_you": "يتابعك",
"account.media": "Media", "account.media": "وسائط",
"account.mention": "أُذكُر @{name}", "account.mention": "أُذكُر @{name}",
"account.mute": "أكتم @{name}", "account.mute": "أكتم @{name}",
"account.posts": "المشاركات", "account.posts": "المشاركات",
"account.report": "أبلغ عن @{name}", "account.report": "أبلغ عن @{name}",
"account.requested": "في انتظار الموافقة", "account.requested": "في انتظار الموافقة",
"account.share": "Share @{name}'s profile", "account.share": "مشاركة @{name}'s profile",
"account.unblock": "إلغاء الحظر عن @{name}", "account.unblock": "إلغاء الحظر عن @{name}",
"account.unblock_domain": "Unhide {domain}", "account.unblock_domain": "فك حظر {domain}",
"account.unfollow": "إلغاء المتابعة", "account.unfollow": "إلغاء المتابعة",
"account.unmute": "إلغاء الكتم عن @{name}", "account.unmute": "إلغاء الكتم عن @{name}",
"account.view_full_profile": "View full profile", "account.view_full_profile": "عرض الملف الشخصي كاملا",
"boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة", "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة",
"bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
"bundle_column_error.retry": "Try again", "bundle_column_error.retry": "إعادة المحاولة",
"bundle_column_error.title": "Network error", "bundle_column_error.title": "خطأ في الشبكة",
"bundle_modal_error.close": "Close", "bundle_modal_error.close": "أغلق",
"bundle_modal_error.message": "Something went wrong while loading this component.", "bundle_modal_error.message": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
"bundle_modal_error.retry": "Try again", "bundle_modal_error.retry": "إعادة المحاولة",
"column.blocks": "الحسابات المحجوبة", "column.blocks": "الحسابات المحجوبة",
"column.community": "الخيط العام المحلي", "column.community": "الخيط العام المحلي",
"column.favourites": "المفضلة", "column.favourites": "المفضلة",
@ -33,15 +33,15 @@
"column.home": "الرئيسية", "column.home": "الرئيسية",
"column.mutes": "الحسابات المكتومة", "column.mutes": "الحسابات المكتومة",
"column.notifications": "الإشعارات", "column.notifications": "الإشعارات",
"column.pins": "Pinned toot", "column.pins": "التبويقات المثبتة",
"column.public": "الخيط العام الموحد", "column.public": "الخيط العام الموحد",
"column_back_button.label": "العودة", "column_back_button.label": "العودة",
"column_header.hide_settings": "Hide settings", "column_header.hide_settings": "إخفاء الإعدادات",
"column_header.moveLeft_settings": "Move column to the left", "column_header.moveLeft_settings": "نقل القائمة إلى اليسار",
"column_header.moveRight_settings": "Move column to the right", "column_header.moveRight_settings": "نقل القائمة إلى اليمين",
"column_header.pin": "Pin", "column_header.pin": "تدبيس",
"column_header.show_settings": "Show settings", "column_header.show_settings": "عرض الإعدادات",
"column_header.unpin": "Unpin", "column_header.unpin": "فك التدبيس",
"column_subheading.navigation": "التصفح", "column_subheading.navigation": "التصفح",
"column_subheading.settings": "الإعدادات", "column_subheading.settings": "الإعدادات",
"compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.", "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
@ -57,16 +57,16 @@
"confirmations.block.message": "هل أنت متأكد أنك تريد حجب {name} ؟", "confirmations.block.message": "هل أنت متأكد أنك تريد حجب {name} ؟",
"confirmations.delete.confirm": "حذف", "confirmations.delete.confirm": "حذف",
"confirmations.delete.message": "هل أنت متأكد أنك تريد حذف هذا المنشور ؟", "confirmations.delete.message": "هل أنت متأكد أنك تريد حذف هذا المنشور ؟",
"confirmations.domain_block.confirm": "Hide entire domain", "confirmations.domain_block.confirm": "إخفاء إسم النطاق كاملا",
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.", "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
"confirmations.mute.confirm": "أكتم", "confirmations.mute.confirm": "أكتم",
"confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟", "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
"confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.confirm": "إلغاء المتابعة",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", "confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
"embed.instructions": "Embed this status on your website by copying the code below.", "embed.instructions": "يمكنكم إدماج هذه الحالة على موقعكم الإلكتروني عن طريق نسخ الشفرة أدناه.",
"embed.preview": "Here is what it will look like:", "embed.preview": "هكذا ما سوف يبدو عليه :",
"emoji_button.activity": "الأنشطة", "emoji_button.activity": "الأنشطة",
"emoji_button.custom": "Custom", "emoji_button.custom": "مخصص",
"emoji_button.flags": "الأعلام", "emoji_button.flags": "الأعلام",
"emoji_button.food": "الطعام والشراب", "emoji_button.food": "الطعام والشراب",
"emoji_button.label": "أدرج إيموجي", "emoji_button.label": "أدرج إيموجي",
@ -74,9 +74,9 @@
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
"emoji_button.objects": "أشياء", "emoji_button.objects": "أشياء",
"emoji_button.people": "الناس", "emoji_button.people": "الناس",
"emoji_button.recent": "Frequently used", "emoji_button.recent": "الشائعة الإستخدام",
"emoji_button.search": "ابحث...", "emoji_button.search": "ابحث...",
"emoji_button.search_results": "Search results", "emoji_button.search_results": "نتائج البحث",
"emoji_button.symbols": "رموز", "emoji_button.symbols": "رموز",
"emoji_button.travel": "أماكن و أسفار", "emoji_button.travel": "أماكن و أسفار",
"empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.", "empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.",
@ -100,8 +100,8 @@
"home.column_settings.show_replies": "عرض الردود", "home.column_settings.show_replies": "عرض الردود",
"home.settings": "إعدادات العمود", "home.settings": "إعدادات العمود",
"lightbox.close": "إغلاق", "lightbox.close": "إغلاق",
"lightbox.next": "Next", "lightbox.next": "التالي",
"lightbox.previous": "Previous", "lightbox.previous": "العودة",
"loading_indicator.label": "تحميل ...", "loading_indicator.label": "تحميل ...",
"media_gallery.toggle_visible": "عرض / إخفاء", "media_gallery.toggle_visible": "عرض / إخفاء",
"missing_indicator.label": "تعذر العثور عليه", "missing_indicator.label": "تعذر العثور عليه",
@ -113,7 +113,7 @@
"navigation_bar.info": "معلومات إضافية", "navigation_bar.info": "معلومات إضافية",
"navigation_bar.logout": "خروج", "navigation_bar.logout": "خروج",
"navigation_bar.mutes": "الحسابات المكتومة", "navigation_bar.mutes": "الحسابات المكتومة",
"navigation_bar.pins": "Pinned toots", "navigation_bar.pins": "التبويقات المثبتة",
"navigation_bar.preferences": "التفضيلات", "navigation_bar.preferences": "التفضيلات",
"navigation_bar.public_timeline": "الخيط العام الموحد", "navigation_bar.public_timeline": "الخيط العام الموحد",
"notification.favourite": "{name} أعجب بمنشورك", "notification.favourite": "{name} أعجب بمنشورك",
@ -126,8 +126,8 @@
"notifications.column_settings.favourite": "المُفَضَّلة :", "notifications.column_settings.favourite": "المُفَضَّلة :",
"notifications.column_settings.follow": "متابعُون جُدُد :", "notifications.column_settings.follow": "متابعُون جُدُد :",
"notifications.column_settings.mention": "الإشارات :", "notifications.column_settings.mention": "الإشارات :",
"notifications.column_settings.push": "Push notifications", "notifications.column_settings.push": "الإخطارات المدفوعة",
"notifications.column_settings.push_meta": "This device", "notifications.column_settings.push_meta": "هذا الجهاز",
"notifications.column_settings.reblog": "الترقيّات:", "notifications.column_settings.reblog": "الترقيّات:",
"notifications.column_settings.show": "إعرِضها في عمود", "notifications.column_settings.show": "إعرِضها في عمود",
"notifications.column_settings.sound": "أصدر صوتا", "notifications.column_settings.sound": "أصدر صوتا",
@ -165,18 +165,23 @@
"report.submit": "إرسال", "report.submit": "إرسال",
"report.target": "إبلاغ", "report.target": "إبلاغ",
"search.placeholder": "ابحث", "search.placeholder": "ابحث",
"search_popout.search_format": "نمط البحث المتقدم",
"search_popout.tips.hashtag": "وسم",
"search_popout.tips.status": "حالة",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "مستخدِم",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...", "standalone.public_title": "نظرة على ...",
"status.cannot_reblog": "تعذرت ترقية هذا المنشور", "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
"status.delete": "إحذف", "status.delete": "إحذف",
"status.embed": "Embed", "status.embed": "إدماج",
"status.favourite": "أضف إلى المفضلة", "status.favourite": "أضف إلى المفضلة",
"status.load_more": "حمّل المزيد", "status.load_more": "حمّل المزيد",
"status.media_hidden": "الصورة مستترة", "status.media_hidden": "الصورة مستترة",
"status.mention": "أذكُر @{name}", "status.mention": "أذكُر @{name}",
"status.mute_conversation": "Mute conversation", "status.mute_conversation": "كتم المحادثة",
"status.open": "وسع هذه المشاركة", "status.open": "وسع هذه المشاركة",
"status.pin": "Pin on profile", "status.pin": "تدبيس على الملف الشخصي",
"status.reblog": "رَقِّي", "status.reblog": "رَقِّي",
"status.reblogged_by": "{name} رقى", "status.reblogged_by": "{name} رقى",
"status.reply": "ردّ", "status.reply": "ردّ",
@ -184,11 +189,11 @@
"status.report": "إبلِغ عن @{name}", "status.report": "إبلِغ عن @{name}",
"status.sensitive_toggle": "اضغط للعرض", "status.sensitive_toggle": "اضغط للعرض",
"status.sensitive_warning": "محتوى حساس", "status.sensitive_warning": "محتوى حساس",
"status.share": "Share", "status.share": "مشاركة",
"status.show_less": "إعرض أقلّ", "status.show_less": "إعرض أقلّ",
"status.show_more": "أظهر المزيد", "status.show_more": "أظهر المزيد",
"status.unmute_conversation": "Unmute conversation", "status.unmute_conversation": "فك الكتم عن المحادثة",
"status.unpin": "Unpin from profile", "status.unpin": "فك التدبيس من الملف الشخصي",
"tabs_bar.compose": "تحرير", "tabs_bar.compose": "تحرير",
"tabs_bar.federated_timeline": "الموحَّد", "tabs_bar.federated_timeline": "الموحَّد",
"tabs_bar.home": "الرئيسية", "tabs_bar.home": "الرئيسية",
@ -196,19 +201,16 @@
"tabs_bar.notifications": "الإخطارات", "tabs_bar.notifications": "الإخطارات",
"upload_area.title": "إسحب ثم أفلت للرفع", "upload_area.title": "إسحب ثم أفلت للرفع",
"upload_button.label": "إضافة وسائط", "upload_button.label": "إضافة وسائط",
"upload_form.description": "وصف للمعاقين بصريا",
"upload_form.undo": "إلغاء", "upload_form.undo": "إلغاء",
"upload_progress.label": "يرفع...", "upload_progress.label": "يرفع...",
"video.close": "Close video", "video.close": "إغلاق الفيديو",
"video.exit_fullscreen": "Exit full screen", "video.exit_fullscreen": "الخروج من وضع الشاشة المليئة",
"video.expand": "Expand video", "video.expand": "توسيع الفيديو",
"video.fullscreen": "Full screen", "video.fullscreen": "ملء الشاشة",
"video.hide": "Hide video", "video.hide": "إخفاء الفيديو",
"video.mute": "Mute sound", "video.mute": "كتم الصوت",
"video.pause": "Pause", "video.pause": "إيقاف مؤقت",
"video.play": "Play", "video.play": "تشغيل",
"video.unmute": "Unmute sound", "video.unmute": "تشغيل الصوت"
"video_player.expand": "وسّع الفيديو",
"video_player.toggle_sound": "تبديل الصوت",
"video_player.toggle_visible": "إظهار / إخفاء الفيديو",
"video_player.video_error": "تعذر تشغيل الفيديو"
} }

@ -165,6 +165,11 @@
"report.submit": "Submit", "report.submit": "Submit",
"report.target": "Reporting", "report.target": "Reporting",
"search.placeholder": "Търсене", "search.placeholder": "Търсене",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...", "standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
@ -196,6 +201,7 @@
"tabs_bar.notifications": "Известия", "tabs_bar.notifications": "Известия",
"upload_area.title": "Drag & drop to upload", "upload_area.title": "Drag & drop to upload",
"upload_button.label": "Добави медия", "upload_button.label": "Добави медия",
"upload_form.description": "Describe for the visually impaired",
"upload_form.undo": "Отмяна", "upload_form.undo": "Отмяна",
"upload_progress.label": "Uploading...", "upload_progress.label": "Uploading...",
"video.close": "Close video", "video.close": "Close video",
@ -206,9 +212,5 @@
"video.mute": "Mute sound", "video.mute": "Mute sound",
"video.pause": "Pause", "video.pause": "Pause",
"video.play": "Play", "video.play": "Play",
"video.unmute": "Unmute sound", "video.unmute": "Unmute sound"
"video_player.expand": "Expand video",
"video_player.toggle_sound": "Звук",
"video_player.toggle_visible": "Toggle visibility",
"video_player.video_error": "Video could not be played"
} }

@ -165,6 +165,11 @@
"report.submit": "Enviar", "report.submit": "Enviar",
"report.target": "Informes", "report.target": "Informes",
"search.placeholder": "Cercar", "search.placeholder": "Cercar",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...", "standalone.public_title": "A look inside...",
"status.cannot_reblog": "Aquesta publicació no pot ser retootejada", "status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
@ -196,6 +201,7 @@
"tabs_bar.notifications": "Notificacions", "tabs_bar.notifications": "Notificacions",
"upload_area.title": "Arrossega i deixa anar per carregar", "upload_area.title": "Arrossega i deixa anar per carregar",
"upload_button.label": "Afegir multimèdia", "upload_button.label": "Afegir multimèdia",
"upload_form.description": "Describe for the visually impaired",
"upload_form.undo": "Desfer", "upload_form.undo": "Desfer",
"upload_progress.label": "Pujant...", "upload_progress.label": "Pujant...",
"video.close": "Close video", "video.close": "Close video",
@ -206,9 +212,5 @@
"video.mute": "Mute sound", "video.mute": "Mute sound",
"video.pause": "Pause", "video.pause": "Pause",
"video.play": "Play", "video.play": "Play",
"video.unmute": "Unmute sound", "video.unmute": "Unmute sound"
"video_player.expand": "Ampliar el vídeo",
"video_player.toggle_sound": "Alternar so",
"video_player.toggle_visible": "Alternar visibilitat",
"video_player.video_error": "El vídeo no es pot reproduir"
} }

@ -1,7 +1,7 @@
{ {
"account.block": "@{name} blocken", "account.block": "@{name} blocken",
"account.block_domain": "Alles von {domain} verstecken", "account.block_domain": "Alles von {domain} verstecken",
"account.disclaimer_full": "Hier aufgeführten Informationen können unvollständig sein.", "account.disclaimer_full": "Das Profil wird möglicherweise unvollständig wiedergegeben.",
"account.edit_profile": "Profil bearbeiten", "account.edit_profile": "Profil bearbeiten",
"account.follow": "Folgen", "account.follow": "Folgen",
"account.followers": "Folgende", "account.followers": "Folgende",
@ -18,11 +18,11 @@
"account.unblock_domain": "{domain} wieder anzeigen", "account.unblock_domain": "{domain} wieder anzeigen",
"account.unfollow": "Entfolgen", "account.unfollow": "Entfolgen",
"account.unmute": "@{name} nicht mehr stummschalten", "account.unmute": "@{name} nicht mehr stummschalten",
"account.view_full_profile": "Komplettes Profil anzeigen", "account.view_full_profile": "Vollständiges Profil anzeigen",
"boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen", "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen",
"bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.", "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
"bundle_column_error.retry": "Erneut versuchen", "bundle_column_error.retry": "Erneut versuchen",
"bundle_column_error.title": "Netzwerkfehlher", "bundle_column_error.title": "Netzwerkfehler",
"bundle_modal_error.close": "Schließen", "bundle_modal_error.close": "Schließen",
"bundle_modal_error.message": "Etwas ist beim Laden schiefgelaufen.", "bundle_modal_error.message": "Etwas ist beim Laden schiefgelaufen.",
"bundle_modal_error.retry": "Erneut versuchen", "bundle_modal_error.retry": "Erneut versuchen",
@ -33,18 +33,18 @@
"column.home": "Startseite", "column.home": "Startseite",
"column.mutes": "Stummgeschaltete Profile", "column.mutes": "Stummgeschaltete Profile",
"column.notifications": "Mitteilungen", "column.notifications": "Mitteilungen",
"column.pins": "Pinned toot", "column.pins": "Angeheftete Beiträge",
"column.public": "Gesamtes bekanntes Netz", "column.public": "Gesamtes bekanntes Netz",
"column_back_button.label": "Zurück", "column_back_button.label": "Zurück",
"column_header.hide_settings": "Einstellungen verbergen", "column_header.hide_settings": "Einstellungen verbergen",
"column_header.moveLeft_settings": "Spalte links verschieben", "column_header.moveLeft_settings": "Spalte nach links verschieben",
"column_header.moveRight_settings": "Spalte rechts verschieben", "column_header.moveRight_settings": "Spalte nach rechts verschieben",
"column_header.pin": "Anheften", "column_header.pin": "Anheften",
"column_header.show_settings": "Einstellungen anzeigen", "column_header.show_settings": "Einstellungen anzeigen",
"column_header.unpin": "Lösen", "column_header.unpin": "Lösen",
"column_subheading.navigation": "Navigation", "column_subheading.navigation": "Navigation",
"column_subheading.settings": "Einstellungen", "column_subheading.settings": "Einstellungen",
"compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.", "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.",
"compose_form.lock_disclaimer.lock": "gesperrt", "compose_form.lock_disclaimer.lock": "gesperrt",
"compose_form.placeholder": "Worüber möchtest du schreiben?", "compose_form.placeholder": "Worüber möchtest du schreiben?",
"compose_form.publish": "Tröt", "compose_form.publish": "Tröt",
@ -56,106 +56,106 @@
"confirmations.block.confirm": "Blockieren", "confirmations.block.confirm": "Blockieren",
"confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?", "confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?",
"confirmations.delete.confirm": "Löschen", "confirmations.delete.confirm": "Löschen",
"confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchstest?", "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?",
"confirmations.domain_block.confirm": "Die ganze Domain verbergen", "confirmations.domain_block.confirm": "Die ganze Domain verbergen",
"confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen sind ein paar gezielte Blocks genug.", "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen reichen ein paar gezielte Blocks aus.",
"confirmations.mute.confirm": "Stummschalten", "confirmations.mute.confirm": "Stummschalten",
"confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchstest?", "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchtest?",
"confirmations.unfollow.confirm": "Entfolgen", "confirmations.unfollow.confirm": "Entfolgen",
"confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchstest?", "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchtest?",
"embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, in dem du den folgenden Code einfügst.", "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, indem du den folgenden Code einfügst.",
"embed.preview": "So wird es aussehen:", "embed.preview": "So wird es aussehen:",
"emoji_button.activity": "Aktivitäten", "emoji_button.activity": "Aktivitäten",
"emoji_button.custom": "Custom", "emoji_button.custom": "Eigene",
"emoji_button.flags": "Flaggen", "emoji_button.flags": "Flaggen",
"emoji_button.food": "Essen und Trinken", "emoji_button.food": "Essen und Trinken",
"emoji_button.label": "Emoji einfügen", "emoji_button.label": "Emoji einfügen",
"emoji_button.nature": "Natur", "emoji_button.nature": "Natur",
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", "emoji_button.not_found": "Keine Emojis!! (╯°□°)╯︵ ┻━┻",
"emoji_button.objects": "Dinge", "emoji_button.objects": "Gegenstände",
"emoji_button.people": "Leute", "emoji_button.people": "Personen",
"emoji_button.recent": "Frequently used", "emoji_button.recent": "Häufig benutzt",
"emoji_button.search": "Suche", "emoji_button.search": "Suchen",
"emoji_button.search_results": "Search results", "emoji_button.search_results": "Suchergebnisse",
"emoji_button.symbols": "Symbole", "emoji_button.symbols": "Symbole",
"emoji_button.travel": "Reise und Orte", "emoji_button.travel": "Reisen und Orte",
"empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!", "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe einen öffentlichen Beitrag, um den Ball ins Rollen zu bringen!",
"empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.", "empty_column.hashtag": "Unter diesem Hashtag gibt es noch nichts.",
"empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Profile zu finden.", "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder nutze die Suche, um loszulegen und andere Leute zu finden.",
"empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich wiedererstellt.", "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv warst, wird sie für dich so schnell wie möglich neu erstellt.",
"empty_column.home.public_timeline": "die öffentliche Zeitleiste", "empty_column.home.public_timeline": "die öffentliche Zeitleiste",
"empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.", "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um ins Gespräch zu kommen.",
"empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um es aufzufüllen.", "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um die Zeitleiste aufzufüllen",
"follow_request.authorize": "Erlauben", "follow_request.authorize": "Erlauben",
"follow_request.reject": "Ablehnen", "follow_request.reject": "Ablehnen",
"getting_started.appsshort": "Anwendungen", "getting_started.appsshort": "Apps",
"getting_started.faq": "Häufig gestellte Fragen", "getting_started.faq": "Häufig gestellte Fragen",
"getting_started.heading": "Erste Schritte", "getting_started.heading": "Erste Schritte",
"getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf GitHub unter {github} dazu beitragen oder Probleme melden.",
"getting_started.userguide": "Bedienungsanleitung", "getting_started.userguide": "Bedienungsanleitung",
"home.column_settings.advanced": "Fortgeschritten", "home.column_settings.advanced": "Erweitert",
"home.column_settings.basic": "Einfach", "home.column_settings.basic": "Einfach",
"home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke", "home.column_settings.filter_regex": "Mit regulären Ausdrücken filtern",
"home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen", "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
"home.column_settings.show_replies": "Antworten anzeigen", "home.column_settings.show_replies": "Antworten anzeigen",
"home.settings": "Spalteneinstellungen", "home.settings": "Spalteneinstellungen",
"lightbox.close": "Schließen", "lightbox.close": "Schließen",
"lightbox.next": "Weiter", "lightbox.next": "Weiter",
"lightbox.previous": "Zurück", "lightbox.previous": "Zurück",
"loading_indicator.label": "Lade…", "loading_indicator.label": "Wird geladen …",
"media_gallery.toggle_visible": "Sichtbarkeit einstellen", "media_gallery.toggle_visible": "Sichtbarkeit umschalten",
"missing_indicator.label": "Nicht gefunden", "missing_indicator.label": "Nicht gefunden",
"navigation_bar.blocks": "Blockierte Profile", "navigation_bar.blocks": "Blockierte Profile",
"navigation_bar.community_timeline": "Lokale Zeitleiste", "navigation_bar.community_timeline": "Lokale Zeitleiste",
"navigation_bar.edit_profile": "Profil bearbeiten", "navigation_bar.edit_profile": "Profil bearbeiten",
"navigation_bar.favourites": "Favoriten", "navigation_bar.favourites": "Favoriten",
"navigation_bar.follow_requests": "Folgeanfragen", "navigation_bar.follow_requests": "Folgeanfragen",
"navigation_bar.info": "Erweiterte Informationen", "navigation_bar.info": "Über diese Instanz",
"navigation_bar.logout": "Abmelden", "navigation_bar.logout": "Abmelden",
"navigation_bar.mutes": "Stummgeschaltete Profile", "navigation_bar.mutes": "Stummgeschaltete Profile",
"navigation_bar.pins": "Pinned toots", "navigation_bar.pins": "Angeheftete Beiträge",
"navigation_bar.preferences": "Einstellungen", "navigation_bar.preferences": "Einstellungen",
"navigation_bar.public_timeline": "Föderierte Zeitleiste", "navigation_bar.public_timeline": "Föderierte Zeitleiste",
"notification.favourite": "{name} favorisierte deinen Status", "notification.favourite": "{name} hat deinen Beitrag favorisiert",
"notification.follow": "{name} folgt dir", "notification.follow": "{name} folgt dir",
"notification.mention": "{name} erwähnte dich", "notification.mention": "{name} hat dich erwähnt",
"notification.reblog": "{name} teilte deinen Status", "notification.reblog": "{name} hat deinen Beitrag geteilt",
"notifications.clear": "Mitteilungen löschen", "notifications.clear": "Mitteilungen löschen",
"notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchstest?", "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchtest?",
"notifications.column_settings.alert": "Desktop-Benachrichtigungen", "notifications.column_settings.alert": "Desktop-Benachrichtigungen",
"notifications.column_settings.favourite": "Favorisierungen:", "notifications.column_settings.favourite": "Favorisierungen:",
"notifications.column_settings.follow": "Neue Folgende:", "notifications.column_settings.follow": "Neue Folgende:",
"notifications.column_settings.mention": "Erwähnungen:", "notifications.column_settings.mention": "Erwähnungen:",
"notifications.column_settings.push": "Push notifications", "notifications.column_settings.push": "Push-Benachrichtigungen",
"notifications.column_settings.push_meta": "This device", "notifications.column_settings.push_meta": "Auf diesem Gerät",
"notifications.column_settings.reblog": "Geteilte Beiträge:", "notifications.column_settings.reblog": "Geteilte Beiträge:",
"notifications.column_settings.show": "In der Spalte anzeigen", "notifications.column_settings.show": "In der Spalte anzeigen",
"notifications.column_settings.sound": "Ton abspielen", "notifications.column_settings.sound": "Ton abspielen",
"onboarding.done": "Fertig", "onboarding.done": "Fertig",
"onboarding.next": "Weiter", "onboarding.next": "Weiter",
"onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf deiner Instanz {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, die kollektiv aus deiner Instanz heraus gefolgt werden. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt, durch sie kannst du viel neues entdecken.", "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, denen von Leuten auf {domain} gefolgt wird. Zusammen sind sie die öffentlichen Zeitleisten. In ihnen kannst du viel Neues entdecken!",
"onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.", "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.",
"onboarding.page_four.notifications": "Wenn jemand mir dir interagiert, bekommst du eine Mitteilung.", "onboarding.page_four.notifications": "Wenn jemand mit dir interagiert, bekommst du eine Mitteilung.",
"onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.", "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.",
"onboarding.page_one.handle": "Du bist auf der Instanz {domain}, also ist dein vollständiger Profilname im Netzwerk {handle}", "onboarding.page_one.handle": "Du bist auf der Instanz {domain}, also ist dein vollständiger Profilname im Netzwerk {handle}",
"onboarding.page_one.welcome": "Willkommen bei Mastodon!", "onboarding.page_one.welcome": "Willkommen bei Mastodon!",
"onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.", "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.",
"onboarding.page_six.almost_done": "Fast fertig…", "onboarding.page_six.almost_done": "Fast fertig …",
"onboarding.page_six.appetoot": "Guten Appetröt!", "onboarding.page_six.appetoot": "Guten Appetröt!",
"onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und andere Plattformen.", "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und weitere Plattformen.",
"onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf GitHub unter {github} dazu beitragen, Probleme melden und Wünsche äußern.",
"onboarding.page_six.guidelines": "Richtlinien", "onboarding.page_six.guidelines": "Richtlinien",
"onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!", "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut.",
"onboarding.page_six.various_app": "mobile Anwendungen", "onboarding.page_six.various_app": "Apps",
"onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.", "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen und deine Beschreibung anzupassen. Dort findest du auch weitere Einstellungen.",
"onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel, die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.", "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute zu finden und mit Hashtags wie {illustration} oder {introductions} nach Beiträgen zu suchen. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.",
"onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeitseinstellungen ändern und Inhaltswarnungen hinzufügen.", "onboarding.page_two.compose": "Schreibe deine Beiträge in der Schreiben-Spalte. Mit den Symbolen unter dem Eingabefeld kannst du Bilder hochladen, Sichtbarkeits-Einstellungen ändern und Inhaltswarnungen hinzufügen.",
"onboarding.skip": "Überspringen", "onboarding.skip": "Überspringen",
"privacy.change": "Privatsphäre des Status anpassen", "privacy.change": "Sichtbarkeit des Beitrags anpassen",
"privacy.direct.long": "Beitrag nur an erwähnte Profile", "privacy.direct.long": "Beitrag nur an erwähnte Profile",
"privacy.direct.short": "Direkt", "privacy.direct.short": "Direkt",
"privacy.private.long": "Beitrag nur an Folgende", "privacy.private.long": "Beitrag nur an Folgende",
"privacy.private.short": "Privat", "privacy.private.short": "Nur Folgende",
"privacy.public.long": "Beitrag an öffentliche Zeitleisten", "privacy.public.long": "Beitrag an öffentliche Zeitleisten",
"privacy.public.short": "Öffentlich", "privacy.public.short": "Öffentlich",
"privacy.unlisted.long": "Nicht in öffentlichen Zeitleisten anzeigen", "privacy.unlisted.long": "Nicht in öffentlichen Zeitleisten anzeigen",
@ -163,26 +163,31 @@
"reply_indicator.cancel": "Abbrechen", "reply_indicator.cancel": "Abbrechen",
"report.placeholder": "Zusätzliche Kommentare", "report.placeholder": "Zusätzliche Kommentare",
"report.submit": "Absenden", "report.submit": "Absenden",
"report.target": "Melden", "report.target": "{target} melden",
"search.placeholder": "Suche", "search.placeholder": "Suche",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}", "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
"standalone.public_title": "Vorschau…", "standalone.public_title": "Ein kleiner Einblick …",
"status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden", "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
"status.delete": "Löschen", "status.delete": "Löschen",
"status.embed": "Einbetten", "status.embed": "Einbetten",
"status.favourite": "Favorisieren", "status.favourite": "Favorisieren",
"status.load_more": "Weitere laden", "status.load_more": "Weitere laden",
"status.media_hidden": "Medien versteckt", "status.media_hidden": "Medien versteckt",
"status.mention": "Erwähnen", "status.mention": "@{name} erwähnen",
"status.mute_conversation": "Thread stummschalten", "status.mute_conversation": "Thread stummschalten",
"status.open": "Öffnen", "status.open": "Diesen Beitrag öffnen",
"status.pin": "Auf dem Profil anheften", "status.pin": "Im Profil anheften",
"status.reblog": "Teilen", "status.reblog": "Teilen",
"status.reblogged_by": "{name} teilte", "status.reblogged_by": "{name} teilte",
"status.reply": "Antworten", "status.reply": "Antworten",
"status.replyAll": "Auf Thread antworten", "status.replyAll": "Auf Thread antworten",
"status.report": "@{name} melden", "status.report": "@{name} melden",
"status.sensitive_toggle": "Klicke, um sie zu sehen", "status.sensitive_toggle": "Zum Ansehen klicken",
"status.sensitive_warning": "Heikle Inhalte", "status.sensitive_warning": "Heikle Inhalte",
"status.share": "Teilen", "status.share": "Teilen",
"status.show_less": "Weniger anzeigen", "status.show_less": "Weniger anzeigen",
@ -194,21 +199,18 @@
"tabs_bar.home": "Startseite", "tabs_bar.home": "Startseite",
"tabs_bar.local_timeline": "Lokal", "tabs_bar.local_timeline": "Lokal",
"tabs_bar.notifications": "Mitteilungen", "tabs_bar.notifications": "Mitteilungen",
"upload_area.title": "Hereinziehen zum Hochladen", "upload_area.title": "Zum Hochladen hereinziehen",
"upload_button.label": "Mediendatei hinzufügen", "upload_button.label": "Mediendatei hinzufügen",
"upload_form.description": "Für Menschen mit Sehbehinderung beschreiben",
"upload_form.undo": "Entfernen", "upload_form.undo": "Entfernen",
"upload_progress.label": "Lade hoch…", "upload_progress.label": "Wird hochgeladen …",
"video.close": "Close video", "video.close": "Video schließen",
"video.exit_fullscreen": "Exit full screen", "video.exit_fullscreen": "Vollbild verlassen",
"video.expand": "Expand video", "video.expand": "Video vergrößern",
"video.fullscreen": "Full screen", "video.fullscreen": "Vollbild",
"video.hide": "Hide video", "video.hide": "Video verbergen",
"video.mute": "Mute sound", "video.mute": "Stummschalten",
"video.pause": "Pause", "video.pause": "Pause",
"video.play": "Play", "video.play": "Abspielen",
"video.unmute": "Unmute sound", "video.unmute": "Ton einschalten"
"video_player.expand": "Videoanzeige vergrößern",
"video_player.toggle_sound": "Ton umschalten",
"video_player.toggle_visible": "Sichtbarkeit umschalten",
"video_player.video_error": "Video konnte nicht abgespielt werden"
} }

@ -132,6 +132,31 @@
], ],
"path": "app/javascript/mastodon/components/missing_indicator.json" "path": "app/javascript/mastodon/components/missing_indicator.json"
}, },
{
"descriptors": [
{
"defaultMessage": "now",
"id": "relative_time.just_now"
},
{
"defaultMessage": "{number}s",
"id": "relative_time.seconds"
},
{
"defaultMessage": "{number}m",
"id": "relative_time.minutes"
},
{
"defaultMessage": "{number}h",
"id": "relative_time.hours"
},
{
"defaultMessage": "{number}d",
"id": "relative_time.days"
}
],
"path": "app/javascript/mastodon/components/relative_timestamp.json"
},
{ {
"descriptors": [ "descriptors": [
{ {
@ -227,39 +252,6 @@
], ],
"path": "app/javascript/mastodon/components/status.json" "path": "app/javascript/mastodon/components/status.json"
}, },
{
"descriptors": [
{
"defaultMessage": "Toggle sound",
"id": "video_player.toggle_sound"
},
{
"defaultMessage": "Toggle visibility",
"id": "video_player.toggle_visible"
},
{
"defaultMessage": "Expand video",
"id": "video_player.expand"
},
{
"defaultMessage": "Sensitive content",
"id": "status.sensitive_warning"
},
{
"defaultMessage": "Click to view",
"id": "status.sensitive_toggle"
},
{
"defaultMessage": "Media hidden",
"id": "status.media_hidden"
},
{
"defaultMessage": "Video could not be played",
"id": "video_player.video_error"
}
],
"path": "app/javascript/mastodon/components/video_player.json"
},
{ {
"descriptors": [ "descriptors": [
{ {
@ -640,6 +632,26 @@
{ {
"defaultMessage": "Search", "defaultMessage": "Search",
"id": "search.placeholder" "id": "search.placeholder"
},
{
"defaultMessage": "Advanced search format",
"id": "search_popout.search_format"
},
{
"defaultMessage": "hashtag",
"id": "search_popout.tips.hashtag"
},
{
"defaultMessage": "user",
"id": "search_popout.tips.user"
},
{
"defaultMessage": "status",
"id": "search_popout.tips.status"
},
{
"defaultMessage": "Simple text returns matching display names, usernames and hashtags",
"id": "search_popout.tips.text"
} }
], ],
"path": "app/javascript/mastodon/features/compose/components/search.json" "path": "app/javascript/mastodon/features/compose/components/search.json"
@ -656,20 +668,24 @@
{ {
"descriptors": [ "descriptors": [
{ {
"defaultMessage": "Undo", "defaultMessage": "Uploading...",
"id": "upload_form.undo" "id": "upload_progress.label"
} }
], ],
"path": "app/javascript/mastodon/features/compose/components/upload_form.json" "path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
}, },
{ {
"descriptors": [ "descriptors": [
{ {
"defaultMessage": "Uploading...", "defaultMessage": "Undo",
"id": "upload_progress.label" "id": "upload_form.undo"
},
{
"defaultMessage": "Describe for the visually impaired",
"id": "upload_form.description"
} }
], ],
"path": "app/javascript/mastodon/features/compose/components/upload_progress.json" "path": "app/javascript/mastodon/features/compose/components/upload.json"
}, },
{ {
"descriptors": [ "descriptors": [

@ -165,6 +165,11 @@
"report.submit": "Submit", "report.submit": "Submit",
"report.target": "Reporting {target}", "report.target": "Reporting {target}",
"search.placeholder": "Search", "search.placeholder": "Search",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...", "standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
@ -196,6 +201,7 @@
"tabs_bar.notifications": "Notifications", "tabs_bar.notifications": "Notifications",
"upload_area.title": "Drag & drop to upload", "upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media", "upload_button.label": "Add media",
"upload_form.description": "Describe for the visually impaired",
"upload_form.undo": "Undo", "upload_form.undo": "Undo",
"upload_progress.label": "Uploading...", "upload_progress.label": "Uploading...",
"video.close": "Close video", "video.close": "Close video",
@ -206,9 +212,5 @@
"video.mute": "Mute sound", "video.mute": "Mute sound",
"video.pause": "Pause", "video.pause": "Pause",
"video.play": "Play", "video.play": "Play",
"video.unmute": "Unmute sound", "video.unmute": "Unmute sound"
"video_player.expand": "Expand video",
"video_player.toggle_sound": "Toggle sound",
"video_player.toggle_visible": "Toggle visibility",
"video_player.video_error": "Video could not be played"
} }

@ -165,6 +165,11 @@
"report.submit": "Submit", "report.submit": "Submit",
"report.target": "Reporting", "report.target": "Reporting",
"search.placeholder": "Serĉi", "search.placeholder": "Serĉi",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...", "standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
@ -196,6 +201,7 @@
"tabs_bar.notifications": "Sciigoj", "tabs_bar.notifications": "Sciigoj",
"upload_area.title": "Drag & drop to upload", "upload_area.title": "Drag & drop to upload",
"upload_button.label": "Aldoni enhavaĵon", "upload_button.label": "Aldoni enhavaĵon",
"upload_form.description": "Describe for the visually impaired",
"upload_form.undo": "Malfari", "upload_form.undo": "Malfari",
"upload_progress.label": "Uploading...", "upload_progress.label": "Uploading...",
"video.close": "Close video", "video.close": "Close video",
@ -206,9 +212,5 @@
"video.mute": "Mute sound", "video.mute": "Mute sound",
"video.pause": "Pause", "video.pause": "Pause",
"video.play": "Play", "video.play": "Play",
"video.unmute": "Unmute sound", "video.unmute": "Unmute sound"
"video_player.expand": "Expand video",
"video_player.toggle_sound": "Aktivigi sonojn",
"video_player.toggle_visible": "Toggle visibility",
"video_player.video_error": "Video could not be played"
} }

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save