Merge upstream 2.0ish #165
This commit is contained in:
commit
8d6b9ba494
|
@ -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
|
||||||
|
|
||||||
|
|
4
Gemfile
4
Gemfile
|
@ -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,15 +15,13 @@ 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)
|
||||||
|
.where(account: current_account)
|
||||||
|
.paginate_by_max_id(
|
||||||
limit_param(DEFAULT_ACCOUNTS_LIMIT),
|
limit_param(DEFAULT_ACCOUNTS_LIMIT),
|
||||||
params[:max_id],
|
params[:max_id],
|
||||||
params[:since_id]
|
params[:since_id]
|
||||||
|
@ -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
|
||||||
|
@ -41,10 +48,21 @@ module SignatureVerification
|
||||||
signature = Base64.decode64(signature_params['signature'])
|
signature = Base64.decode64(signature_params['signature'])
|
||||||
compare_signed_string = build_signed_string(signature_params['headers'])
|
compare_signed_string = build_signed_string(signature_params['headers'])
|
||||||
|
|
||||||
|
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
|
||||||
|
@signed_request_account = 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)
|
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
|
||||||
else
|
else
|
||||||
|
@signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
|
||||||
|
@signed_request_account = nil
|
||||||
|
end
|
||||||
|
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)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
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
|
||||||
@statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
|
@statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
|
||||||
@statuses = cache_collection(@statuses, Status)
|
@statuses = cache_collection(@statuses, Status)
|
||||||
|
|
||||||
respond_to do |format|
|
render json: collection_presenter,
|
||||||
format.html
|
serializer: ActivityPub::CollectionSerializer,
|
||||||
|
adapter: ActivityPub::Adapter,
|
||||||
format.json do
|
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
|
||||||
|
|
||||||
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,14 +259,7 @@ export function clearComposeSuggestions() {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchComposeSuggestions(token) {
|
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
|
||||||
return (dispatch, getState) => {
|
|
||||||
if (token[0] === ':') {
|
|
||||||
const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 });
|
|
||||||
dispatch(readyComposeSuggestionsEmojis(token, results));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
api(getState).get('/api/v1/accounts/search', {
|
api(getState).get('/api/v1/accounts/search', {
|
||||||
params: {
|
params: {
|
||||||
q: token.slice(1),
|
q: token.slice(1),
|
||||||
|
@ -229,6 +269,20 @@ export function fetchComposeSuggestions(token) {
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
dispatch(readyComposeSuggestionsAccounts(token, response.data));
|
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) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
if (token[0] === ':') {
|
||||||
|
fetchComposeSuggestionsEmojis(dispatch, getState, token);
|
||||||
|
} else {
|
||||||
|
fetchComposeSuggestionsAccounts(dispatch, getState, token);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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,28 +58,33 @@ 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;
|
}
|
||||||
|
|
||||||
|
updateStateAfterIntersection = (prevState) => {
|
||||||
|
if (prevState.isIntersecting && !this.entry.isIntersecting) {
|
||||||
|
scheduleIdleTask(this.hideIfNotIntersecting);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isIntersecting: this.entry.isIntersecting,
|
||||||
|
isHidden: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateHeight = () => {
|
||||||
|
const { onHeightChange, saveHeightKey, id } = this.props;
|
||||||
|
// save the height of the fully-rendered element (this is expensive
|
||||||
|
// on Chrome, where we need to fall back to getBoundingClientRect)
|
||||||
|
this.height = getRectFromEntry(this.entry).height;
|
||||||
|
|
||||||
if (onHeightChange && saveHeightKey) {
|
if (onHeightChange && saveHeightKey) {
|
||||||
onHeightChange(saveHeightKey, id, this.height);
|
onHeightChange(saveHeightKey, id, this.height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState((prevState) => {
|
|
||||||
if (prevState.isIntersecting && !entry.isIntersecting) {
|
|
||||||
scheduleIdleTask(this.hideIfNotIntersecting);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
isIntersecting: entry.isIntersecting,
|
|
||||||
isHidden: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hideIfNotIntersecting = () => {
|
hideIfNotIntersecting = () => {
|
||||||
if (!this.componentMounted) {
|
if (!this.componentMounted) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -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 {
|
} else {
|
||||||
|
// layout automatically, using image's natural aspect ratio
|
||||||
|
style.height = '';
|
||||||
|
}
|
||||||
|
} 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>
|
</div>
|
||||||
|
|
||||||
<Status {...other} status={status.get('reblog')} account={status.get('account')} />
|
|
||||||
</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,12 +212,27 @@ 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={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
|
||||||
|
{prepend}
|
||||||
|
|
||||||
|
<div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
|
||||||
<div className='status__info'>
|
<div className='status__info'>
|
||||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||||
|
|
||||||
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'>
|
<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'>
|
||||||
<div className='status__avatar'>
|
<div className='status__avatar'>
|
||||||
{statusAvatar}
|
{statusAvatar}
|
||||||
</div>
|
</div>
|
||||||
|
@ -181,8 +245,10 @@ export default class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
{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 }) }}>
|
||||||
|
{({ opacity, scaleX, scaleY }) => (
|
||||||
|
<div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</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 mapStateToProps = state => ({
|
const perLine = 8;
|
||||||
custom_emojis: state.get('custom_emojis'),
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps)(EmojiPickerDropdown);
|
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 => ({
|
||||||
|
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, 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,17 +9,69 @@ 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 notification-follow focusable' tabIndex='0'>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__favourite-icon-wrapper'>
|
||||||
<i className='fa fa-fw fa-user-plus' />
|
<i className='fa fa-fw fa-user-plus' />
|
||||||
|
@ -30,16 +82,26 @@ export default class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
|
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 notification-favourite focusable' tabIndex='0'>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__favourite-icon-wrapper'>
|
||||||
<i className='fa fa-fw fa-star star-icon' />
|
<i className='fa fa-fw fa-star star-icon' />
|
||||||
|
@ -49,12 +111,14 @@ export default class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<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 notification-reblog focusable' tabIndex='0'>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__favourite-icon-wrapper'>
|
||||||
<i className='fa fa-fw fa-retweet' />
|
<i className='fa fa-fw fa-retweet' />
|
||||||
|
@ -64,6 +128,7 @@ export default class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<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,14 +271,26 @@ 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}
|
||||||
|
|
||||||
|
<HotKeys handlers={handlers}>
|
||||||
|
<div className='focusable' tabIndex='0'>
|
||||||
<DetailedStatus
|
<DetailedStatus
|
||||||
status={status}
|
status={status}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
@ -207,6 +312,8 @@ export default class Status extends ImmutablePureComponent {
|
||||||
onPin={this.handlePin}
|
onPin={this.handlePin}
|
||||||
onEmbed={this.handleEmbed}
|
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 }) => (
|
|
||||||
<div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
|
||||||
<div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
|
|
||||||
<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} />}
|
{(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
|
||||||
</BundleContainer>
|
</BundleContainer>) :
|
||||||
</div>
|
null
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</TransitionMotion>
|
</div>
|
||||||
|
</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 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,9 +347,29 @@ 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 (
|
||||||
|
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
|
||||||
<div className={className} ref={this.setRef}>
|
<div className={className} ref={this.setRef}>
|
||||||
{navbarUnder ? null : (<TabsBar />)}
|
{navbarUnder ? null : (<TabsBar />)}
|
||||||
|
|
||||||
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
|
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
|
||||||
<WrappedSwitch>
|
<WrappedSwitch>
|
||||||
<Redirect from='/' to='/getting-started' exact />
|
<Redirect from='/' to='/getting-started' exact />
|
||||||
|
@ -259,12 +400,14 @@ export default class UI extends React.PureComponent {
|
||||||
<WrappedRoute component={GenericNotFound} content={children} />
|
<WrappedRoute component={GenericNotFound} content={children} />
|
||||||
</WrappedSwitch>
|
</WrappedSwitch>
|
||||||
</ColumnsAreaContainer>
|
</ColumnsAreaContainer>
|
||||||
|
|
||||||
<NotificationsContainer />
|
<NotificationsContainer />
|
||||||
{navbarUnder ? (<TabsBar />) : null}
|
{navbarUnder ? (<TabsBar />) : null}
|
||||||
<LoadingBarContainer className='loading-bar' />
|
<LoadingBarContainer className='loading-bar' />
|
||||||
<ModalContainer />
|
<ModalContainer />
|
||||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||||
</div>
|
</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"
|
||||||
|
@ -653,15 +665,6 @@
|
||||||
],
|
],
|
||||||
"path": "app/javascript/mastodon/features/compose/components/upload_button.json"
|
"path": "app/javascript/mastodon/features/compose/components/upload_button.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"descriptors": [
|
|
||||||
{
|
|
||||||
"defaultMessage": "Undo",
|
|
||||||
"id": "upload_form.undo"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"path": "app/javascript/mastodon/features/compose/components/upload_form.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"descriptors": [
|
"descriptors": [
|
||||||
{
|
{
|
||||||
|
@ -671,6 +674,19 @@
|
||||||
],
|
],
|
||||||
"path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
|
"path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"descriptors": [
|
||||||
|
{
|
||||||
|
"defaultMessage": "Undo",
|
||||||
|
"id": "upload_form.undo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defaultMessage": "Describe for the visually impaired",
|
||||||
|
"id": "upload_form.description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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…
Reference in New Issue