Merge pull request #157 from glitch-soc/merging-upstream
ABRACA-HRRRRRRRRRRRNGGGGGGGHHH!!!!!!!!!!!!!!!!!!!
| 
						 | 
					@ -1,5 +1,6 @@
 | 
				
			||||||
# Service dependencies
 | 
					# Service dependencies
 | 
				
			||||||
# You may set REDIS_URL instead for more advanced options
 | 
					# You may set REDIS_URL instead for more advanced options
 | 
				
			||||||
 | 
					# You may also set REDIS_NAMESPACE to share Redis between multiple Mastodon servers
 | 
				
			||||||
REDIS_HOST=redis
 | 
					REDIS_HOST=redis
 | 
				
			||||||
REDIS_PORT=6379
 | 
					REDIS_PORT=6379
 | 
				
			||||||
# You may set DATABASE_URL instead for more advanced options
 | 
					# You may set DATABASE_URL instead for more advanced options
 | 
				
			||||||
| 
						 | 
					@ -101,11 +102,19 @@ SMTP_FROM_ADDRESS=notifications@example.com
 | 
				
			||||||
# Swift (optional)
 | 
					# Swift (optional)
 | 
				
			||||||
# SWIFT_ENABLED=true
 | 
					# SWIFT_ENABLED=true
 | 
				
			||||||
# SWIFT_USERNAME=
 | 
					# SWIFT_USERNAME=
 | 
				
			||||||
 | 
					# For Keystone V3, the value for SWIFT_TENANT should be the project name
 | 
				
			||||||
# SWIFT_TENANT=
 | 
					# SWIFT_TENANT=
 | 
				
			||||||
# SWIFT_PASSWORD=
 | 
					# SWIFT_PASSWORD=
 | 
				
			||||||
 | 
					# Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid
 | 
				
			||||||
 | 
					# issues with token rate-limiting during high load.
 | 
				
			||||||
# SWIFT_AUTH_URL=
 | 
					# SWIFT_AUTH_URL=
 | 
				
			||||||
# SWIFT_CONTAINER=
 | 
					# SWIFT_CONTAINER=
 | 
				
			||||||
# SWIFT_OBJECT_URL=
 | 
					# SWIFT_OBJECT_URL=
 | 
				
			||||||
 | 
					# SWIFT_REGION=
 | 
				
			||||||
 | 
					# Defaults to 'default'
 | 
				
			||||||
 | 
					# SWIFT_DOMAIN_NAME=
 | 
				
			||||||
 | 
					# Defaults to 60 seconds. Set to 0 to disable
 | 
				
			||||||
 | 
					# SWIFT_CACHE_TTL=
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
 | 
					# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
 | 
				
			||||||
# S3_CLOUDFRONT_HOST=
 | 
					# S3_CLOUDFRONT_HOST=
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| 
						 | 
					@ -21,6 +21,7 @@ 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/
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1 +1 @@
 | 
				
			||||||
2.4.1
 | 
					2.4.2
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,18 +26,16 @@ addons:
 | 
				
			||||||
  postgresql: 9.4
 | 
					  postgresql: 9.4
 | 
				
			||||||
  apt:
 | 
					  apt:
 | 
				
			||||||
    sources:
 | 
					    sources:
 | 
				
			||||||
    - ubuntu-toolchain-r-test
 | 
					 | 
				
			||||||
    - trusty-media
 | 
					    - trusty-media
 | 
				
			||||||
    packages:
 | 
					    packages:
 | 
				
			||||||
    - ffmpeg
 | 
					    - ffmpeg
 | 
				
			||||||
    - g++-6
 | 
					 | 
				
			||||||
    - libprotobuf-dev
 | 
					    - libprotobuf-dev
 | 
				
			||||||
    - protobuf-compiler
 | 
					    - protobuf-compiler
 | 
				
			||||||
    - libicu-dev
 | 
					    - libicu-dev
 | 
				
			||||||
 | 
					
 | 
				
			||||||
rvm:
 | 
					rvm:
 | 
				
			||||||
  - 2.3.4
 | 
					  - 2.3.4
 | 
				
			||||||
  - 2.4.1
 | 
					  - 2.4.2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
services:
 | 
					services:
 | 
				
			||||||
  - redis-server
 | 
					  - redis-server
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										1
									
								
								Aptfile
									
									
									
									
									
								
							
							
						
						| 
						 | 
					@ -1,4 +1,5 @@
 | 
				
			||||||
ffmpeg
 | 
					ffmpeg
 | 
				
			||||||
 | 
					libicu[0-9][0-9]
 | 
				
			||||||
libicu-dev
 | 
					libicu-dev
 | 
				
			||||||
libidn11
 | 
					libidn11
 | 
				
			||||||
libidn11-dev
 | 
					libidn11-dev
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										18
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
FROM ruby:2.4.1-alpine3.6
 | 
					FROM ruby:2.4.2-alpine3.6
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LABEL maintainer="https://github.com/tootsuite/mastodon" \
 | 
					LABEL maintainer="https://github.com/tootsuite/mastodon" \
 | 
				
			||||||
      description="A GNU Social-compatible microblogging server"
 | 
					      description="A GNU Social-compatible microblogging server"
 | 
				
			||||||
| 
						 | 
					@ -7,6 +7,8 @@ ENV UID=991 GID=991 \
 | 
				
			||||||
    RAILS_SERVE_STATIC_FILES=true \
 | 
					    RAILS_SERVE_STATIC_FILES=true \
 | 
				
			||||||
    RAILS_ENV=production NODE_ENV=production
 | 
					    RAILS_ENV=production NODE_ENV=production
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ARG YARN_VERSION=1.1.0
 | 
				
			||||||
 | 
					ARG YARN_DOWNLOAD_SHA256=171c1f9ee93c488c0d774ac6e9c72649047c3f896277d88d0f805266519430f3
 | 
				
			||||||
ARG LIBICONV_VERSION=1.15
 | 
					ARG LIBICONV_VERSION=1.15
 | 
				
			||||||
ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178
 | 
					ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -19,6 +21,7 @@ RUN apk -U upgrade \
 | 
				
			||||||
    build-base \
 | 
					    build-base \
 | 
				
			||||||
    icu-dev \
 | 
					    icu-dev \
 | 
				
			||||||
    libidn-dev \
 | 
					    libidn-dev \
 | 
				
			||||||
 | 
					    libressl \
 | 
				
			||||||
    libtool \
 | 
					    libtool \
 | 
				
			||||||
    postgresql-dev \
 | 
					    postgresql-dev \
 | 
				
			||||||
    protobuf-dev \
 | 
					    protobuf-dev \
 | 
				
			||||||
| 
						 | 
					@ -32,16 +35,21 @@ RUN apk -U upgrade \
 | 
				
			||||||
    imagemagick \
 | 
					    imagemagick \
 | 
				
			||||||
    libidn \
 | 
					    libidn \
 | 
				
			||||||
    libpq \
 | 
					    libpq \
 | 
				
			||||||
    nodejs-npm \
 | 
					 | 
				
			||||||
    nodejs \
 | 
					    nodejs \
 | 
				
			||||||
 | 
					    nodejs-npm \
 | 
				
			||||||
    protobuf \
 | 
					    protobuf \
 | 
				
			||||||
    su-exec \
 | 
					    su-exec \
 | 
				
			||||||
    tini \
 | 
					    tini \
 | 
				
			||||||
    yarn \
 | 
					 | 
				
			||||||
 && update-ca-certificates \
 | 
					 && update-ca-certificates \
 | 
				
			||||||
 | 
					 && mkdir -p /tmp/src /opt \
 | 
				
			||||||
 | 
					 && wget -O yarn.tar.gz "https://github.com/yarnpkg/yarn/releases/download/v$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \
 | 
				
			||||||
 | 
					 && echo "$YARN_DOWNLOAD_SHA256 *yarn.tar.gz" | sha256sum -c - \
 | 
				
			||||||
 | 
					 && tar -xzf yarn.tar.gz -C /tmp/src \
 | 
				
			||||||
 | 
					 && rm yarn.tar.gz \
 | 
				
			||||||
 | 
					 && mv /tmp/src/yarn-v$YARN_VERSION /opt/yarn \
 | 
				
			||||||
 | 
					 && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \
 | 
				
			||||||
 && wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \
 | 
					 && wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \
 | 
				
			||||||
 && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \
 | 
					 && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \
 | 
				
			||||||
 && mkdir -p /tmp/src \
 | 
					 | 
				
			||||||
 && tar -xzf libiconv.tar.gz -C /tmp/src \
 | 
					 && tar -xzf libiconv.tar.gz -C /tmp/src \
 | 
				
			||||||
 && rm libiconv.tar.gz \
 | 
					 && rm libiconv.tar.gz \
 | 
				
			||||||
 && cd /tmp/src/libiconv-$LIBICONV_VERSION \
 | 
					 && cd /tmp/src/libiconv-$LIBICONV_VERSION \
 | 
				
			||||||
| 
						 | 
					@ -56,7 +64,7 @@ COPY Gemfile Gemfile.lock package.json yarn.lock /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 --ignore-optional --pure-lockfile
 | 
					 && yarn --pure-lockfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY . /mastodon
 | 
					COPY . /mastodon
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										13
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						| 
						 | 
					@ -5,8 +5,8 @@ ruby '>= 2.3.0', '< 2.5.0'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gem 'pkg-config', '~> 1.2'
 | 
					gem 'pkg-config', '~> 1.2'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gem 'puma', '~> 3.8'
 | 
					gem 'puma', '~> 3.10'
 | 
				
			||||||
gem 'rails', '~> 5.1.0'
 | 
					gem 'rails', '~> 5.1.4'
 | 
				
			||||||
gem 'uglifier', '~> 3.2'
 | 
					gem 'uglifier', '~> 3.2'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gem 'hamlit-rails', '~> 0.2'
 | 
					gem 'hamlit-rails', '~> 0.2'
 | 
				
			||||||
| 
						 | 
					@ -25,7 +25,7 @@ gem 'bootsnap'
 | 
				
			||||||
gem 'browser'
 | 
					gem 'browser'
 | 
				
			||||||
gem 'charlock_holmes', '~> 0.7.5'
 | 
					gem 'charlock_holmes', '~> 0.7.5'
 | 
				
			||||||
gem 'iso-639'
 | 
					gem 'iso-639'
 | 
				
			||||||
gem 'cld3', '~> 3.1'
 | 
					gem 'cld3', '~> 3.2.0'
 | 
				
			||||||
gem 'devise', '~> 4.2'
 | 
					gem 'devise', '~> 4.2'
 | 
				
			||||||
gem 'devise-two-factor', '~> 3.0'
 | 
					gem 'devise-two-factor', '~> 3.0'
 | 
				
			||||||
gem 'doorkeeper', '~> 4.2'
 | 
					gem 'doorkeeper', '~> 4.2'
 | 
				
			||||||
| 
						 | 
					@ -67,7 +67,7 @@ gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
 | 
				
			||||||
gem 'statsd-instrument', '~> 2.1'
 | 
					gem 'statsd-instrument', '~> 2.1'
 | 
				
			||||||
gem 'twitter-text', '~> 1.14'
 | 
					gem 'twitter-text', '~> 1.14'
 | 
				
			||||||
gem 'tzinfo-data', '~> 1.2017'
 | 
					gem 'tzinfo-data', '~> 1.2017'
 | 
				
			||||||
gem 'webpacker', '~> 2.0'
 | 
					gem 'webpacker', '~> 3.0'
 | 
				
			||||||
gem 'webpush'
 | 
					gem 'webpush'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gem 'json-ld-preloaded', '~> 2.2.1'
 | 
					gem 'json-ld-preloaded', '~> 2.2.1'
 | 
				
			||||||
| 
						 | 
					@ -102,9 +102,10 @@ group :development do
 | 
				
			||||||
  gem 'letter_opener', '~> 1.4'
 | 
					  gem 'letter_opener', '~> 1.4'
 | 
				
			||||||
  gem 'letter_opener_web', '~> 1.3'
 | 
					  gem 'letter_opener_web', '~> 1.3'
 | 
				
			||||||
  gem 'rubocop', require: false
 | 
					  gem 'rubocop', require: false
 | 
				
			||||||
  gem 'brakeman', '~> 3.6', require: false
 | 
					  gem 'brakeman', '~> 4.0', require: false
 | 
				
			||||||
  gem 'bundler-audit', '~> 0.5', 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'
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										191
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						| 
						 | 
					@ -1,25 +1,25 @@
 | 
				
			||||||
GEM
 | 
					GEM
 | 
				
			||||||
  remote: https://rubygems.org/
 | 
					  remote: https://rubygems.org/
 | 
				
			||||||
  specs:
 | 
					  specs:
 | 
				
			||||||
    actioncable (5.1.3)
 | 
					    actioncable (5.1.4)
 | 
				
			||||||
      actionpack (= 5.1.3)
 | 
					      actionpack (= 5.1.4)
 | 
				
			||||||
      nio4r (~> 2.0)
 | 
					      nio4r (~> 2.0)
 | 
				
			||||||
      websocket-driver (~> 0.6.1)
 | 
					      websocket-driver (~> 0.6.1)
 | 
				
			||||||
    actionmailer (5.1.3)
 | 
					    actionmailer (5.1.4)
 | 
				
			||||||
      actionpack (= 5.1.3)
 | 
					      actionpack (= 5.1.4)
 | 
				
			||||||
      actionview (= 5.1.3)
 | 
					      actionview (= 5.1.4)
 | 
				
			||||||
      activejob (= 5.1.3)
 | 
					      activejob (= 5.1.4)
 | 
				
			||||||
      mail (~> 2.5, >= 2.5.4)
 | 
					      mail (~> 2.5, >= 2.5.4)
 | 
				
			||||||
      rails-dom-testing (~> 2.0)
 | 
					      rails-dom-testing (~> 2.0)
 | 
				
			||||||
    actionpack (5.1.3)
 | 
					    actionpack (5.1.4)
 | 
				
			||||||
      actionview (= 5.1.3)
 | 
					      actionview (= 5.1.4)
 | 
				
			||||||
      activesupport (= 5.1.3)
 | 
					      activesupport (= 5.1.4)
 | 
				
			||||||
      rack (~> 2.0)
 | 
					      rack (~> 2.0)
 | 
				
			||||||
      rack-test (~> 0.6.3)
 | 
					      rack-test (>= 0.6.3)
 | 
				
			||||||
      rails-dom-testing (~> 2.0)
 | 
					      rails-dom-testing (~> 2.0)
 | 
				
			||||||
      rails-html-sanitizer (~> 1.0, >= 1.0.2)
 | 
					      rails-html-sanitizer (~> 1.0, >= 1.0.2)
 | 
				
			||||||
    actionview (5.1.3)
 | 
					    actionview (5.1.4)
 | 
				
			||||||
      activesupport (= 5.1.3)
 | 
					      activesupport (= 5.1.4)
 | 
				
			||||||
      builder (~> 3.1)
 | 
					      builder (~> 3.1)
 | 
				
			||||||
      erubi (~> 1.4)
 | 
					      erubi (~> 1.4)
 | 
				
			||||||
      rails-dom-testing (~> 2.0)
 | 
					      rails-dom-testing (~> 2.0)
 | 
				
			||||||
| 
						 | 
					@ -30,16 +30,16 @@ GEM
 | 
				
			||||||
      case_transform (>= 0.2)
 | 
					      case_transform (>= 0.2)
 | 
				
			||||||
      jsonapi-renderer (>= 0.1.1.beta1, < 0.2)
 | 
					      jsonapi-renderer (>= 0.1.1.beta1, < 0.2)
 | 
				
			||||||
    active_record_query_trace (1.5.4)
 | 
					    active_record_query_trace (1.5.4)
 | 
				
			||||||
    activejob (5.1.3)
 | 
					    activejob (5.1.4)
 | 
				
			||||||
      activesupport (= 5.1.3)
 | 
					      activesupport (= 5.1.4)
 | 
				
			||||||
      globalid (>= 0.3.6)
 | 
					      globalid (>= 0.3.6)
 | 
				
			||||||
    activemodel (5.1.3)
 | 
					    activemodel (5.1.4)
 | 
				
			||||||
      activesupport (= 5.1.3)
 | 
					      activesupport (= 5.1.4)
 | 
				
			||||||
    activerecord (5.1.3)
 | 
					    activerecord (5.1.4)
 | 
				
			||||||
      activemodel (= 5.1.3)
 | 
					      activemodel (= 5.1.4)
 | 
				
			||||||
      activesupport (= 5.1.3)
 | 
					      activesupport (= 5.1.4)
 | 
				
			||||||
      arel (~> 8.0)
 | 
					      arel (~> 8.0)
 | 
				
			||||||
    activesupport (5.1.3)
 | 
					    activesupport (5.1.4)
 | 
				
			||||||
      concurrent-ruby (~> 1.0, >= 1.0.2)
 | 
					      concurrent-ruby (~> 1.0, >= 1.0.2)
 | 
				
			||||||
      i18n (~> 0.7)
 | 
					      i18n (~> 0.7)
 | 
				
			||||||
      minitest (~> 5.1)
 | 
					      minitest (~> 5.1)
 | 
				
			||||||
| 
						 | 
					@ -57,33 +57,33 @@ GEM
 | 
				
			||||||
      encryptor (~> 3.0.0)
 | 
					      encryptor (~> 3.0.0)
 | 
				
			||||||
    av (0.9.0)
 | 
					    av (0.9.0)
 | 
				
			||||||
      cocaine (~> 0.5.3)
 | 
					      cocaine (~> 0.5.3)
 | 
				
			||||||
    aws-sdk (2.10.21)
 | 
					    aws-sdk (2.10.46)
 | 
				
			||||||
      aws-sdk-resources (= 2.10.21)
 | 
					      aws-sdk-resources (= 2.10.46)
 | 
				
			||||||
    aws-sdk-core (2.10.21)
 | 
					    aws-sdk-core (2.10.46)
 | 
				
			||||||
      aws-sigv4 (~> 1.0)
 | 
					      aws-sigv4 (~> 1.0)
 | 
				
			||||||
      jmespath (~> 1.0)
 | 
					      jmespath (~> 1.0)
 | 
				
			||||||
    aws-sdk-resources (2.10.21)
 | 
					    aws-sdk-resources (2.10.46)
 | 
				
			||||||
      aws-sdk-core (= 2.10.21)
 | 
					      aws-sdk-core (= 2.10.46)
 | 
				
			||||||
    aws-sigv4 (1.0.1)
 | 
					    aws-sigv4 (1.0.2)
 | 
				
			||||||
    bcrypt (3.1.11)
 | 
					    bcrypt (3.1.11)
 | 
				
			||||||
    better_errors (2.1.1)
 | 
					    better_errors (2.3.0)
 | 
				
			||||||
      coderay (>= 1.0.0)
 | 
					      coderay (>= 1.0.0)
 | 
				
			||||||
      erubis (>= 2.6.6)
 | 
					      erubi (>= 1.0.0)
 | 
				
			||||||
      rack (>= 0.9.0)
 | 
					      rack (>= 0.9.0)
 | 
				
			||||||
    binding_of_caller (0.7.2)
 | 
					    binding_of_caller (0.7.2)
 | 
				
			||||||
      debug_inspector (>= 0.0.1)
 | 
					      debug_inspector (>= 0.0.1)
 | 
				
			||||||
    bootsnap (1.1.2)
 | 
					    bootsnap (1.1.3)
 | 
				
			||||||
      msgpack (~> 1.0)
 | 
					      msgpack (~> 1.0)
 | 
				
			||||||
    brakeman (3.7.2)
 | 
					    brakeman (4.0.1)
 | 
				
			||||||
    browser (2.4.0)
 | 
					    browser (2.5.1)
 | 
				
			||||||
    builder (3.2.3)
 | 
					    builder (3.2.3)
 | 
				
			||||||
    bullet (5.5.1)
 | 
					    bullet (5.6.1)
 | 
				
			||||||
      activesupport (>= 3.0.0)
 | 
					      activesupport (>= 3.0.0)
 | 
				
			||||||
      uniform_notifier (~> 1.10.0)
 | 
					      uniform_notifier (~> 1.10.0)
 | 
				
			||||||
    bundler-audit (0.6.0)
 | 
					    bundler-audit (0.6.0)
 | 
				
			||||||
      bundler (~> 1.2)
 | 
					      bundler (~> 1.2)
 | 
				
			||||||
      thor (~> 0.18)
 | 
					      thor (~> 0.18)
 | 
				
			||||||
    capistrano (3.8.2)
 | 
					    capistrano (3.9.1)
 | 
				
			||||||
      airbrussh (>= 1.0.0)
 | 
					      airbrussh (>= 1.0.0)
 | 
				
			||||||
      i18n
 | 
					      i18n
 | 
				
			||||||
      rake (>= 10.0.0)
 | 
					      rake (>= 10.0.0)
 | 
				
			||||||
| 
						 | 
					@ -99,9 +99,9 @@ GEM
 | 
				
			||||||
      sshkit (~> 1.3)
 | 
					      sshkit (~> 1.3)
 | 
				
			||||||
    capistrano-yarn (2.0.2)
 | 
					    capistrano-yarn (2.0.2)
 | 
				
			||||||
      capistrano (~> 3.0)
 | 
					      capistrano (~> 3.0)
 | 
				
			||||||
    capybara (2.14.4)
 | 
					    capybara (2.15.1)
 | 
				
			||||||
      addressable
 | 
					      addressable
 | 
				
			||||||
      mime-types (>= 1.16)
 | 
					      mini_mime (>= 0.1.3)
 | 
				
			||||||
      nokogiri (>= 1.3.3)
 | 
					      nokogiri (>= 1.3.3)
 | 
				
			||||||
      rack (>= 1.0.0)
 | 
					      rack (>= 1.0.0)
 | 
				
			||||||
      rack-test (>= 0.5.4)
 | 
					      rack-test (>= 0.5.4)
 | 
				
			||||||
| 
						 | 
					@ -110,12 +110,12 @@ GEM
 | 
				
			||||||
      activesupport
 | 
					      activesupport
 | 
				
			||||||
    charlock_holmes (0.7.5)
 | 
					    charlock_holmes (0.7.5)
 | 
				
			||||||
    chunky_png (1.3.8)
 | 
					    chunky_png (1.3.8)
 | 
				
			||||||
    cld3 (3.1.3)
 | 
					    cld3 (3.2.0)
 | 
				
			||||||
      ffi (>= 1.1.0, < 1.10.0)
 | 
					      ffi (>= 1.1.0, < 1.10.0)
 | 
				
			||||||
    climate_control (0.2.0)
 | 
					    climate_control (0.2.0)
 | 
				
			||||||
    cocaine (0.5.8)
 | 
					    cocaine (0.5.8)
 | 
				
			||||||
      climate_control (>= 0.0.3, < 1.0)
 | 
					      climate_control (>= 0.0.3, < 1.0)
 | 
				
			||||||
    coderay (1.1.1)
 | 
					    coderay (1.1.2)
 | 
				
			||||||
    colorize (0.8.1)
 | 
					    colorize (0.8.1)
 | 
				
			||||||
    concurrent-ruby (1.0.5)
 | 
					    concurrent-ruby (1.0.5)
 | 
				
			||||||
    connection_pool (2.2.1)
 | 
					    connection_pool (2.2.1)
 | 
				
			||||||
| 
						 | 
					@ -151,13 +151,12 @@ GEM
 | 
				
			||||||
      thread_safe
 | 
					      thread_safe
 | 
				
			||||||
    encryptor (3.0.0)
 | 
					    encryptor (3.0.0)
 | 
				
			||||||
    erubi (1.6.1)
 | 
					    erubi (1.6.1)
 | 
				
			||||||
    erubis (2.7.0)
 | 
					 | 
				
			||||||
    et-orbi (1.0.5)
 | 
					    et-orbi (1.0.5)
 | 
				
			||||||
      tzinfo
 | 
					      tzinfo
 | 
				
			||||||
    excon (0.58.0)
 | 
					    excon (0.59.0)
 | 
				
			||||||
    execjs (2.7.0)
 | 
					    execjs (2.7.0)
 | 
				
			||||||
    fabrication (2.16.2)
 | 
					    fabrication (2.16.3)
 | 
				
			||||||
    faker (1.7.3)
 | 
					    faker (1.8.4)
 | 
				
			||||||
      i18n (~> 0.5)
 | 
					      i18n (~> 0.5)
 | 
				
			||||||
    fast_blank (1.0.0)
 | 
					    fast_blank (1.0.0)
 | 
				
			||||||
    ffi (1.9.18)
 | 
					    ffi (1.9.18)
 | 
				
			||||||
| 
						 | 
					@ -194,7 +193,7 @@ GEM
 | 
				
			||||||
      railties (>= 4.0.1)
 | 
					      railties (>= 4.0.1)
 | 
				
			||||||
    hamster (3.0.0)
 | 
					    hamster (3.0.0)
 | 
				
			||||||
      concurrent-ruby (~> 1.0)
 | 
					      concurrent-ruby (~> 1.0)
 | 
				
			||||||
    hashdiff (0.3.5)
 | 
					    hashdiff (0.3.6)
 | 
				
			||||||
    highline (1.7.8)
 | 
					    highline (1.7.8)
 | 
				
			||||||
    hiredis (0.6.1)
 | 
					    hiredis (0.6.1)
 | 
				
			||||||
    hkdf (0.3.0)
 | 
					    hkdf (0.3.0)
 | 
				
			||||||
| 
						 | 
					@ -213,11 +212,11 @@ GEM
 | 
				
			||||||
      colorize
 | 
					      colorize
 | 
				
			||||||
      rack
 | 
					      rack
 | 
				
			||||||
    i18n (0.8.6)
 | 
					    i18n (0.8.6)
 | 
				
			||||||
    i18n-tasks (0.9.16)
 | 
					    i18n-tasks (0.9.18)
 | 
				
			||||||
      activesupport (>= 4.0.2)
 | 
					      activesupport (>= 4.0.2)
 | 
				
			||||||
      ast (>= 2.1.0)
 | 
					      ast (>= 2.1.0)
 | 
				
			||||||
      easy_translate (>= 0.5.0)
 | 
					      easy_translate (>= 0.5.0)
 | 
				
			||||||
      erubis
 | 
					      erubi
 | 
				
			||||||
      highline (>= 1.7.3)
 | 
					      highline (>= 1.7.3)
 | 
				
			||||||
      i18n
 | 
					      i18n
 | 
				
			||||||
      parser (>= 2.2.3.0)
 | 
					      parser (>= 2.2.3.0)
 | 
				
			||||||
| 
						 | 
					@ -231,7 +230,7 @@ GEM
 | 
				
			||||||
    json-ld (2.1.5)
 | 
					    json-ld (2.1.5)
 | 
				
			||||||
      multi_json (~> 1.12)
 | 
					      multi_json (~> 1.12)
 | 
				
			||||||
      rdf (~> 2.2)
 | 
					      rdf (~> 2.2)
 | 
				
			||||||
    json-ld-preloaded (2.2.1)
 | 
					    json-ld-preloaded (2.2.2)
 | 
				
			||||||
      json-ld (~> 2.1, >= 2.1.5)
 | 
					      json-ld (~> 2.1, >= 2.1.5)
 | 
				
			||||||
      multi_json (~> 1.11)
 | 
					      multi_json (~> 1.11)
 | 
				
			||||||
      rdf (~> 2.2)
 | 
					      rdf (~> 2.2)
 | 
				
			||||||
| 
						 | 
					@ -258,10 +257,11 @@ GEM
 | 
				
			||||||
      letter_opener (~> 1.0)
 | 
					      letter_opener (~> 1.0)
 | 
				
			||||||
      railties (>= 3.2)
 | 
					      railties (>= 3.2)
 | 
				
			||||||
    link_header (0.0.8)
 | 
					    link_header (0.0.8)
 | 
				
			||||||
    lograge (0.5.1)
 | 
					    lograge (0.6.0)
 | 
				
			||||||
      actionpack (>= 4, < 5.2)
 | 
					      actionpack (>= 4, < 5.2)
 | 
				
			||||||
      activesupport (>= 4, < 5.2)
 | 
					      activesupport (>= 4, < 5.2)
 | 
				
			||||||
      railties (>= 4, < 5.2)
 | 
					      railties (>= 4, < 5.2)
 | 
				
			||||||
 | 
					      request_store (~> 1.0)
 | 
				
			||||||
    loofah (2.0.3)
 | 
					    loofah (2.0.3)
 | 
				
			||||||
      nokogiri (>= 1.5.9)
 | 
					      nokogiri (>= 1.5.9)
 | 
				
			||||||
    mail (2.6.6)
 | 
					    mail (2.6.6)
 | 
				
			||||||
| 
						 | 
					@ -276,27 +276,28 @@ GEM
 | 
				
			||||||
      mime-types-data (~> 3.2015)
 | 
					      mime-types-data (~> 3.2015)
 | 
				
			||||||
    mime-types-data (3.2016.0521)
 | 
					    mime-types-data (3.2016.0521)
 | 
				
			||||||
    mimemagic (0.3.2)
 | 
					    mimemagic (0.3.2)
 | 
				
			||||||
 | 
					    mini_mime (0.1.4)
 | 
				
			||||||
    mini_portile2 (2.2.0)
 | 
					    mini_portile2 (2.2.0)
 | 
				
			||||||
    minitest (5.10.3)
 | 
					    minitest (5.10.3)
 | 
				
			||||||
    msgpack (1.1.0)
 | 
					    msgpack (1.1.0)
 | 
				
			||||||
    multi_json (1.12.1)
 | 
					    multi_json (1.12.2)
 | 
				
			||||||
    net-scp (1.2.1)
 | 
					    net-scp (1.2.1)
 | 
				
			||||||
      net-ssh (>= 2.6.5)
 | 
					      net-ssh (>= 2.6.5)
 | 
				
			||||||
    net-ssh (4.1.0)
 | 
					    net-ssh (4.2.0)
 | 
				
			||||||
    nio4r (2.1.0)
 | 
					    nio4r (2.1.0)
 | 
				
			||||||
    nokogiri (1.8.0)
 | 
					    nokogiri (1.8.0)
 | 
				
			||||||
      mini_portile2 (~> 2.2.0)
 | 
					      mini_portile2 (~> 2.2.0)
 | 
				
			||||||
    nokogumbo (1.4.13)
 | 
					    nokogumbo (1.4.13)
 | 
				
			||||||
      nokogiri
 | 
					      nokogiri
 | 
				
			||||||
    oj (3.3.4)
 | 
					    oj (3.3.5)
 | 
				
			||||||
    openssl (2.0.4)
 | 
					    openssl (2.0.5)
 | 
				
			||||||
    orm_adapter (0.5.0)
 | 
					    orm_adapter (0.5.0)
 | 
				
			||||||
    ostatus2 (2.0.1)
 | 
					    ostatus2 (2.0.1)
 | 
				
			||||||
      addressable (~> 2.4)
 | 
					      addressable (~> 2.4)
 | 
				
			||||||
      http (~> 2.0)
 | 
					      http (~> 2.0)
 | 
				
			||||||
      nokogiri (~> 1.6)
 | 
					      nokogiri (~> 1.6)
 | 
				
			||||||
      openssl (~> 2.0)
 | 
					      openssl (~> 2.0)
 | 
				
			||||||
    ox (2.5.0)
 | 
					    ox (2.6.0)
 | 
				
			||||||
    paperclip (5.1.0)
 | 
					    paperclip (5.1.0)
 | 
				
			||||||
      activemodel (>= 4.2.0)
 | 
					      activemodel (>= 4.2.0)
 | 
				
			||||||
      activesupport (>= 4.2.0)
 | 
					      activesupport (>= 4.2.0)
 | 
				
			||||||
| 
						 | 
					@ -306,15 +307,15 @@ GEM
 | 
				
			||||||
    paperclip-av-transcoder (0.6.4)
 | 
					    paperclip-av-transcoder (0.6.4)
 | 
				
			||||||
      av (~> 0.9.0)
 | 
					      av (~> 0.9.0)
 | 
				
			||||||
      paperclip (>= 2.5.2)
 | 
					      paperclip (>= 2.5.2)
 | 
				
			||||||
    parallel (1.11.2)
 | 
					    parallel (1.12.0)
 | 
				
			||||||
    parallel_tests (2.14.2)
 | 
					    parallel_tests (2.15.0)
 | 
				
			||||||
      parallel
 | 
					      parallel
 | 
				
			||||||
    parser (2.4.0.0)
 | 
					    parser (2.4.0.0)
 | 
				
			||||||
      ast (~> 2.2)
 | 
					      ast (~> 2.2)
 | 
				
			||||||
    pg (0.21.0)
 | 
					    pg (0.21.0)
 | 
				
			||||||
    pghero (1.7.0)
 | 
					    pghero (1.7.0)
 | 
				
			||||||
      activerecord
 | 
					      activerecord
 | 
				
			||||||
    pkg-config (1.2.4)
 | 
					    pkg-config (1.2.7)
 | 
				
			||||||
    powerpack (0.1.1)
 | 
					    powerpack (0.1.1)
 | 
				
			||||||
    pry (0.10.4)
 | 
					    pry (0.10.4)
 | 
				
			||||||
      coderay (~> 1.1.0)
 | 
					      coderay (~> 1.1.0)
 | 
				
			||||||
| 
						 | 
					@ -323,7 +324,7 @@ GEM
 | 
				
			||||||
    pry-rails (0.3.6)
 | 
					    pry-rails (0.3.6)
 | 
				
			||||||
      pry (>= 0.10.4)
 | 
					      pry (>= 0.10.4)
 | 
				
			||||||
    public_suffix (3.0.0)
 | 
					    public_suffix (3.0.0)
 | 
				
			||||||
    puma (3.9.1)
 | 
					    puma (3.10.0)
 | 
				
			||||||
    pundit (1.1.0)
 | 
					    pundit (1.1.0)
 | 
				
			||||||
      activesupport (>= 3.0.0)
 | 
					      activesupport (>= 3.0.0)
 | 
				
			||||||
    rabl (0.13.1)
 | 
					    rabl (0.13.1)
 | 
				
			||||||
| 
						 | 
					@ -334,20 +335,22 @@ GEM
 | 
				
			||||||
    rack-cors (0.4.1)
 | 
					    rack-cors (0.4.1)
 | 
				
			||||||
    rack-protection (2.0.0)
 | 
					    rack-protection (2.0.0)
 | 
				
			||||||
      rack
 | 
					      rack
 | 
				
			||||||
    rack-test (0.6.3)
 | 
					    rack-proxy (0.6.2)
 | 
				
			||||||
      rack (>= 1.0)
 | 
					      rack
 | 
				
			||||||
 | 
					    rack-test (0.7.0)
 | 
				
			||||||
 | 
					      rack (>= 1.0, < 3)
 | 
				
			||||||
    rack-timeout (0.4.2)
 | 
					    rack-timeout (0.4.2)
 | 
				
			||||||
    rails (5.1.3)
 | 
					    rails (5.1.4)
 | 
				
			||||||
      actioncable (= 5.1.3)
 | 
					      actioncable (= 5.1.4)
 | 
				
			||||||
      actionmailer (= 5.1.3)
 | 
					      actionmailer (= 5.1.4)
 | 
				
			||||||
      actionpack (= 5.1.3)
 | 
					      actionpack (= 5.1.4)
 | 
				
			||||||
      actionview (= 5.1.3)
 | 
					      actionview (= 5.1.4)
 | 
				
			||||||
      activejob (= 5.1.3)
 | 
					      activejob (= 5.1.4)
 | 
				
			||||||
      activemodel (= 5.1.3)
 | 
					      activemodel (= 5.1.4)
 | 
				
			||||||
      activerecord (= 5.1.3)
 | 
					      activerecord (= 5.1.4)
 | 
				
			||||||
      activesupport (= 5.1.3)
 | 
					      activesupport (= 5.1.4)
 | 
				
			||||||
      bundler (>= 1.3.0)
 | 
					      bundler (>= 1.3.0)
 | 
				
			||||||
      railties (= 5.1.3)
 | 
					      railties (= 5.1.4)
 | 
				
			||||||
      sprockets-rails (>= 2.0.0)
 | 
					      sprockets-rails (>= 2.0.0)
 | 
				
			||||||
    rails-controller-testing (1.0.2)
 | 
					    rails-controller-testing (1.0.2)
 | 
				
			||||||
      actionpack (~> 5.x, >= 5.0.1)
 | 
					      actionpack (~> 5.x, >= 5.0.1)
 | 
				
			||||||
| 
						 | 
					@ -363,16 +366,16 @@ GEM
 | 
				
			||||||
      railties (~> 5.0)
 | 
					      railties (~> 5.0)
 | 
				
			||||||
    rails-settings-cached (0.6.6)
 | 
					    rails-settings-cached (0.6.6)
 | 
				
			||||||
      rails (>= 4.2.0)
 | 
					      rails (>= 4.2.0)
 | 
				
			||||||
    railties (5.1.3)
 | 
					    railties (5.1.4)
 | 
				
			||||||
      actionpack (= 5.1.3)
 | 
					      actionpack (= 5.1.4)
 | 
				
			||||||
      activesupport (= 5.1.3)
 | 
					      activesupport (= 5.1.4)
 | 
				
			||||||
      method_source
 | 
					      method_source
 | 
				
			||||||
      rake (>= 0.8.7)
 | 
					      rake (>= 0.8.7)
 | 
				
			||||||
      thor (>= 0.18.1, < 2.0)
 | 
					      thor (>= 0.18.1, < 2.0)
 | 
				
			||||||
    rainbow (2.2.2)
 | 
					    rainbow (2.2.2)
 | 
				
			||||||
      rake
 | 
					      rake
 | 
				
			||||||
    rake (12.0.0)
 | 
					    rake (12.1.0)
 | 
				
			||||||
    rdf (2.2.8)
 | 
					    rdf (2.2.9)
 | 
				
			||||||
      hamster (~> 3.0)
 | 
					      hamster (~> 3.0)
 | 
				
			||||||
      link_header (~> 0.0, >= 0.0.8)
 | 
					      link_header (~> 0.0, >= 0.0.8)
 | 
				
			||||||
    rdf-normalize (0.3.2)
 | 
					    rdf-normalize (0.3.2)
 | 
				
			||||||
| 
						 | 
					@ -396,6 +399,7 @@ GEM
 | 
				
			||||||
      redis-store (>= 1.2, < 2)
 | 
					      redis-store (>= 1.2, < 2)
 | 
				
			||||||
    redis-store (1.3.0)
 | 
					    redis-store (1.3.0)
 | 
				
			||||||
      redis (>= 2.2)
 | 
					      redis (>= 2.2)
 | 
				
			||||||
 | 
					    request_store (1.3.2)
 | 
				
			||||||
    responders (2.4.0)
 | 
					    responders (2.4.0)
 | 
				
			||||||
      actionpack (>= 4.2.0, < 5.3)
 | 
					      actionpack (>= 4.2.0, < 5.3)
 | 
				
			||||||
      railties (>= 4.2.0, < 5.3)
 | 
					      railties (>= 4.2.0, < 5.3)
 | 
				
			||||||
| 
						 | 
					@ -410,7 +414,7 @@ GEM
 | 
				
			||||||
    rspec-mocks (3.6.0)
 | 
					    rspec-mocks (3.6.0)
 | 
				
			||||||
      diff-lcs (>= 1.2.0, < 2.0)
 | 
					      diff-lcs (>= 1.2.0, < 2.0)
 | 
				
			||||||
      rspec-support (~> 3.6.0)
 | 
					      rspec-support (~> 3.6.0)
 | 
				
			||||||
    rspec-rails (3.6.0)
 | 
					    rspec-rails (3.6.1)
 | 
				
			||||||
      actionpack (>= 3.0)
 | 
					      actionpack (>= 3.0)
 | 
				
			||||||
      activesupport (>= 3.0)
 | 
					      activesupport (>= 3.0)
 | 
				
			||||||
      railties (>= 3.0)
 | 
					      railties (>= 3.0)
 | 
				
			||||||
| 
						 | 
					@ -422,15 +426,15 @@ GEM
 | 
				
			||||||
      rspec-core (~> 3.0, >= 3.0.0)
 | 
					      rspec-core (~> 3.0, >= 3.0.0)
 | 
				
			||||||
      sidekiq (>= 2.4.0)
 | 
					      sidekiq (>= 2.4.0)
 | 
				
			||||||
    rspec-support (3.6.0)
 | 
					    rspec-support (3.6.0)
 | 
				
			||||||
    rubocop (0.49.1)
 | 
					    rubocop (0.50.0)
 | 
				
			||||||
      parallel (~> 1.10)
 | 
					      parallel (~> 1.10)
 | 
				
			||||||
      parser (>= 2.3.3.1, < 3.0)
 | 
					      parser (>= 2.3.3.1, < 3.0)
 | 
				
			||||||
      powerpack (~> 0.1)
 | 
					      powerpack (~> 0.1)
 | 
				
			||||||
      rainbow (>= 1.99.1, < 3.0)
 | 
					      rainbow (>= 2.2.2, < 3.0)
 | 
				
			||||||
      ruby-progressbar (~> 1.7)
 | 
					      ruby-progressbar (~> 1.7)
 | 
				
			||||||
      unicode-display_width (~> 1.0, >= 1.0.1)
 | 
					      unicode-display_width (~> 1.0, >= 1.0.1)
 | 
				
			||||||
    ruby-oembed (0.12.0)
 | 
					    ruby-oembed (0.12.0)
 | 
				
			||||||
    ruby-progressbar (1.8.1)
 | 
					    ruby-progressbar (1.8.3)
 | 
				
			||||||
    rufus-scheduler (3.4.2)
 | 
					    rufus-scheduler (3.4.2)
 | 
				
			||||||
      et-orbi (~> 1.0)
 | 
					      et-orbi (~> 1.0)
 | 
				
			||||||
    safe_yaml (1.0.4)
 | 
					    safe_yaml (1.0.4)
 | 
				
			||||||
| 
						 | 
					@ -438,7 +442,7 @@ GEM
 | 
				
			||||||
      crass (~> 1.0.2)
 | 
					      crass (~> 1.0.2)
 | 
				
			||||||
      nokogiri (>= 1.4.4)
 | 
					      nokogiri (>= 1.4.4)
 | 
				
			||||||
      nokogumbo (~> 1.4.1)
 | 
					      nokogumbo (~> 1.4.1)
 | 
				
			||||||
    sass (3.4.24)
 | 
					    sass (3.4.25)
 | 
				
			||||||
    scss_lint (0.54.0)
 | 
					    scss_lint (0.54.0)
 | 
				
			||||||
      rake (>= 0.9, < 13)
 | 
					      rake (>= 0.9, < 13)
 | 
				
			||||||
      sass (~> 3.4.20)
 | 
					      sass (~> 3.4.20)
 | 
				
			||||||
| 
						 | 
					@ -450,12 +454,12 @@ GEM
 | 
				
			||||||
    sidekiq-bulk (0.1.1)
 | 
					    sidekiq-bulk (0.1.1)
 | 
				
			||||||
      activesupport
 | 
					      activesupport
 | 
				
			||||||
      sidekiq
 | 
					      sidekiq
 | 
				
			||||||
    sidekiq-scheduler (2.1.8)
 | 
					    sidekiq-scheduler (2.1.9)
 | 
				
			||||||
      redis (~> 3)
 | 
					      redis (~> 3)
 | 
				
			||||||
      rufus-scheduler (~> 3.2)
 | 
					      rufus-scheduler (~> 3.2)
 | 
				
			||||||
      sidekiq (>= 3)
 | 
					      sidekiq (>= 3)
 | 
				
			||||||
      tilt (>= 1.4.0)
 | 
					      tilt (>= 1.4.0)
 | 
				
			||||||
    sidekiq-unique-jobs (5.0.9)
 | 
					    sidekiq-unique-jobs (5.0.10)
 | 
				
			||||||
      sidekiq (>= 4.0, <= 6.0)
 | 
					      sidekiq (>= 4.0, <= 6.0)
 | 
				
			||||||
      thor (~> 0)
 | 
					      thor (~> 0)
 | 
				
			||||||
    simple-navigation (4.0.5)
 | 
					    simple-navigation (4.0.5)
 | 
				
			||||||
| 
						 | 
					@ -463,23 +467,25 @@ GEM
 | 
				
			||||||
    simple_form (3.5.0)
 | 
					    simple_form (3.5.0)
 | 
				
			||||||
      actionpack (> 4, < 5.2)
 | 
					      actionpack (> 4, < 5.2)
 | 
				
			||||||
      activemodel (> 4, < 5.2)
 | 
					      activemodel (> 4, < 5.2)
 | 
				
			||||||
    simplecov (0.14.1)
 | 
					    simplecov (0.15.1)
 | 
				
			||||||
      docile (~> 1.1.0)
 | 
					      docile (~> 1.1.0)
 | 
				
			||||||
      json (>= 1.8, < 3)
 | 
					      json (>= 1.8, < 3)
 | 
				
			||||||
      simplecov-html (~> 0.10.0)
 | 
					      simplecov-html (~> 0.10.0)
 | 
				
			||||||
    simplecov-html (0.10.1)
 | 
					    simplecov-html (0.10.2)
 | 
				
			||||||
    slop (3.6.0)
 | 
					    slop (3.6.0)
 | 
				
			||||||
    sprockets (3.7.1)
 | 
					    sprockets (3.7.1)
 | 
				
			||||||
      concurrent-ruby (~> 1.0)
 | 
					      concurrent-ruby (~> 1.0)
 | 
				
			||||||
      rack (> 1, < 3)
 | 
					      rack (> 1, < 3)
 | 
				
			||||||
    sprockets-rails (3.2.0)
 | 
					    sprockets-rails (3.2.1)
 | 
				
			||||||
      actionpack (>= 4.0)
 | 
					      actionpack (>= 4.0)
 | 
				
			||||||
      activesupport (>= 4.0)
 | 
					      activesupport (>= 4.0)
 | 
				
			||||||
      sprockets (>= 3.0.0)
 | 
					      sprockets (>= 3.0.0)
 | 
				
			||||||
    sshkit (1.13.1)
 | 
					    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-instrument (2.1.4)
 | 
				
			||||||
 | 
					    strong_migrations (0.1.9)
 | 
				
			||||||
 | 
					      activerecord (>= 3.2.0)
 | 
				
			||||||
    temple (0.8.0)
 | 
					    temple (0.8.0)
 | 
				
			||||||
    terminal-table (1.8.0)
 | 
					    terminal-table (1.8.0)
 | 
				
			||||||
      unicode-display_width (~> 1.1, >= 1.1.1)
 | 
					      unicode-display_width (~> 1.1, >= 1.1.1)
 | 
				
			||||||
| 
						 | 
					@ -506,9 +512,9 @@ GEM
 | 
				
			||||||
      addressable (>= 2.3.6)
 | 
					      addressable (>= 2.3.6)
 | 
				
			||||||
      crack (>= 0.3.2)
 | 
					      crack (>= 0.3.2)
 | 
				
			||||||
      hashdiff
 | 
					      hashdiff
 | 
				
			||||||
    webpacker (2.0)
 | 
					    webpacker (3.0.1)
 | 
				
			||||||
      activesupport (>= 4.2)
 | 
					      activesupport (>= 4.2)
 | 
				
			||||||
      multi_json (~> 1.2)
 | 
					      rack-proxy (>= 0.6.1)
 | 
				
			||||||
      railties (>= 4.2)
 | 
					      railties (>= 4.2)
 | 
				
			||||||
    webpush (0.3.2)
 | 
					    webpush (0.3.2)
 | 
				
			||||||
      hkdf (~> 0.2)
 | 
					      hkdf (~> 0.2)
 | 
				
			||||||
| 
						 | 
					@ -531,17 +537,17 @@ DEPENDENCIES
 | 
				
			||||||
  better_errors (~> 2.1)
 | 
					  better_errors (~> 2.1)
 | 
				
			||||||
  binding_of_caller (~> 0.7)
 | 
					  binding_of_caller (~> 0.7)
 | 
				
			||||||
  bootsnap
 | 
					  bootsnap
 | 
				
			||||||
  brakeman (~> 3.6)
 | 
					  brakeman (~> 4.0)
 | 
				
			||||||
  browser
 | 
					  browser
 | 
				
			||||||
  bullet (~> 5.5)
 | 
					  bullet (~> 5.5)
 | 
				
			||||||
  bundler-audit (~> 0.5)
 | 
					  bundler-audit (~> 0.6)
 | 
				
			||||||
  capistrano (~> 3.8)
 | 
					  capistrano (~> 3.8)
 | 
				
			||||||
  capistrano-rails (~> 1.2)
 | 
					  capistrano-rails (~> 1.2)
 | 
				
			||||||
  capistrano-rbenv (~> 2.1)
 | 
					  capistrano-rbenv (~> 2.1)
 | 
				
			||||||
  capistrano-yarn (~> 2.0)
 | 
					  capistrano-yarn (~> 2.0)
 | 
				
			||||||
  capybara (~> 2.14)
 | 
					  capybara (~> 2.14)
 | 
				
			||||||
  charlock_holmes (~> 0.7.5)
 | 
					  charlock_holmes (~> 0.7.5)
 | 
				
			||||||
  cld3 (~> 3.1)
 | 
					  cld3 (~> 3.2.0)
 | 
				
			||||||
  climate_control (~> 0.2)
 | 
					  climate_control (~> 0.2)
 | 
				
			||||||
  devise (~> 4.2)
 | 
					  devise (~> 4.2)
 | 
				
			||||||
  devise-two-factor (~> 3.0)
 | 
					  devise-two-factor (~> 3.0)
 | 
				
			||||||
| 
						 | 
					@ -582,13 +588,13 @@ DEPENDENCIES
 | 
				
			||||||
  pghero (~> 1.7)
 | 
					  pghero (~> 1.7)
 | 
				
			||||||
  pkg-config (~> 1.2)
 | 
					  pkg-config (~> 1.2)
 | 
				
			||||||
  pry-rails (~> 0.3)
 | 
					  pry-rails (~> 0.3)
 | 
				
			||||||
  puma (~> 3.8)
 | 
					  puma (~> 3.10)
 | 
				
			||||||
  pundit (~> 1.1)
 | 
					  pundit (~> 1.1)
 | 
				
			||||||
  rabl (~> 0.13)
 | 
					  rabl (~> 0.13)
 | 
				
			||||||
  rack-attack (~> 5.0)
 | 
					  rack-attack (~> 5.0)
 | 
				
			||||||
  rack-cors (~> 0.4)
 | 
					  rack-cors (~> 0.4)
 | 
				
			||||||
  rack-timeout (~> 0.4)
 | 
					  rack-timeout (~> 0.4)
 | 
				
			||||||
  rails (~> 5.1.0)
 | 
					  rails (~> 5.1.4)
 | 
				
			||||||
  rails-controller-testing (~> 1.0)
 | 
					  rails-controller-testing (~> 1.0)
 | 
				
			||||||
  rails-i18n (~> 5.0)
 | 
					  rails-i18n (~> 5.0)
 | 
				
			||||||
  rails-settings-cached (~> 0.6)
 | 
					  rails-settings-cached (~> 0.6)
 | 
				
			||||||
| 
						 | 
					@ -612,15 +618,16 @@ DEPENDENCIES
 | 
				
			||||||
  simplecov (~> 0.14)
 | 
					  simplecov (~> 0.14)
 | 
				
			||||||
  sprockets-rails (~> 3.2)
 | 
					  sprockets-rails (~> 3.2)
 | 
				
			||||||
  statsd-instrument (~> 2.1)
 | 
					  statsd-instrument (~> 2.1)
 | 
				
			||||||
 | 
					  strong_migrations
 | 
				
			||||||
  twitter-text (~> 1.14)
 | 
					  twitter-text (~> 1.14)
 | 
				
			||||||
  tzinfo-data (~> 1.2017)
 | 
					  tzinfo-data (~> 1.2017)
 | 
				
			||||||
  uglifier (~> 3.2)
 | 
					  uglifier (~> 3.2)
 | 
				
			||||||
  webmock (~> 3.0)
 | 
					  webmock (~> 3.0)
 | 
				
			||||||
  webpacker (~> 2.0)
 | 
					  webpacker (~> 3.0)
 | 
				
			||||||
  webpush
 | 
					  webpush
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUBY VERSION
 | 
					RUBY VERSION
 | 
				
			||||||
   ruby 2.4.1p111
 | 
					   ruby 2.4.2p198
 | 
				
			||||||
 | 
					
 | 
				
			||||||
BUNDLED WITH
 | 
					BUNDLED WITH
 | 
				
			||||||
   1.15.4
 | 
					   1.15.4
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
web: PORT=3000 bundle exec puma -C config/puma.rb
 | 
					web: PORT=3000 bundle exec puma -C config/puma.rb
 | 
				
			||||||
sidekiq: PORT=3000 bundle exec sidekiq
 | 
					sidekiq: PORT=3000 bundle exec sidekiq
 | 
				
			||||||
stream: PORT=4000 yarn run start
 | 
					stream: PORT=4000 yarn run start
 | 
				
			||||||
webpack: ./bin/webpack-dev-server --host 0.0.0.0
 | 
					webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										34
									
								
								app/controllers/admin/custom_emojis_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
					@ -0,0 +1,34 @@
 | 
				
			||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module Admin
 | 
				
			||||||
 | 
					  class CustomEmojisController < BaseController
 | 
				
			||||||
 | 
					    def index
 | 
				
			||||||
 | 
					      @custom_emojis = CustomEmoji.local
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def new
 | 
				
			||||||
 | 
					      @custom_emoji = CustomEmoji.new
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def create
 | 
				
			||||||
 | 
					      @custom_emoji = CustomEmoji.new(resource_params)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if @custom_emoji.save
 | 
				
			||||||
 | 
					        redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg')
 | 
				
			||||||
 | 
					      else
 | 
				
			||||||
 | 
					        render :new
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def destroy
 | 
				
			||||||
 | 
					      CustomEmoji.find(params[:id]).destroy
 | 
				
			||||||
 | 
					      redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def resource_params
 | 
				
			||||||
 | 
					      params.require(:custom_emoji).permit(:shortcode, :image)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
| 
						 | 
					@ -14,8 +14,12 @@ module Admin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private
 | 
					    private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def filtered_instances
 | 
				
			||||||
 | 
					      InstanceFilter.new(filter_params).results
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def paginated_instances
 | 
					    def paginated_instances
 | 
				
			||||||
      Account.remote.by_domain_accounts.page(params[:page])
 | 
					      filtered_instances.page(params[:page])
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    helper_method :paginated_instances
 | 
					    helper_method :paginated_instances
 | 
				
			||||||
| 
						 | 
					@ -27,5 +31,11 @@ module Admin
 | 
				
			||||||
    def subscribeable_accounts
 | 
					    def subscribeable_accounts
 | 
				
			||||||
      Account.with_followers.remote.where(domain: params[:by_domain])
 | 
					      Account.with_followers.remote.where(domain: params[:by_domain])
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def filter_params
 | 
				
			||||||
 | 
					      params.permit(
 | 
				
			||||||
 | 
					        :domain_name
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,6 +14,7 @@ module Admin
 | 
				
			||||||
      open_deletion
 | 
					      open_deletion
 | 
				
			||||||
      timeline_preview
 | 
					      timeline_preview
 | 
				
			||||||
      bootstrap_timeline_accounts
 | 
					      bootstrap_timeline_accounts
 | 
				
			||||||
 | 
					      thumbnail
 | 
				
			||||||
    ).freeze
 | 
					    ).freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    BOOLEAN_SETTINGS = %w(
 | 
					    BOOLEAN_SETTINGS = %w(
 | 
				
			||||||
| 
						 | 
					@ -22,15 +23,24 @@ module Admin
 | 
				
			||||||
      timeline_preview
 | 
					      timeline_preview
 | 
				
			||||||
    ).freeze
 | 
					    ).freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    UPLOAD_SETTINGS = %w(
 | 
				
			||||||
 | 
					      thumbnail
 | 
				
			||||||
 | 
					    ).freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def edit
 | 
					    def edit
 | 
				
			||||||
      @admin_settings = Form::AdminSettings.new
 | 
					      @admin_settings = Form::AdminSettings.new
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def update
 | 
					    def update
 | 
				
			||||||
      settings_params.each do |key, value|
 | 
					      settings_params.each do |key, value|
 | 
				
			||||||
 | 
					        if UPLOAD_SETTINGS.include?(key)
 | 
				
			||||||
 | 
					          upload = SiteUpload.where(var: key).first_or_initialize(var: key)
 | 
				
			||||||
 | 
					          upload.update(file: value)
 | 
				
			||||||
 | 
					        else
 | 
				
			||||||
          setting = Setting.where(var: key).first_or_initialize(var: key)
 | 
					          setting = Setting.where(var: key).first_or_initialize(var: key)
 | 
				
			||||||
          setting.update(value: value_for_update(key, value))
 | 
					          setting.update(value: value_for_update(key, value))
 | 
				
			||||||
        end
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      flash[:notice] = I18n.t('generic.changes_saved_msg')
 | 
					      flash[:notice] = I18n.t('generic.changes_saved_msg')
 | 
				
			||||||
      redirect_to edit_admin_settings_path
 | 
					      redirect_to edit_admin_settings_path
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										9
									
								
								app/controllers/api/v1/custom_emojis_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
					@ -0,0 +1,9 @@
 | 
				
			||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Api::V1::CustomEmojisController < Api::BaseController
 | 
				
			||||||
 | 
					  respond_to :json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def index
 | 
				
			||||||
 | 
					    render json: CustomEmoji.local, each_serializer: REST::CustomEmojiSerializer
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
| 
						 | 
					@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  helper_method :current_account
 | 
					  helper_method :current_account
 | 
				
			||||||
  helper_method :current_session
 | 
					  helper_method :current_session
 | 
				
			||||||
 | 
					  helper_method :current_theme
 | 
				
			||||||
  helper_method :single_user_mode?
 | 
					  helper_method :single_user_mode?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  rescue_from ActionController::RoutingError, with: :not_found
 | 
					  rescue_from ActionController::RoutingError, with: :not_found
 | 
				
			||||||
| 
						 | 
					@ -77,6 +78,11 @@ class ApplicationController < ActionController::Base
 | 
				
			||||||
    @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id'])
 | 
					    @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id'])
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def current_theme
 | 
				
			||||||
 | 
					    return Setting.default_settings['theme'] unless Themes.instance.names.include? current_user&.setting_theme
 | 
				
			||||||
 | 
					    current_user.setting_theme
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def cache_collection(raw, klass)
 | 
					  def cache_collection(raw, klass)
 | 
				
			||||||
    return raw unless klass.respond_to?(:with_includes)
 | 
					    return raw unless klass.respond_to?(:with_includes)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,12 +17,29 @@ class FollowerAccountsController < ApplicationController
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def page_url(page)
 | 
				
			||||||
 | 
					    account_followers_url(@account, page: page) unless page.nil?
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def collection_presenter
 | 
					  def collection_presenter
 | 
				
			||||||
 | 
					    page = ActivityPub::CollectionPresenter.new(
 | 
				
			||||||
 | 
					      id: account_followers_url(@account, page: params.fetch(:page, 1)),
 | 
				
			||||||
 | 
					      type: :ordered,
 | 
				
			||||||
 | 
					      size: @account.followers_count,
 | 
				
			||||||
 | 
					      items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) },
 | 
				
			||||||
 | 
					      part_of: account_followers_url(@account),
 | 
				
			||||||
 | 
					      next: page_url(@follows.next_page),
 | 
				
			||||||
 | 
					      prev: page_url(@follows.prev_page)
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    if params[:page].present?
 | 
				
			||||||
 | 
					      page
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
      ActivityPub::CollectionPresenter.new(
 | 
					      ActivityPub::CollectionPresenter.new(
 | 
				
			||||||
        id: account_followers_url(@account),
 | 
					        id: account_followers_url(@account),
 | 
				
			||||||
        type: :ordered,
 | 
					        type: :ordered,
 | 
				
			||||||
        size: @account.followers_count,
 | 
					        size: @account.followers_count,
 | 
				
			||||||
      items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }
 | 
					        first: page
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,12 +17,29 @@ class FollowingAccountsController < ApplicationController
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def page_url(page)
 | 
				
			||||||
 | 
					    account_following_index_url(@account, page: page) unless page.nil?
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def collection_presenter
 | 
					  def collection_presenter
 | 
				
			||||||
 | 
					    page = ActivityPub::CollectionPresenter.new(
 | 
				
			||||||
 | 
					      id: account_following_index_url(@account, page: params.fetch(:page, 1)),
 | 
				
			||||||
 | 
					      type: :ordered,
 | 
				
			||||||
 | 
					      size: @account.following_count,
 | 
				
			||||||
 | 
					      items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) },
 | 
				
			||||||
 | 
					      part_of: account_following_index_url(@account),
 | 
				
			||||||
 | 
					      next: page_url(@follows.next_page),
 | 
				
			||||||
 | 
					      prev: page_url(@follows.prev_page)
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    if params[:page].present?
 | 
				
			||||||
 | 
					      page
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
      ActivityPub::CollectionPresenter.new(
 | 
					      ActivityPub::CollectionPresenter.new(
 | 
				
			||||||
        id: account_following_index_url(@account),
 | 
					        id: account_following_index_url(@account),
 | 
				
			||||||
        type: :ordered,
 | 
					        type: :ordered,
 | 
				
			||||||
        size: @account.following_count,
 | 
					        size: @account.following_count,
 | 
				
			||||||
      items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }
 | 
					        first: page
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,7 +12,30 @@ class HomeController < ApplicationController
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def authenticate_user!
 | 
					  def authenticate_user!
 | 
				
			||||||
    redirect_to(single_user_mode? ? account_path(Account.first) : about_path) unless user_signed_in?
 | 
					    return if user_signed_in?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if matches
 | 
				
			||||||
 | 
					      case matches[1]
 | 
				
			||||||
 | 
					      when 'statuses'
 | 
				
			||||||
 | 
					        status = Status.find_by(id: matches[2])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if status && (status.public_visibility? || status.unlisted_visibility?)
 | 
				
			||||||
 | 
					          redirect_to(ActivityPub::TagManager.instance.url_for(status))
 | 
				
			||||||
 | 
					          return
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      when 'accounts'
 | 
				
			||||||
 | 
					        account = Account.find_by(id: matches[2])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if account
 | 
				
			||||||
 | 
					          redirect_to(ActivityPub::TagManager.instance.url_for(account))
 | 
				
			||||||
 | 
					          return
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    redirect_to(default_redirect_path)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def set_initial_state_json
 | 
					  def set_initial_state_json
 | 
				
			||||||
| 
						 | 
					@ -29,4 +52,14 @@ class HomeController < ApplicationController
 | 
				
			||||||
      admin: Account.find_local(Setting.site_contact_username),
 | 
					      admin: Account.find_local(Setting.site_contact_username),
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def default_redirect_path
 | 
				
			||||||
 | 
					    if request.path.start_with?('/web')
 | 
				
			||||||
 | 
					      new_user_session_path
 | 
				
			||||||
 | 
					    elsif single_user_mode?
 | 
				
			||||||
 | 
					      short_account_path(Account.first)
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
 | 
					      about_path
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										40
									
								
								app/controllers/media_proxy_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
					@ -0,0 +1,40 @@
 | 
				
			||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class MediaProxyController < ApplicationController
 | 
				
			||||||
 | 
					  include RoutingHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def show
 | 
				
			||||||
 | 
					    RedisLock.acquire(lock_options) do |lock|
 | 
				
			||||||
 | 
					      if lock.acquired?
 | 
				
			||||||
 | 
					        @media_attachment = MediaAttachment.remote.find(params[:id])
 | 
				
			||||||
 | 
					        redownload! if @media_attachment.needs_redownload? && !reject_media?
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    redirect_to full_asset_url(@media_attachment.file.url(version))
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def redownload!
 | 
				
			||||||
 | 
					    @media_attachment.file_remote_url = @media_attachment.remote_url
 | 
				
			||||||
 | 
					    @media_attachment.created_at      = Time.now.utc
 | 
				
			||||||
 | 
					    @media_attachment.save!
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def version
 | 
				
			||||||
 | 
					    if request.path.ends_with?('/small')
 | 
				
			||||||
 | 
					      :small
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
 | 
					      :original
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def lock_options
 | 
				
			||||||
 | 
					    { redis: Redis.current, key: "media_download:#{params[:id]}" }
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def reject_media?
 | 
				
			||||||
 | 
					    DomainBlock.find_by(domain: @media_attachment.account.domain)&.reject_media?
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
| 
						 | 
					@ -41,6 +41,7 @@ class Settings::PreferencesController < ApplicationController
 | 
				
			||||||
      :setting_auto_play_gif,
 | 
					      :setting_auto_play_gif,
 | 
				
			||||||
      :setting_system_font_ui,
 | 
					      :setting_system_font_ui,
 | 
				
			||||||
      :setting_noindex,
 | 
					      :setting_noindex,
 | 
				
			||||||
 | 
					      :setting_theme,
 | 
				
			||||||
      notification_emails: %i(follow follow_request reblog favourite mention digest),
 | 
					      notification_emails: %i(follow follow_request reblog favourite mention digest),
 | 
				
			||||||
      interactions: %i(must_be_follower must_be_following)
 | 
					      interactions: %i(must_be_follower must_be_following)
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -42,4 +42,8 @@ module ApplicationHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
 | 
					    content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def opengraph(property, content)
 | 
				
			||||||
 | 
					    tag(:meta, content: content, property: property)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,24 +0,0 @@
 | 
				
			||||||
# frozen_string_literal: true
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module EmojiHelper
 | 
					 | 
				
			||||||
  def emojify(text)
 | 
					 | 
				
			||||||
    return text if text.blank?
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    text.gsub(emoji_pattern) do |match|
 | 
					 | 
				
			||||||
      emoji = Emoji.instance.unicode($1) # rubocop:disable Style/PerlBackrefs
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if emoji
 | 
					 | 
				
			||||||
        emoji
 | 
					 | 
				
			||||||
      else
 | 
					 | 
				
			||||||
        match
 | 
					 | 
				
			||||||
      end
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def emoji_pattern
 | 
					 | 
				
			||||||
    @emoji_pattern ||=
 | 
					 | 
				
			||||||
      /(?<=[^[:alnum:]:]|\n|^)
 | 
					 | 
				
			||||||
      (#{Emoji.instance.names.map { |name| Regexp.escape(name) }.join('|')})
 | 
					 | 
				
			||||||
      (?=[^[:alnum:]:]|$)/x
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
end
 | 
					 | 
				
			||||||
| 
						 | 
					@ -41,7 +41,7 @@ module SettingsHelper
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def filterable_languages
 | 
					  def filterable_languages
 | 
				
			||||||
    I18n.available_locales.map { |locale| locale.to_s.split('-').first.to_sym }.uniq
 | 
					    LanguageDetector.instance.language_names.select(&HUMAN_LOCALES.method(:key?))
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def hash_to_object(hash)
 | 
					  def hash_to_object(hash)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -44,7 +44,6 @@ Imports:
 | 
				
			||||||
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 PropTypes from 'prop-types';
 | 
				
			||||||
import escapeTextContentForBrowser from 'escape-html';
 | 
					 | 
				
			||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
					import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -89,7 +88,7 @@ export default class AccountHeader extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    account  : ImmutablePropTypes.map,
 | 
					    account  : ImmutablePropTypes.map,
 | 
				
			||||||
    me       : PropTypes.number.isRequired,
 | 
					    me       : PropTypes.string.isRequired,
 | 
				
			||||||
    onFollow : PropTypes.func.isRequired,
 | 
					    onFollow : PropTypes.func.isRequired,
 | 
				
			||||||
    intl     : PropTypes.object.isRequired,
 | 
					    intl     : PropTypes.object.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
| 
						 | 
					@ -117,7 +116,7 @@ then we set the `displayName` to just be the `username` of the account.
 | 
				
			||||||
      return null;
 | 
					      return null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let displayName = account.get('display_name');
 | 
					    let displayName = account.get('display_name_html');
 | 
				
			||||||
    let info        = '';
 | 
					    let info        = '';
 | 
				
			||||||
    let actionBtn   = '';
 | 
					    let actionBtn   = '';
 | 
				
			||||||
    let following   = false;
 | 
					    let following   = false;
 | 
				
			||||||
| 
						 | 
					@ -167,16 +166,11 @@ appropriate icon.
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
 | 
					 we extract the `text` and
 | 
				
			||||||
`displayNameHTML` processes the `displayName` and prepares it for
 | 
					 | 
				
			||||||
insertion into the document. Meanwhile, we extract the `text` and
 | 
					 | 
				
			||||||
`metadata` from our account's `note` using `processBio()`.
 | 
					`metadata` from our account's `note` using `processBio()`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const displayNameHTML    = {
 | 
					 | 
				
			||||||
      __html : emojify(escapeTextContentForBrowser(displayName)),
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    const { text, metadata } = processBio(account.get('note'));
 | 
					    const { text, metadata } = processBio(account.get('note'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
| 
						 | 
					@ -198,7 +192,7 @@ Here, we render our component using all the things we've defined above.
 | 
				
			||||||
              </span>
 | 
					              </span>
 | 
				
			||||||
              <span
 | 
					              <span
 | 
				
			||||||
                className='account__header__display-name'
 | 
					                className='account__header__display-name'
 | 
				
			||||||
                dangerouslySetInnerHTML={displayNameHTML}
 | 
					                dangerouslySetInnerHTML={{ __html: displayName }}
 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
            </a>
 | 
					            </a>
 | 
				
			||||||
            <span className='account__header__username'>
 | 
					            <span className='account__header__username'>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
@import 'variables';
 | 
					@import 'styles/variables';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.glitch.local-settings__navigation__item {
 | 
					.glitch.local-settings__navigation__item {
 | 
				
			||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
@import 'variables';
 | 
					@import 'styles/variables';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.glitch.local-settings__navigation {
 | 
					.glitch.local-settings__navigation {
 | 
				
			||||||
  background: $primary-text-color;
 | 
					  background: $primary-text-color;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
@import 'variables';
 | 
					@import 'styles/variables';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.glitch.local-settings__page__item {
 | 
					.glitch.local-settings__page__item {
 | 
				
			||||||
  select {
 | 
					  select {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
@import 'variables';
 | 
					@import 'styles/variables';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.glitch.local-settings__page {
 | 
					.glitch.local-settings__page {
 | 
				
			||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
@import 'variables';
 | 
					@import 'styles/variables';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.glitch.local-settings {
 | 
					.glitch.local-settings {
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,11 +11,9 @@ 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 { FormattedMessage } from 'react-intl';
 | 
					import { FormattedMessage } from 'react-intl';
 | 
				
			||||||
import escapeTextContentForBrowser from 'escape-html';
 | 
					 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
//  Mastodon imports.
 | 
					//  Mastodon imports.
 | 
				
			||||||
import emojify from '../../../mastodon/emoji';
 | 
					 | 
				
			||||||
import Permalink from '../../../mastodon/components/permalink';
 | 
					import Permalink from '../../../mastodon/components/permalink';
 | 
				
			||||||
import AccountContainer from '../../../mastodon/containers/account_container';
 | 
					import AccountContainer from '../../../mastodon/containers/account_container';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -30,7 +28,7 @@ import NotificationOverlayContainer from '../notification/overlay/container';
 | 
				
			||||||
export default class NotificationFollow extends ImmutablePureComponent {
 | 
					export default class NotificationFollow extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    id                   : PropTypes.number.isRequired,
 | 
					    id                   : PropTypes.string.isRequired,
 | 
				
			||||||
    account              : ImmutablePropTypes.map.isRequired,
 | 
					    account              : ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
    notification         : ImmutablePropTypes.map.isRequired,
 | 
					    notification         : ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
| 
						 | 
					@ -39,15 +37,14 @@ export default class NotificationFollow extends ImmutablePureComponent {
 | 
				
			||||||
    const { account, notification } = this.props;
 | 
					    const { account, notification } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    //  Links to the display name.
 | 
					    //  Links to the display name.
 | 
				
			||||||
    const displayName = account.get('display_name') || account.get('username');
 | 
					    const displayName = account.get('display_name_html') || account.get('username');
 | 
				
			||||||
    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
 | 
					 | 
				
			||||||
    const link = (
 | 
					    const link = (
 | 
				
			||||||
      <Permalink
 | 
					      <Permalink
 | 
				
			||||||
        className='notification__display-name'
 | 
					        className='notification__display-name'
 | 
				
			||||||
        href={account.get('url')}
 | 
					        href={account.get('url')}
 | 
				
			||||||
        title={account.get('acct')}
 | 
					        title={account.get('acct')}
 | 
				
			||||||
        to={`/accounts/${account.get('id')}`}
 | 
					        to={`/accounts/${account.get('id')}`}
 | 
				
			||||||
        dangerouslySetInnerHTML={displayNameHTML}
 | 
					        dangerouslySetInnerHTML={{ __html: displayName }}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -50,7 +50,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
 | 
				
			||||||
    onEmbed: PropTypes.func,
 | 
					    onEmbed: PropTypes.func,
 | 
				
			||||||
    onMuteConversation: PropTypes.func,
 | 
					    onMuteConversation: PropTypes.func,
 | 
				
			||||||
    onPin: PropTypes.func,
 | 
					    onPin: PropTypes.func,
 | 
				
			||||||
    me: PropTypes.number,
 | 
					    me: PropTypes.string,
 | 
				
			||||||
    withDismiss: PropTypes.bool,
 | 
					    withDismiss: PropTypes.bool,
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,13 +1,11 @@
 | 
				
			||||||
//  Package imports  //
 | 
					//  Package imports  //
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
import escapeTextContentForBrowser from 'escape-html';
 | 
					 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import { FormattedMessage } from 'react-intl';
 | 
					import { FormattedMessage } from 'react-intl';
 | 
				
			||||||
import classnames from 'classnames';
 | 
					import classnames from 'classnames';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
//  Mastodon imports  //
 | 
					//  Mastodon imports  //
 | 
				
			||||||
import emojify from '../../../mastodon/emoji';
 | 
					 | 
				
			||||||
import { isRtl } from '../../../mastodon/rtl';
 | 
					import { isRtl } from '../../../mastodon/rtl';
 | 
				
			||||||
import Permalink from '../../../mastodon/components/permalink';
 | 
					import Permalink from '../../../mastodon/components/permalink';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -32,7 +30,7 @@ export default class StatusContent extends React.PureComponent {
 | 
				
			||||||
    const node  = this.node;
 | 
					    const node  = this.node;
 | 
				
			||||||
    const links = node.querySelectorAll('a');
 | 
					    const links = node.querySelectorAll('a');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (var i = 0; i < links.length; ++i) {
 | 
					    for (let i = 0; i < links.length; ++i) {
 | 
				
			||||||
      let link    = links[i];
 | 
					      let link    = links[i];
 | 
				
			||||||
      let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
 | 
					      let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -131,12 +129,8 @@ export default class StatusContent extends React.PureComponent {
 | 
				
			||||||
      this.state.hidden
 | 
					      this.state.hidden
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const content = { __html: emojify(status.get('content')) };
 | 
					    const content = { __html: status.get('contentHtml') };
 | 
				
			||||||
    const spoilerContent = {
 | 
					    const spoilerContent = { __html: status.get('spoilerHtml') };
 | 
				
			||||||
      __html: emojify(escapeTextContentForBrowser(
 | 
					 | 
				
			||||||
        status.get('spoiler_text', '')
 | 
					 | 
				
			||||||
      )),
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    const directionStyle = { direction: 'ltr' };
 | 
					    const directionStyle = { direction: 'ltr' };
 | 
				
			||||||
    const classNames = classnames('status__content', {
 | 
					    const classNames = classnames('status__content', {
 | 
				
			||||||
      'status__content--with-action': parseClick && !disabled,
 | 
					      'status__content--with-action': parseClick && !disabled,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -155,12 +155,12 @@ export default class Status extends ImmutablePureComponent {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    id                          : PropTypes.number,
 | 
					    id                          : PropTypes.string,
 | 
				
			||||||
    status                      : ImmutablePropTypes.map,
 | 
					    status                      : ImmutablePropTypes.map,
 | 
				
			||||||
    account                     : ImmutablePropTypes.map,
 | 
					    account                     : ImmutablePropTypes.map,
 | 
				
			||||||
    settings                    : ImmutablePropTypes.map,
 | 
					    settings                    : ImmutablePropTypes.map,
 | 
				
			||||||
    notification                : ImmutablePropTypes.map,
 | 
					    notification                : ImmutablePropTypes.map,
 | 
				
			||||||
    me                          : PropTypes.number,
 | 
					    me                          : PropTypes.string,
 | 
				
			||||||
    onFavourite                 : PropTypes.func,
 | 
					    onFavourite                 : PropTypes.func,
 | 
				
			||||||
    onReblog                    : PropTypes.func,
 | 
					    onReblog                    : PropTypes.func,
 | 
				
			||||||
    onModalReblog               : PropTypes.func,
 | 
					    onModalReblog               : PropTypes.func,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -22,12 +22,8 @@ Imports:
 | 
				
			||||||
import React from 'react';
 | 
					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 escapeTextContentForBrowser from 'escape-html';
 | 
					 | 
				
			||||||
import { FormattedMessage } from 'react-intl';
 | 
					import { FormattedMessage } from 'react-intl';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
//  Mastodon imports  //
 | 
					 | 
				
			||||||
import emojify from '../../../mastodon/emoji';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                            /* * * * */
 | 
					                            /* * * * */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
| 
						 | 
					@ -99,9 +95,7 @@ generate the message.
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <b
 | 
					        <b
 | 
				
			||||||
          dangerouslySetInnerHTML={{
 | 
					          dangerouslySetInnerHTML={{
 | 
				
			||||||
            __html : emojify(escapeTextContentForBrowser(
 | 
					            __html : account.get('display_name_html') || account.get('username'),
 | 
				
			||||||
              account.get('display_name') || account.get('username')
 | 
					 | 
				
			||||||
            )),
 | 
					 | 
				
			||||||
          }}
 | 
					          }}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </a>
 | 
					      </a>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1 +1 @@
 | 
				
			||||||
<svg xmlns="http://www.w3.org/2000/svg" width="61.076954mm" height="65.47831mm" viewBox="0 0 216.4144 232.00976"><path d="M211.80734 139.0875c-3.18125 16.36625-28.4925 34.2775-57.5625 37.74875-15.15875 1.80875-30.08375 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.39125 27.9425 21.11625.7225 39.91875-5.20625 39.91875-5.20625l.8675 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23234 213.82 1.40609 165.31125.20859 116.09125c-.365-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67234 3.45375 78.20359.2425 107.86484 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.975 14.7525 32.975 65.0825 0 0 .41375 37.13375-4.59875 62.915" fill="#3088d4"/><path d="M177.50984 80.077v60.94125h-24.14375v-59.15c0-12.46875-5.24625-18.7975-15.74-18.7975-11.6025 0-17.4175 7.5075-17.4175 22.3525v32.37625H96.20734V85.42325c0-14.845-5.81625-22.3525-17.41875-22.3525-10.49375 0-15.74 6.32875-15.74 18.7975v59.15H38.90484V80.077c0-12.455 3.17125-22.3525 9.54125-29.675 6.56875-7.3225 15.17125-11.07625 25.85-11.07625 12.355 0 21.71125 4.74875 27.8975 14.2475l6.01375 10.08125 6.015-10.08125c6.185-9.49875 15.54125-14.2475 27.8975-14.2475 10.6775 0 19.28 3.75375 25.85 11.07625 6.36875 7.3225 9.54 17.22 9.54 29.675" fill="#fff"/></svg>
 | 
					<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976"><path d="M211.80734 139.0875c-3.18125 16.36625-28.4925 34.2775-57.5625 37.74875-15.15875 1.80875-30.08375 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.39125 27.9425 21.11625.7225 39.91875-5.20625 39.91875-5.20625l.8675 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23234 213.82 1.40609 165.31125.20859 116.09125c-.365-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67234 3.45375 78.20359.2425 107.86484 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.975 14.7525 32.975 65.0825 0 0 .41375 37.13375-4.59875 62.915" fill="#3088d4"/><path d="M177.50984 80.077v60.94125h-24.14375v-59.15c0-12.46875-5.24625-18.7975-15.74-18.7975-11.6025 0-17.4175 7.5075-17.4175 22.3525v32.37625H96.20734V85.42325c0-14.845-5.81625-22.3525-17.41875-22.3525-10.49375 0-15.74 6.32875-15.74 18.7975v59.15H38.90484V80.077c0-12.455 3.17125-22.3525 9.54125-29.675 6.56875-7.3225 15.17125-11.07625 25.85-11.07625 12.355 0 21.71125 4.74875 27.8975 14.2475l6.01375 10.08125 6.015-10.08125c6.185-9.49875 15.54125-14.2475 27.8975-14.2475 10.6775 0 19.28 3.75375 25.85 11.07625 6.36875 7.3225 9.54 17.22 9.54 29.675" fill="#fff"/></svg>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
		 Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB  | 
| 
						 | 
					@ -1 +1 @@
 | 
				
			||||||
<svg xmlns="http://www.w3.org/2000/svg" width="61.077141mm" height="65.47831mm" viewBox="0 0 216.41507 232.00976"><path d="M211.80683 139.0875c-3.1825 16.36625-28.4925 34.2775-57.5625 37.74875-15.16 1.80875-30.0825 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.3925 27.9425 21.115.7225 39.91625-5.20625 39.91625-5.20625l.86875 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23183 213.82 1.40558 165.31125.20808 116.09125c-.36375-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67058 3.45375 78.20308.2425 107.86433 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.97625 14.7525 32.97625 65.0825 0 0 .4125 37.13375-4.6 62.915" fill="#3088d4"/><path d="M65.68743 96.45938c0 9.01375-7.3075 16.32125-16.3225 16.32125-9.01375 0-16.32-7.3075-16.32-16.32125 0-9.01375 7.30625-16.3225 16.32-16.3225 9.015 0 16.3225 7.30875 16.3225 16.3225M124.52893 96.45938c0 9.01375-7.30875 16.32125-16.3225 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.3225 7.30875 16.3225 16.3225M183.36933 96.45938c0 9.01375-7.3075 16.32125-16.32125 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.32125 7.30875 16.32125 16.3225" fill="#fff"/></svg>
 | 
					<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.41507 232.00976"><path d="M211.80683 139.0875c-3.1825 16.36625-28.4925 34.2775-57.5625 37.74875-15.16 1.80875-30.0825 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.3925 27.9425 21.115.7225 39.91625-5.20625 39.91625-5.20625l.86875 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23183 213.82 1.40558 165.31125.20808 116.09125c-.36375-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67058 3.45375 78.20308.2425 107.86433 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.97625 14.7525 32.97625 65.0825 0 0 .4125 37.13375-4.6 62.915" fill="#3088d4"/><path d="M65.68743 96.45938c0 9.01375-7.3075 16.32125-16.3225 16.32125-9.01375 0-16.32-7.3075-16.32-16.32125 0-9.01375 7.30625-16.3225 16.32-16.3225 9.015 0 16.3225 7.30875 16.3225 16.3225M124.52893 96.45938c0 9.01375-7.30875 16.32125-16.3225 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.3225 7.30875 16.3225 16.3225M183.36933 96.45938c0 9.01375-7.3075 16.32125-16.32125 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.32125 7.30875 16.32125 16.3225" fill="#fff"/></svg>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
		 Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB  | 
| 
		 Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.5 KiB  | 
| 
		 Before Width: | Height: | Size: 25 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								app/javascript/images/preview.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 285 KiB  | 
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
import api from '../api';
 | 
					import api from '../api';
 | 
				
			||||||
import emojione from 'emojione';
 | 
					import { emojiIndex } from 'emoji-mart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  updateTimeline,
 | 
					  updateTimeline,
 | 
				
			||||||
| 
						 | 
					@ -23,7 +23,6 @@ export const COMPOSE_UPLOAD_UNDO     = 'COMPOSE_UPLOAD_UNDO';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
 | 
					export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
 | 
				
			||||||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
 | 
					export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
 | 
				
			||||||
export const COMPOSE_SUGGESTIONS_READY_TXT = 'COMPOSE_SUGGESTIONS_READY_TXT';
 | 
					 | 
				
			||||||
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
 | 
					export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT';
 | 
					export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT';
 | 
				
			||||||
| 
						 | 
					@ -213,59 +212,35 @@ export function clearComposeSuggestions() {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
let allShortcodes = null; // cached list of all shortcodes for suggestions
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function fetchComposeSuggestions(token) {
 | 
					export function fetchComposeSuggestions(token) {
 | 
				
			||||||
  let leading = token[0];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (leading === '@') {
 | 
					 | 
				
			||||||
    // handle search
 | 
					 | 
				
			||||||
  return (dispatch, getState) => {
 | 
					  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), // remove the '@'
 | 
					        q: token.slice(1),
 | 
				
			||||||
        resolve: false,
 | 
					        resolve: false,
 | 
				
			||||||
        limit: 4,
 | 
					        limit: 4,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    }).then(response => {
 | 
					    }).then(response => {
 | 
				
			||||||
        dispatch(readyComposeSuggestions(token, response.data));
 | 
					      dispatch(readyComposeSuggestionsAccounts(token, response.data));
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
  } else if (leading === ':') {
 | 
					 | 
				
			||||||
    // shortcode
 | 
					 | 
				
			||||||
    if (!allShortcodes) {
 | 
					 | 
				
			||||||
      allShortcodes = Object.keys(emojione.emojioneList);
 | 
					 | 
				
			||||||
      // TODO when we have custom emojons merged, add them to this shortcode list
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return (dispatch) => {
 | 
					 | 
				
			||||||
      const innertxt = token.slice(1);
 | 
					 | 
				
			||||||
      if (innertxt.length > 1) { // prevent searching single letter, causes lag
 | 
					 | 
				
			||||||
        dispatch(readyComposeSuggestionsTxt(token, allShortcodes.filter((sc) => {
 | 
					 | 
				
			||||||
          return sc.indexOf(innertxt) !== -1;
 | 
					 | 
				
			||||||
        }).sort((a, b) => {
 | 
					 | 
				
			||||||
          if (a.indexOf(token) === 0 && b.indexOf(token) === 0) return a.localeCompare(b);
 | 
					 | 
				
			||||||
          if (a.indexOf(token) === 0) return -1;
 | 
					 | 
				
			||||||
          if (b.indexOf(token) === 0) return 1;
 | 
					 | 
				
			||||||
          return a.localeCompare(b);
 | 
					 | 
				
			||||||
        })));
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  } else {
 | 
					 | 
				
			||||||
    // hashtag
 | 
					 | 
				
			||||||
    return (dispatch, getState) => {
 | 
					 | 
				
			||||||
      api(getState).get('/api/v1/search', {
 | 
					 | 
				
			||||||
        params: {
 | 
					 | 
				
			||||||
          q: token,
 | 
					 | 
				
			||||||
          resolve: true,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      }).then(response => {
 | 
					 | 
				
			||||||
        dispatch(readyComposeSuggestionsTxt(token, response.data.hashtags.map((ht) => `#${ht}`)));
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function readyComposeSuggestions(token, accounts) {
 | 
					export function readyComposeSuggestionsEmojis(token, emojis) {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    type: COMPOSE_SUGGESTIONS_READY,
 | 
				
			||||||
 | 
					    token,
 | 
				
			||||||
 | 
					    emojis,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function readyComposeSuggestionsAccounts(token, accounts) {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    type: COMPOSE_SUGGESTIONS_READY,
 | 
					    type: COMPOSE_SUGGESTIONS_READY,
 | 
				
			||||||
    token,
 | 
					    token,
 | 
				
			||||||
| 
						 | 
					@ -273,23 +248,21 @@ export function readyComposeSuggestions(token, accounts) {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function readyComposeSuggestionsTxt(token, items) {
 | 
					export function selectComposeSuggestion(position, token, suggestion) {
 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    type: COMPOSE_SUGGESTIONS_READY_TXT,
 | 
					 | 
				
			||||||
    token,
 | 
					 | 
				
			||||||
    items,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function selectComposeSuggestion(position, token, accountId) {
 | 
					 | 
				
			||||||
  return (dispatch, getState) => {
 | 
					  return (dispatch, getState) => {
 | 
				
			||||||
    const completion = (typeof accountId === 'string') ?
 | 
					    let completion, startPosition;
 | 
				
			||||||
      accountId.slice(1) : // text suggestion: discard the leading : or # - the replacing code replaces only what follows
 | 
					
 | 
				
			||||||
      getState().getIn(['accounts', accountId, 'acct']);
 | 
					    if (typeof suggestion === 'object' && suggestion.id) {
 | 
				
			||||||
 | 
					      completion    = suggestion.native || suggestion.colons;
 | 
				
			||||||
 | 
					      startPosition = position - 1;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      completion    = getState().getIn(['accounts', suggestion, 'acct']);
 | 
				
			||||||
 | 
					      startPosition = position;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    dispatch({
 | 
					    dispatch({
 | 
				
			||||||
      type: COMPOSE_SUGGESTION_SELECT,
 | 
					      type: COMPOSE_SUGGESTION_SELECT,
 | 
				
			||||||
      position,
 | 
					      position: startPosition,
 | 
				
			||||||
      token,
 | 
					      token,
 | 
				
			||||||
      completion,
 | 
					      completion,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										17
									
								
								app/javascript/mastodon/actions/height_cache.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
					@ -0,0 +1,17 @@
 | 
				
			||||||
 | 
					export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET';
 | 
				
			||||||
 | 
					export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function setHeight (key, id, height) {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    type: HEIGHT_CACHE_SET,
 | 
				
			||||||
 | 
					    key,
 | 
				
			||||||
 | 
					    id,
 | 
				
			||||||
 | 
					    height,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function clearHeight () {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    type: HEIGHT_CACHE_CLEAR,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -23,9 +23,6 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
 | 
				
			||||||
export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
 | 
					export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
 | 
				
			||||||
export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL';
 | 
					export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const STATUS_SET_HEIGHT = 'STATUS_SET_HEIGHT';
 | 
					 | 
				
			||||||
export const STATUSES_CLEAR_HEIGHT = 'STATUSES_CLEAR_HEIGHT';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function fetchStatusRequest(id, skipLoading) {
 | 
					export function fetchStatusRequest(id, skipLoading) {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    type: STATUS_FETCH_REQUEST,
 | 
					    type: STATUS_FETCH_REQUEST,
 | 
				
			||||||
| 
						 | 
					@ -218,17 +215,3 @@ export function unmuteStatusFail(id, error) {
 | 
				
			||||||
    error,
 | 
					    error,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					 | 
				
			||||||
export function setStatusHeight (id, height) {
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    type: STATUS_SET_HEIGHT,
 | 
					 | 
				
			||||||
    id,
 | 
					 | 
				
			||||||
    height,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function clearStatusesHeight () {
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    type: STATUSES_CLEAR_HEIGHT,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,8 +5,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const convertState = rawState =>
 | 
					const convertState = rawState =>
 | 
				
			||||||
  fromJS(rawState, (k, v) =>
 | 
					  fromJS(rawState, (k, v) =>
 | 
				
			||||||
    Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
 | 
					    Iterable.isIndexed(v) ? v.toList() : v.toMap());
 | 
				
			||||||
      Number.isNaN(x * 1) ? x : x * 1));
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function hydrateStore(rawState) {
 | 
					export function hydrateStore(rawState) {
 | 
				
			||||||
  const state = convertState(rawState);
 | 
					  const state = convertState(rawState);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -23,7 +23,7 @@ export default class Account extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    account: ImmutablePropTypes.map.isRequired,
 | 
					    account: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
    me: PropTypes.number.isRequired,
 | 
					    me: PropTypes.string.isRequired,
 | 
				
			||||||
    onFollow: PropTypes.func.isRequired,
 | 
					    onFollow: PropTypes.func.isRequired,
 | 
				
			||||||
    onBlock: PropTypes.func.isRequired,
 | 
					    onBlock: PropTypes.func.isRequired,
 | 
				
			||||||
    onMute: PropTypes.func.isRequired,
 | 
					    onMute: PropTypes.func.isRequired,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										37
									
								
								app/javascript/mastodon/components/autosuggest_emoji.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
					@ -0,0 +1,37 @@
 | 
				
			||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
 | 
					import { unicodeMapping } from '../emojione_light';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const assetHost = process.env.CDN_HOST || '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default class AutosuggestEmoji extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    emoji: PropTypes.object.isRequired,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { emoji } = this.props;
 | 
				
			||||||
 | 
					    let url;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (emoji.custom) {
 | 
				
			||||||
 | 
					      url = emoji.imageUrl;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      const [ filename ] = unicodeMapping[emoji.native];
 | 
				
			||||||
 | 
					      url = `${assetHost}/emoji/${filename}.svg`;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div className='autosuggest-emoji'>
 | 
				
			||||||
 | 
					        <img
 | 
				
			||||||
 | 
					          className='emojione'
 | 
				
			||||||
 | 
					          src={url}
 | 
				
			||||||
 | 
					          alt={emoji.native || emoji.colons}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {emoji.colons}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,12 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
 | 
					import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
 | 
				
			||||||
import AutosuggestShortcode from '../features/compose/components/autosuggest_shortcode';
 | 
					import AutosuggestEmoji from './autosuggest_emoji';
 | 
				
			||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import { isRtl } from '../rtl';
 | 
					import { isRtl } from '../rtl';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
import Textarea from 'react-textarea-autosize';
 | 
					import Textarea from 'react-textarea-autosize';
 | 
				
			||||||
 | 
					import classNames from 'classnames';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const textAtCursorMatchesToken = (str, caretPosition) => {
 | 
					const textAtCursorMatchesToken = (str, caretPosition) => {
 | 
				
			||||||
  let word;
 | 
					  let word;
 | 
				
			||||||
| 
						 | 
					@ -19,12 +20,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
 | 
				
			||||||
    word = str.slice(left, right + caretPosition);
 | 
					    word = str.slice(left, right + caretPosition);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!word || word.trim().length < 2 || ['@', ':', '#'].indexOf(word[0]) === -1) {
 | 
					  if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
 | 
				
			||||||
    return [null, null];
 | 
					    return [null, null];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  word = word.trim().toLowerCase();
 | 
					  word = word.trim().toLowerCase();
 | 
				
			||||||
  // was: .slice(1); - we leave the leading char there, handler can decide what to do based on it
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (word.length > 0) {
 | 
					  if (word.length > 0) {
 | 
				
			||||||
    return [left + 1, word];
 | 
					    return [left + 1, word];
 | 
				
			||||||
| 
						 | 
					@ -43,7 +43,6 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
 | 
				
			||||||
    onSuggestionSelected: PropTypes.func.isRequired,
 | 
					    onSuggestionSelected: PropTypes.func.isRequired,
 | 
				
			||||||
    onSuggestionsClearRequested: PropTypes.func.isRequired,
 | 
					    onSuggestionsClearRequested: PropTypes.func.isRequired,
 | 
				
			||||||
    onSuggestionsFetchRequested: PropTypes.func.isRequired,
 | 
					    onSuggestionsFetchRequested: PropTypes.func.isRequired,
 | 
				
			||||||
    onLocalSuggestionsFetchRequested: PropTypes.func.isRequired,
 | 
					 | 
				
			||||||
    onChange: PropTypes.func.isRequired,
 | 
					    onChange: PropTypes.func.isRequired,
 | 
				
			||||||
    onKeyUp: PropTypes.func,
 | 
					    onKeyUp: PropTypes.func,
 | 
				
			||||||
    onKeyDown: PropTypes.func,
 | 
					    onKeyDown: PropTypes.func,
 | 
				
			||||||
| 
						 | 
					@ -67,13 +66,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (token !== null && this.state.lastToken !== token) {
 | 
					    if (token !== null && this.state.lastToken !== token) {
 | 
				
			||||||
      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
 | 
					      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
 | 
				
			||||||
      if (token[0] === ':') {
 | 
					 | 
				
			||||||
        // faster debounce for shortcodes.
 | 
					 | 
				
			||||||
        // hashtags have long debounce because they're fetched from server.
 | 
					 | 
				
			||||||
        this.props.onLocalSuggestionsFetchRequested(token);
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
      this.props.onSuggestionsFetchRequested(token);
 | 
					      this.props.onSuggestionsFetchRequested(token);
 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } else if (token === null) {
 | 
					    } else if (token === null) {
 | 
				
			||||||
      this.setState({ lastToken: null });
 | 
					      this.setState({ lastToken: null });
 | 
				
			||||||
      this.props.onSuggestionsClearRequested();
 | 
					      this.props.onSuggestionsClearRequested();
 | 
				
			||||||
| 
						 | 
					@ -137,9 +130,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onSuggestionClick = (e) => {
 | 
					  onSuggestionClick = (e) => {
 | 
				
			||||||
    // leave suggestion string unchanged if it's a hash / shortcode suggestion. convert account number to int.
 | 
					    const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
 | 
				
			||||||
    const suggestionStr = e.currentTarget.getAttribute('data-index');
 | 
					 | 
				
			||||||
    const suggestion = [':', '#'].indexOf(suggestionStr[0]) !== -1 ? suggestionStr : Number(suggestionStr);
 | 
					 | 
				
			||||||
    e.preventDefault();
 | 
					    e.preventDefault();
 | 
				
			||||||
    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
 | 
					    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
 | 
				
			||||||
    this.textarea.focus();
 | 
					    this.textarea.focus();
 | 
				
			||||||
| 
						 | 
					@ -162,36 +153,39 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentDidUpdate () {
 | 
					  renderSuggestion = (suggestion, i) => {
 | 
				
			||||||
    if (this.refs.selected) {
 | 
					    const { selectedSuggestion } = this.state;
 | 
				
			||||||
      if (this.refs.selected.scrollIntoViewIfNeeded)
 | 
					    let inner, key;
 | 
				
			||||||
        this.refs.selected.scrollIntoViewIfNeeded();
 | 
					
 | 
				
			||||||
      else
 | 
					    if (typeof suggestion === 'object') {
 | 
				
			||||||
        this.refs.selected.scrollIntoView({ behavior: 'auto', block: 'nearest' });
 | 
					      inner = <AutosuggestEmoji emoji={suggestion} />;
 | 
				
			||||||
 | 
					      key   = suggestion.id;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      inner = <AutosuggestAccountContainer id={suggestion} />;
 | 
				
			||||||
 | 
					      key   = suggestion;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
 | 
				
			||||||
 | 
					        {inner}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
 | 
					    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
 | 
				
			||||||
    const { suggestionsHidden, selectedSuggestion } = this.state;
 | 
					    const { suggestionsHidden } = this.state;
 | 
				
			||||||
    const style = { direction: 'ltr' };
 | 
					    const style = { direction: 'ltr' };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (isRtl(value)) {
 | 
					    if (isRtl(value)) {
 | 
				
			||||||
      style.direction = 'rtl';
 | 
					      style.direction = 'rtl';
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let makeItem = (suggestion) => {
 | 
					 | 
				
			||||||
      if (suggestion[0] === ':') return <AutosuggestShortcode shortcode={suggestion} />;
 | 
					 | 
				
			||||||
      if (suggestion[0] === '#') return suggestion; // hashtag
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // else - accounts are always returned as IDs with no prefix
 | 
					 | 
				
			||||||
      return <AutosuggestAccountContainer id={suggestion} />;
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className='autosuggest-textarea'>
 | 
					      <div className='autosuggest-textarea'>
 | 
				
			||||||
        <label>
 | 
					        <label>
 | 
				
			||||||
          <span style={{ display: 'none' }}>{placeholder}</span>
 | 
					          <span style={{ display: 'none' }}>{placeholder}</span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <Textarea
 | 
					          <Textarea
 | 
				
			||||||
            inputRef={this.setTextarea}
 | 
					            inputRef={this.setTextarea}
 | 
				
			||||||
            className='autosuggest-textarea__textarea'
 | 
					            className='autosuggest-textarea__textarea'
 | 
				
			||||||
| 
						 | 
					@ -209,19 +203,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
 | 
				
			||||||
        </label>
 | 
					        </label>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
 | 
					        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
 | 
				
			||||||
          {suggestions.map((suggestion, i) => (
 | 
					          {suggestions.map(this.renderSuggestion)}
 | 
				
			||||||
            <div
 | 
					 | 
				
			||||||
              ref={i === selectedSuggestion ? 'selected' : null}
 | 
					 | 
				
			||||||
              role='button'
 | 
					 | 
				
			||||||
              tabIndex='0'
 | 
					 | 
				
			||||||
              key={suggestion}
 | 
					 | 
				
			||||||
              data-index={suggestion}
 | 
					 | 
				
			||||||
              className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
 | 
					 | 
				
			||||||
              onMouseDown={this.onSuggestionClick}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              {makeItem(suggestion)}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          ))}
 | 
					 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,53 +1,58 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
					 | 
				
			||||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
 | 
					 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
 | 
					import IconButton from './icon_button';
 | 
				
			||||||
 | 
					import { Overlay } from 'react-overlays';
 | 
				
			||||||
 | 
					import { Motion, spring } from 'react-motion';
 | 
				
			||||||
 | 
					import detectPassiveEvents from 'detect-passive-events';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class DropdownMenu extends React.PureComponent {
 | 
					const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DropdownMenu extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static contextTypes = {
 | 
					  static contextTypes = {
 | 
				
			||||||
    router: PropTypes.object,
 | 
					    router: PropTypes.object,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    isUserTouching: PropTypes.func,
 | 
					 | 
				
			||||||
    isModalOpen: PropTypes.bool.isRequired,
 | 
					 | 
				
			||||||
    onModalOpen: PropTypes.func,
 | 
					 | 
				
			||||||
    onModalClose: PropTypes.func,
 | 
					 | 
				
			||||||
    icon: PropTypes.string.isRequired,
 | 
					 | 
				
			||||||
    items: PropTypes.array.isRequired,
 | 
					    items: PropTypes.array.isRequired,
 | 
				
			||||||
    size: PropTypes.number.isRequired,
 | 
					    onClose: PropTypes.func.isRequired,
 | 
				
			||||||
    direction: PropTypes.string,
 | 
					    style: PropTypes.object,
 | 
				
			||||||
    status: ImmutablePropTypes.map,
 | 
					    placement: PropTypes.string,
 | 
				
			||||||
    ariaLabel: PropTypes.string,
 | 
					    arrowOffsetLeft: PropTypes.string,
 | 
				
			||||||
    disabled: PropTypes.bool,
 | 
					    arrowOffsetTop: PropTypes.string,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static defaultProps = {
 | 
					  static defaultProps = {
 | 
				
			||||||
    ariaLabel: 'Menu',
 | 
					    style: {},
 | 
				
			||||||
    isModalOpen: false,
 | 
					    placement: 'bottom',
 | 
				
			||||||
    isUserTouching: () => false,
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  state = {
 | 
					  handleDocumentClick = e => {
 | 
				
			||||||
    direction: 'left',
 | 
					    if (this.node && !this.node.contains(e.target)) {
 | 
				
			||||||
    expanded: false,
 | 
					      this.props.onClose();
 | 
				
			||||||
  };
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
  setRef = (c) => {
 | 
					 | 
				
			||||||
    this.dropdown = c;
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleClick = (e) => {
 | 
					  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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleClick = e => {
 | 
				
			||||||
    const i = Number(e.currentTarget.getAttribute('data-index'));
 | 
					    const i = Number(e.currentTarget.getAttribute('data-index'));
 | 
				
			||||||
    const { action, to } = this.props.items[i];
 | 
					    const { action, to } = this.props.items[i];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (this.props.isModalOpen) {
 | 
					    this.props.onClose();
 | 
				
			||||||
      this.props.onModalClose();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Don't call e.preventDefault() when the item uses 'href' property.
 | 
					 | 
				
			||||||
    // ex. "Edit profile" on the account action bar
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (typeof action === 'function') {
 | 
					    if (typeof action === 'function') {
 | 
				
			||||||
      e.preventDefault();
 | 
					      e.preventDefault();
 | 
				
			||||||
| 
						 | 
					@ -56,46 +61,18 @@ export default class DropdownMenu extends React.PureComponent {
 | 
				
			||||||
      e.preventDefault();
 | 
					      e.preventDefault();
 | 
				
			||||||
      this.context.router.history.push(to);
 | 
					      this.context.router.history.push(to);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
    this.dropdown.hide();
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleShow = () => {
 | 
					  renderItem (option, i) {
 | 
				
			||||||
    if (this.props.isUserTouching()) {
 | 
					    if (option === null) {
 | 
				
			||||||
      this.props.onModalOpen({
 | 
					      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
 | 
				
			||||||
        status: this.props.status,
 | 
					 | 
				
			||||||
        actions: this.props.items,
 | 
					 | 
				
			||||||
        onClick: this.handleClick,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      this.setState({ expanded: true });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleHide = () => this.setState({ expanded: false })
 | 
					    const { text, href = '#' } = option;
 | 
				
			||||||
 | 
					 | 
				
			||||||
  handleToggle = (e) => {
 | 
					 | 
				
			||||||
    if (e.key === 'Enter') {
 | 
					 | 
				
			||||||
      if (this.props.isUserTouching()) {
 | 
					 | 
				
			||||||
        this.handleShow();
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        this.setState({ expanded: !this.state.expanded });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } else if (e.key === 'Escape') {
 | 
					 | 
				
			||||||
      this.setState({ expanded: false });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  renderItem = (item, i) => {
 | 
					 | 
				
			||||||
    if (item === null) {
 | 
					 | 
				
			||||||
      return <li key={`sep-${i}`} className='dropdown__sep' />;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const { text, href = '#' } = item;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <li className='dropdown__content-list-item' key={`${text}-${i}`}>
 | 
					      <li className='dropdown-menu__item' key={`${text}-${i}`}>
 | 
				
			||||||
        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i} className='dropdown__content-list-link'>
 | 
					        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}>
 | 
				
			||||||
          {text}
 | 
					          {text}
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
| 
						 | 
					@ -103,44 +80,131 @@ export default class DropdownMenu extends React.PureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { icon, items, size, direction, ariaLabel, disabled } = this.props;
 | 
					    const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
 | 
				
			||||||
    const { expanded }   = this.state;
 | 
					 | 
				
			||||||
    const isUserTouching = this.props.isUserTouching();
 | 
					 | 
				
			||||||
    const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right';
 | 
					 | 
				
			||||||
    const iconStyle      = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` };
 | 
					 | 
				
			||||||
    const iconClassname  = `fa fa-fw fa-${icon} dropdown__icon`;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (disabled) {
 | 
					 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <div className='icon-button disabled' style={iconStyle} aria-label={ariaLabel}>
 | 
					      <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 }) }}>
 | 
				
			||||||
          <i className={iconClassname} aria-hidden />
 | 
					        {({ opacity, scaleX, scaleY }) => (
 | 
				
			||||||
 | 
					          <div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
 | 
				
			||||||
 | 
					            <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <ul>
 | 
				
			||||||
 | 
					              {items.map((option, i) => this.renderItem(option, i))}
 | 
				
			||||||
 | 
					            </ul>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </Motion>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default class Dropdown extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static contextTypes = {
 | 
				
			||||||
 | 
					    router: PropTypes.object,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    icon: PropTypes.string.isRequired,
 | 
				
			||||||
 | 
					    items: PropTypes.array.isRequired,
 | 
				
			||||||
 | 
					    size: PropTypes.number.isRequired,
 | 
				
			||||||
 | 
					    ariaLabel: PropTypes.string,
 | 
				
			||||||
 | 
					    disabled: PropTypes.bool,
 | 
				
			||||||
 | 
					    status: ImmutablePropTypes.map,
 | 
				
			||||||
 | 
					    isUserTouching: PropTypes.func,
 | 
				
			||||||
 | 
					    isModalOpen: PropTypes.bool.isRequired,
 | 
				
			||||||
 | 
					    onModalOpen: PropTypes.func,
 | 
				
			||||||
 | 
					    onModalClose: PropTypes.func,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static defaultProps = {
 | 
				
			||||||
 | 
					    ariaLabel: 'Menu',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  state = {
 | 
				
			||||||
 | 
					    expanded: false,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleClick = () => {
 | 
				
			||||||
 | 
					    if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
 | 
				
			||||||
 | 
					      const { status, items } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.props.onModalOpen({
 | 
				
			||||||
 | 
					        status,
 | 
				
			||||||
 | 
					        actions: items,
 | 
				
			||||||
 | 
					        onClick: this.handleItemClick,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.setState({ expanded: !this.state.expanded });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleClose = () => {
 | 
				
			||||||
 | 
					    if (this.props.onModalClose) {
 | 
				
			||||||
 | 
					      this.props.onModalClose();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.setState({ expanded: false });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleKeyDown = e => {
 | 
				
			||||||
 | 
					    switch(e.key) {
 | 
				
			||||||
 | 
					    case 'Enter':
 | 
				
			||||||
 | 
					      this.handleClick();
 | 
				
			||||||
 | 
					      break;
 | 
				
			||||||
 | 
					    case 'Escape':
 | 
				
			||||||
 | 
					      this.handleClose();
 | 
				
			||||||
 | 
					      break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleItemClick = e => {
 | 
				
			||||||
 | 
					    const i = Number(e.currentTarget.getAttribute('data-index'));
 | 
				
			||||||
 | 
					    const { action, to } = this.props.items[i];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.handleClose();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (typeof action === 'function') {
 | 
				
			||||||
 | 
					      e.preventDefault();
 | 
				
			||||||
 | 
					      action();
 | 
				
			||||||
 | 
					    } else if (to) {
 | 
				
			||||||
 | 
					      e.preventDefault();
 | 
				
			||||||
 | 
					      this.context.router.history.push(to);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setTargetRef = c => {
 | 
				
			||||||
 | 
					    this.target = c;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  findTarget = () => {
 | 
				
			||||||
 | 
					    return this.target;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { icon, items, size, ariaLabel, disabled } = this.props;
 | 
				
			||||||
 | 
					    const { expanded } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div onKeyDown={this.handleKeyDown}>
 | 
				
			||||||
 | 
					        <IconButton
 | 
				
			||||||
 | 
					          icon={icon}
 | 
				
			||||||
 | 
					          title={ariaLabel}
 | 
				
			||||||
 | 
					          active={expanded}
 | 
				
			||||||
 | 
					          disabled={disabled}
 | 
				
			||||||
 | 
					          size={size}
 | 
				
			||||||
 | 
					          ref={this.setTargetRef}
 | 
				
			||||||
 | 
					          onClick={this.handleClick}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <Overlay show={expanded} placement='bottom' target={this.findTarget}>
 | 
				
			||||||
 | 
					          <DropdownMenu items={items} onClose={this.handleClose} />
 | 
				
			||||||
 | 
					        </Overlay>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const dropdownItems = expanded && (
 | 
					 | 
				
			||||||
      <ul role='group' className='dropdown__content-list' onClick={this.handleHide}>
 | 
					 | 
				
			||||||
        {items.map(this.renderItem)}
 | 
					 | 
				
			||||||
      </ul>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // No need to render the actual dropdown if we use the modal. If we
 | 
					 | 
				
			||||||
    // don't render anything <Dropdow /> breaks, so we just put an empty div.
 | 
					 | 
				
			||||||
    const dropdownContent = !isUserTouching ? (
 | 
					 | 
				
			||||||
      <DropdownContent className={directionClass} >
 | 
					 | 
				
			||||||
        {dropdownItems}
 | 
					 | 
				
			||||||
      </DropdownContent>
 | 
					 | 
				
			||||||
    ) : <div />;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}>
 | 
					 | 
				
			||||||
        <DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-expanded={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}>
 | 
					 | 
				
			||||||
          <i className={iconClassname} aria-hidden />
 | 
					 | 
				
			||||||
        </DropdownTrigger>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        {dropdownContent}
 | 
					 | 
				
			||||||
      </Dropdown>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,16 +1,24 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					 | 
				
			||||||
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
 | 
					import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
 | 
				
			||||||
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
 | 
					import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
 | 
				
			||||||
 | 
					import { is } from 'immutable';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class IntersectionObserverArticle extends ImmutablePureComponent {
 | 
					// Diff these props in the "rendered" state
 | 
				
			||||||
 | 
					const updateOnPropsForRendered = ['id', 'index', 'listLength'];
 | 
				
			||||||
 | 
					// Diff these props in the "unrendered" state
 | 
				
			||||||
 | 
					const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default class IntersectionObserverArticle extends React.Component {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    intersectionObserverWrapper: PropTypes.object,
 | 
					    intersectionObserverWrapper: PropTypes.object.isRequired,
 | 
				
			||||||
    id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
 | 
					    id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
 | 
				
			||||||
    index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
 | 
					    index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
 | 
				
			||||||
    listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
 | 
					    listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
 | 
				
			||||||
 | 
					    saveHeightKey: PropTypes.string,
 | 
				
			||||||
 | 
					    cachedHeight: PropTypes.number,
 | 
				
			||||||
 | 
					    onHeightChange: PropTypes.func,
 | 
				
			||||||
    children: PropTypes.node,
 | 
					    children: PropTypes.node,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -19,28 +27,22 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  shouldComponentUpdate (nextProps, nextState) {
 | 
					  shouldComponentUpdate (nextProps, nextState) {
 | 
				
			||||||
    if (!nextState.isIntersecting && nextState.isHidden) {
 | 
					    const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
 | 
				
			||||||
      // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
 | 
					    const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
 | 
				
			||||||
      // that either "isIntersecting" or "isHidden" matter, and then they're
 | 
					    if (!!isUnrendered !== !!willBeUnrendered) {
 | 
				
			||||||
      // the only things that matter (and updated ARIA attributes).
 | 
					      // If we're going from rendered to unrendered (or vice versa) then update
 | 
				
			||||||
      return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
 | 
					 | 
				
			||||||
    } else if (nextState.isIntersecting && !this.state.isIntersecting) {
 | 
					 | 
				
			||||||
      // If we're going from a non-intersecting state to an intersecting state,
 | 
					 | 
				
			||||||
      // (i.e. offscreen to onscreen), then we definitely need to re-render
 | 
					 | 
				
			||||||
      return true;
 | 
					      return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    // Otherwise, diff based on "updateOnProps" and "updateOnStates"
 | 
					    // Otherwise, diff based on props
 | 
				
			||||||
    return super.shouldComponentUpdate(nextProps, nextState);
 | 
					    const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
 | 
				
			||||||
 | 
					    return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentDidMount () {
 | 
					  componentDidMount () {
 | 
				
			||||||
    if (!this.props.intersectionObserverWrapper) {
 | 
					    const { intersectionObserverWrapper, id } = this.props;
 | 
				
			||||||
      // TODO: enable IntersectionObserver optimization for notification statuses.
 | 
					
 | 
				
			||||||
      // These are managed in notifications/index.js rather than status_list.js
 | 
					    intersectionObserverWrapper.observe(
 | 
				
			||||||
      return;
 | 
					      id,
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    this.props.intersectionObserverWrapper.observe(
 | 
					 | 
				
			||||||
      this.props.id,
 | 
					 | 
				
			||||||
      this.node,
 | 
					      this.node,
 | 
				
			||||||
      this.handleIntersection
 | 
					      this.handleIntersection
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
| 
						 | 
					@ -49,20 +51,21 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillUnmount () {
 | 
					  componentWillUnmount () {
 | 
				
			||||||
    if (this.props.intersectionObserverWrapper) {
 | 
					    const { intersectionObserverWrapper, id } = this.props;
 | 
				
			||||||
      this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node);
 | 
					    intersectionObserverWrapper.unobserve(id, this.node);
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.componentMounted = false;
 | 
					    this.componentMounted = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleIntersection = (entry) => {
 | 
					  handleIntersection = (entry) => {
 | 
				
			||||||
 | 
					    const { onHeightChange, saveHeightKey, id } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (this.node && this.node.children.length !== 0) {
 | 
					    if (this.node && this.node.children.length !== 0) {
 | 
				
			||||||
      // save the height of the fully-rendered element
 | 
					      // save the height of the fully-rendered element
 | 
				
			||||||
      this.height = getRectFromEntry(entry).height;
 | 
					      this.height = getRectFromEntry(entry).height;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (this.props.onHeightChange) {
 | 
					      if (onHeightChange && saveHeightKey) {
 | 
				
			||||||
        this.props.onHeightChange(this.props.status, this.height);
 | 
					        onHeightChange(saveHeightKey, id, this.height);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -94,16 +97,16 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { children, id, index, listLength } = this.props;
 | 
					    const { children, id, index, listLength, cachedHeight } = this.props;
 | 
				
			||||||
    const { isIntersecting, isHidden } = this.state;
 | 
					    const { isIntersecting, isHidden } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!isIntersecting && isHidden) {
 | 
					    if (!isIntersecting && (isHidden || cachedHeight)) {
 | 
				
			||||||
      return (
 | 
					      return (
 | 
				
			||||||
        <article
 | 
					        <article
 | 
				
			||||||
          ref={this.handleRef}
 | 
					          ref={this.handleRef}
 | 
				
			||||||
          aria-posinset={index}
 | 
					          aria-posinset={index}
 | 
				
			||||||
          aria-setsize={listLength}
 | 
					          aria-setsize={listLength}
 | 
				
			||||||
          style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}
 | 
					          style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
 | 
				
			||||||
          data-id={id}
 | 
					          data-id={id}
 | 
				
			||||||
          tabIndex='0'
 | 
					          tabIndex='0'
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,7 +17,7 @@ export default class LoadMore extends React.PureComponent {
 | 
				
			||||||
    const { visible } = this.props;
 | 
					    const { visible } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <button className='load-more' disabled={!visible} style={{ opacity: visible ? 1 : 0 }} onClick={this.props.onClick}>
 | 
					      <button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
 | 
				
			||||||
        <FormattedMessage id='status.load_more' defaultMessage='Load more' />
 | 
					        <FormattedMessage id='status.load_more' defaultMessage='Load more' />
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,9 +4,12 @@
 | 
				
			||||||
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 PropTypes from 'prop-types';
 | 
				
			||||||
 | 
					import { is } from 'immutable';
 | 
				
			||||||
import IconButton from './icon_button';
 | 
					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 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' },
 | 
				
			||||||
| 
						 | 
					@ -20,6 +23,7 @@ class Item extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    attachment: ImmutablePropTypes.map.isRequired,
 | 
					    attachment: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
 | 
					    standalone: PropTypes.bool,
 | 
				
			||||||
    index: PropTypes.number.isRequired,
 | 
					    index: PropTypes.number.isRequired,
 | 
				
			||||||
    size: PropTypes.number.isRequired,
 | 
					    size: PropTypes.number.isRequired,
 | 
				
			||||||
    onClick: PropTypes.func.isRequired,
 | 
					    onClick: PropTypes.func.isRequired,
 | 
				
			||||||
| 
						 | 
					@ -28,6 +32,9 @@ class Item extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static defaultProps = {
 | 
					  static defaultProps = {
 | 
				
			||||||
    autoPlayGif: false,
 | 
					    autoPlayGif: false,
 | 
				
			||||||
 | 
					    standalone: false,
 | 
				
			||||||
 | 
					    index: 0,
 | 
				
			||||||
 | 
					    size: 1,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleMouseEnter = (e) => {
 | 
					  handleMouseEnter = (e) => {
 | 
				
			||||||
| 
						 | 
					@ -60,7 +67,7 @@ class Item extends React.PureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { attachment, index, size } = this.props;
 | 
					    const { attachment, index, size, standalone } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let width  = 50;
 | 
					    let width  = 50;
 | 
				
			||||||
    let height = 100;
 | 
					    let height = 100;
 | 
				
			||||||
| 
						 | 
					@ -122,8 +129,8 @@ class Item extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 | 
					      const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const srcSet = hasSize && `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
 | 
					      const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
 | 
				
			||||||
      const sizes = hasSize && `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`;
 | 
					      const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      thumbnail = (
 | 
					      thumbnail = (
 | 
				
			||||||
        <a
 | 
					        <a
 | 
				
			||||||
| 
						 | 
					@ -139,7 +146,7 @@ class Item extends React.PureComponent {
 | 
				
			||||||
      const autoPlay = !isIOS() && this.props.autoPlayGif;
 | 
					      const autoPlay = !isIOS() && this.props.autoPlayGif;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      thumbnail = (
 | 
					      thumbnail = (
 | 
				
			||||||
        <div className={`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'
 | 
				
			||||||
            role='application'
 | 
					            role='application'
 | 
				
			||||||
| 
						 | 
					@ -158,7 +165,7 @@ class Item extends React.PureComponent {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
 | 
					      <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
 | 
				
			||||||
        {thumbnail}
 | 
					        {thumbnail}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
| 
						 | 
					@ -167,11 +174,14 @@ 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 = {
 | 
				
			||||||
    sensitive: PropTypes.bool,
 | 
					    sensitive: PropTypes.bool,
 | 
				
			||||||
 | 
					    standalone: PropTypes.bool,
 | 
				
			||||||
    media: ImmutablePropTypes.list.isRequired,
 | 
					    media: ImmutablePropTypes.list.isRequired,
 | 
				
			||||||
 | 
					    size: PropTypes.object,
 | 
				
			||||||
    height: PropTypes.number.isRequired,
 | 
					    height: PropTypes.number.isRequired,
 | 
				
			||||||
    onOpenMedia: PropTypes.func.isRequired,
 | 
					    onOpenMedia: PropTypes.func.isRequired,
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
| 
						 | 
					@ -180,6 +190,7 @@ export default class MediaGallery extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static defaultProps = {
 | 
					  static defaultProps = {
 | 
				
			||||||
    autoPlayGif: false,
 | 
					    autoPlayGif: false,
 | 
				
			||||||
 | 
					    standalone: false,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  state = {
 | 
					  state = {
 | 
				
			||||||
| 
						 | 
					@ -187,7 +198,7 @@ export default class MediaGallery extends React.PureComponent {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillReceiveProps (nextProps) {
 | 
					  componentWillReceiveProps (nextProps) {
 | 
				
			||||||
    if (nextProps.sensitive !== this.props.sensitive) {
 | 
					    if (!is(nextProps.media, this.props.media)) {
 | 
				
			||||||
      this.setState({ visible: !nextProps.sensitive });
 | 
					      this.setState({ visible: !nextProps.sensitive });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -201,10 +212,19 @@ export default class MediaGallery extends React.PureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { media, intl, sensitive } = this.props;
 | 
					    const { media, intl, sensitive, height, standalone, size } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let children;
 | 
					    let children;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const standaloneEligible = standalone && size.width && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
 | 
				
			||||||
 | 
					    const style = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (standaloneEligible) {
 | 
				
			||||||
 | 
					      style.height = size.width / media.getIn([0, 'meta', 'small', 'aspect']);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      style.height = height;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!this.state.visible) {
 | 
					    if (!this.state.visible) {
 | 
				
			||||||
      let warning;
 | 
					      let warning;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -215,19 +235,24 @@ export default class MediaGallery extends React.PureComponent {
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      children = (
 | 
					      children = (
 | 
				
			||||||
        <button className='media-spoiler' onClick={this.handleOpen}>
 | 
					        <button className='media-spoiler' onClick={this.handleOpen} style={style}>
 | 
				
			||||||
          <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>
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      const size = media.take(4).size;
 | 
					      const size = media.take(4).size;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (standaloneEligible) {
 | 
				
			||||||
 | 
					        children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />;
 | 
				
			||||||
 | 
					      } 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} />);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className='media-gallery' style={{ height: `${this.props.height}px` }}>
 | 
					      <div className='media-gallery' style={style}>
 | 
				
			||||||
        <div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}>
 | 
					        <div className={classNames('spoiler-button', { 'spoiler-button--visible': this.state.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={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,7 @@
 | 
				
			||||||
import React, { PureComponent } from 'react';
 | 
					import React, { PureComponent } from 'react';
 | 
				
			||||||
import { ScrollContainer } from 'react-router-scroll';
 | 
					import { ScrollContainer } from 'react-router-scroll';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import IntersectionObserverArticle from './intersection_observer_article';
 | 
					import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
 | 
				
			||||||
import LoadMore from './load_more';
 | 
					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';
 | 
				
			||||||
| 
						 | 
					@ -9,6 +9,10 @@ import { List as ImmutableList } from 'immutable';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class ScrollableList extends PureComponent {
 | 
					export default class ScrollableList extends PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static contextTypes = {
 | 
				
			||||||
 | 
					    router: PropTypes.object,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    scrollKey: PropTypes.string.isRequired,
 | 
					    scrollKey: PropTypes.string.isRequired,
 | 
				
			||||||
    onScrollToBottom: PropTypes.func,
 | 
					    onScrollToBottom: PropTypes.func,
 | 
				
			||||||
| 
						 | 
					@ -163,7 +167,7 @@ export default class ScrollableList extends PureComponent {
 | 
				
			||||||
    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
 | 
					    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
 | 
				
			||||||
    const childrenCount = React.Children.count(children);
 | 
					    const childrenCount = React.Children.count(children);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const loadMore     = <LoadMore visible={!isLoading && childrenCount > 0 && hasMore} onClick={this.handleLoadMore} />;
 | 
					    const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
 | 
				
			||||||
    let scrollableArea = null;
 | 
					    let scrollableArea = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (isLoading || childrenCount > 0 || !emptyMessage) {
 | 
					    if (isLoading || childrenCount > 0 || !emptyMessage) {
 | 
				
			||||||
| 
						 | 
					@ -173,9 +177,16 @@ export default class ScrollableList extends PureComponent {
 | 
				
			||||||
            {prepend}
 | 
					            {prepend}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            {React.Children.map(this.props.children, (child, index) => (
 | 
					            {React.Children.map(this.props.children, (child, index) => (
 | 
				
			||||||
              <IntersectionObserverArticle key={child.key} id={child.key} index={index} listLength={childrenCount} intersectionObserverWrapper={this.intersectionObserverWrapper}>
 | 
					              <IntersectionObserverArticleContainer
 | 
				
			||||||
 | 
					                key={child.key}
 | 
				
			||||||
 | 
					                id={child.key}
 | 
				
			||||||
 | 
					                index={index}
 | 
				
			||||||
 | 
					                listLength={childrenCount}
 | 
				
			||||||
 | 
					                intersectionObserverWrapper={this.intersectionObserverWrapper}
 | 
				
			||||||
 | 
					                saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
                {child}
 | 
					                {child}
 | 
				
			||||||
              </IntersectionObserverArticle>
 | 
					              </IntersectionObserverArticleContainer>
 | 
				
			||||||
            ))}
 | 
					            ))}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            {loadMore}
 | 
					            {loadMore}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,7 +12,7 @@ import StatusContent from './status_content';
 | 
				
			||||||
import StatusActionBar from './status_action_bar';
 | 
					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, VideoPlayer } from '../features/ui/util/async-components';
 | 
					import { MediaGallery, Video } from '../features/ui/util/async-components';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// 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
 | 
				
			||||||
| 
						 | 
					@ -37,7 +37,7 @@ export default class Status extends ImmutablePureComponent {
 | 
				
			||||||
    onBlock: PropTypes.func,
 | 
					    onBlock: PropTypes.func,
 | 
				
			||||||
    onEmbed: PropTypes.func,
 | 
					    onEmbed: PropTypes.func,
 | 
				
			||||||
    onHeightChange: PropTypes.func,
 | 
					    onHeightChange: PropTypes.func,
 | 
				
			||||||
    me: PropTypes.number,
 | 
					    me: PropTypes.string,
 | 
				
			||||||
    boostModal: PropTypes.bool,
 | 
					    boostModal: PropTypes.bool,
 | 
				
			||||||
    autoPlayGif: PropTypes.bool,
 | 
					    autoPlayGif: PropTypes.bool,
 | 
				
			||||||
    muted: PropTypes.bool,
 | 
					    muted: PropTypes.bool,
 | 
				
			||||||
| 
						 | 
					@ -73,7 +73,7 @@ export default class Status extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleAccountClick = (e) => {
 | 
					  handleAccountClick = (e) => {
 | 
				
			||||||
    if (this.context.router && e.button === 0) {
 | 
					    if (this.context.router && e.button === 0) {
 | 
				
			||||||
      const id = Number(e.currentTarget.getAttribute('data-id'));
 | 
					      const id = e.currentTarget.getAttribute('data-id');
 | 
				
			||||||
      e.preventDefault();
 | 
					      e.preventDefault();
 | 
				
			||||||
      this.context.router.history.push(`/accounts/${id}`);
 | 
					      this.context.router.history.push(`/accounts/${id}`);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -91,6 +91,10 @@ export default class Status extends ImmutablePureComponent {
 | 
				
			||||||
    return <div className='media-spoiler-video' style={{ height: '110px' }} />;
 | 
					    return <div className='media-spoiler-video' style={{ height: '110px' }} />;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleOpenVideo = startTime => {
 | 
				
			||||||
 | 
					    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    let media = null;
 | 
					    let media = null;
 | 
				
			||||||
    let statusAvatar;
 | 
					    let statusAvatar;
 | 
				
			||||||
| 
						 | 
					@ -130,9 +134,18 @@ export default class Status extends ImmutablePureComponent {
 | 
				
			||||||
      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
 | 
					      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
 | 
					      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
 | 
				
			||||||
 | 
					        const video = status.getIn(['media_attachments', 0]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        media = (
 | 
					        media = (
 | 
				
			||||||
          <Bundle fetchComponent={VideoPlayer} loading={this.renderLoadingVideoPlayer} >
 | 
					          <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
 | 
				
			||||||
            {Component => <Component media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />}
 | 
					            {Component => <Component
 | 
				
			||||||
 | 
					              preview={video.get('preview_url')}
 | 
				
			||||||
 | 
					              src={video.get('url')}
 | 
				
			||||||
 | 
					              width={239}
 | 
				
			||||||
 | 
					              height={110}
 | 
				
			||||||
 | 
					              sensitive={status.get('sensitive')}
 | 
				
			||||||
 | 
					              onOpenVideo={this.handleOpenVideo}
 | 
				
			||||||
 | 
					            />}
 | 
				
			||||||
          </Bundle>
 | 
					          </Bundle>
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -49,7 +49,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
 | 
				
			||||||
    onEmbed: PropTypes.func,
 | 
					    onEmbed: PropTypes.func,
 | 
				
			||||||
    onMuteConversation: PropTypes.func,
 | 
					    onMuteConversation: PropTypes.func,
 | 
				
			||||||
    onPin: PropTypes.func,
 | 
					    onPin: PropTypes.func,
 | 
				
			||||||
    me: PropTypes.number,
 | 
					    me: PropTypes.string,
 | 
				
			||||||
    withDismiss: PropTypes.bool,
 | 
					    withDismiss: PropTypes.bool,
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										18
									
								
								app/javascript/mastodon/containers/card_container.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
					@ -0,0 +1,18 @@
 | 
				
			||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
 | 
					import Card from '../features/status/components/card';
 | 
				
			||||||
 | 
					import { fromJS } from 'immutable';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default class CardContainer extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    locale: PropTypes.string,
 | 
				
			||||||
 | 
					    card: PropTypes.array.isRequired,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { card, ...props } = this.props;
 | 
				
			||||||
 | 
					    return <Card card={fromJS(card)} {...props} />;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,17 @@
 | 
				
			||||||
 | 
					import { connect } from 'react-redux';
 | 
				
			||||||
 | 
					import IntersectionObserverArticle from '../components/intersection_observer_article';
 | 
				
			||||||
 | 
					import { setHeight } from '../actions/height_cache';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const makeMapStateToProps = (state, props) => ({
 | 
				
			||||||
 | 
					  cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const mapDispatchToProps = (dispatch) => ({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onHeightChange (key, id, height) {
 | 
				
			||||||
 | 
					    dispatch(setHeight(key, id, height));
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle);
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,34 @@
 | 
				
			||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
 | 
					import { IntlProvider, addLocaleData } from 'react-intl';
 | 
				
			||||||
 | 
					import { getLocale } from '../locales';
 | 
				
			||||||
 | 
					import MediaGallery from '../components/media_gallery';
 | 
				
			||||||
 | 
					import { fromJS } from 'immutable';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const { localeData, messages } = getLocale();
 | 
				
			||||||
 | 
					addLocaleData(localeData);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default class MediaGalleryContainer extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    locale: PropTypes.string.isRequired,
 | 
				
			||||||
 | 
					    media: PropTypes.array.isRequired,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleOpenMedia = () => {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { locale, media, ...props } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <IntlProvider locale={locale} messages={messages}>
 | 
				
			||||||
 | 
					        <MediaGallery
 | 
				
			||||||
 | 
					          {...props}
 | 
				
			||||||
 | 
					          media={fromJS(media)}
 | 
				
			||||||
 | 
					          onOpenMedia={this.handleOpenMedia}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </IntlProvider>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -21,7 +21,7 @@ import {
 | 
				
			||||||
  blockAccount,
 | 
					  blockAccount,
 | 
				
			||||||
  muteAccount,
 | 
					  muteAccount,
 | 
				
			||||||
} from '../actions/accounts';
 | 
					} from '../actions/accounts';
 | 
				
			||||||
import { muteStatus, unmuteStatus, deleteStatus, setStatusHeight } from '../actions/statuses';
 | 
					import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses';
 | 
				
			||||||
import { initReport } from '../actions/reports';
 | 
					import { initReport } from '../actions/reports';
 | 
				
			||||||
import { openModal } from '../actions/modal';
 | 
					import { openModal } from '../actions/modal';
 | 
				
			||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
					import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
				
			||||||
| 
						 | 
					@ -141,10 +141,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onHeightChange (status, height) {
 | 
					 | 
				
			||||||
    dispatch(setStatusHeight(status.get('id'), height));
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
 | 
					export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										26
									
								
								app/javascript/mastodon/containers/video_container.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
					@ -0,0 +1,26 @@
 | 
				
			||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
 | 
					import { IntlProvider, addLocaleData } from 'react-intl';
 | 
				
			||||||
 | 
					import { getLocale } from '../locales';
 | 
				
			||||||
 | 
					import Video from '../features/video';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const { localeData, messages } = getLocale();
 | 
				
			||||||
 | 
					addLocaleData(localeData);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default class VideoContainer extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    locale: PropTypes.string.isRequired,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { locale, ...props } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <IntlProvider locale={locale} messages={messages}>
 | 
				
			||||||
 | 
					        <Video {...props} />
 | 
				
			||||||
 | 
					      </IntlProvider>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -3,24 +3,43 @@ import Trie from 'substring-trie';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const trie = new Trie(Object.keys(unicodeMapping));
 | 
					const trie = new Trie(Object.keys(unicodeMapping));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emojify = str => {
 | 
					const assetHost = process.env.CDN_HOST || '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emojify = (str, customEmojis = {}) => {
 | 
				
			||||||
  let rtn = '';
 | 
					  let rtn = '';
 | 
				
			||||||
  for (;;) {
 | 
					  for (;;) {
 | 
				
			||||||
    let match, i = 0;
 | 
					    let match, i = 0, tag;
 | 
				
			||||||
    while (i < str.length && str[i] !== '<' && !(match = trie.search(str.slice(i)))) {
 | 
					    while (i < str.length && (tag = '<&'.indexOf(str[i])) === -1 && str[i] !== ':' && !(match = trie.search(str.slice(i)))) {
 | 
				
			||||||
      i += str.codePointAt(i) < 65536 ? 1 : 2;
 | 
					      i += str.codePointAt(i) < 65536 ? 1 : 2;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    if (i === str.length)
 | 
					    if (i === str.length)
 | 
				
			||||||
      break;
 | 
					      break;
 | 
				
			||||||
    else if (str[i] === '<') {
 | 
					    else if (tag >= 0) {
 | 
				
			||||||
      let tagend = str.indexOf('>', i + 1) + 1;
 | 
					      const tagend = str.indexOf('>;'[tag], i + 1) + 1;
 | 
				
			||||||
      if (!tagend)
 | 
					      if (!tagend)
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
      rtn += str.slice(0, tagend);
 | 
					      rtn += str.slice(0, tagend);
 | 
				
			||||||
      str = str.slice(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 {
 | 
					    } else {
 | 
				
			||||||
      const [filename, shortCode] = unicodeMapping[match];
 | 
					      const [filename, shortCode] = unicodeMapping[match];
 | 
				
			||||||
      rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
 | 
					      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);
 | 
					      str = str.slice(i + match.length);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -28,3 +47,26 @@ const emojify = str => {
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default emojify;
 | 
					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
									
								
								app/javascript/mastodon/emoji_map.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
					@ -1,13 +1,38 @@
 | 
				
			||||||
// @preval
 | 
					// @preval
 | 
				
			||||||
// Force tree shaking on emojione by exposing just a subset of its functionality
 | 
					// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emojione = require('emojione');
 | 
					const emojis         = require('./emoji_map.json');
 | 
				
			||||||
 | 
					const { emojiIndex } = require('emoji-mart');
 | 
				
			||||||
const mappedUnicode = emojione.mapUnicodeToShort();
 | 
					 | 
				
			||||||
const excluded       = ['®', '©', '™'];
 | 
					const excluded       = ['®', '©', '™'];
 | 
				
			||||||
 | 
					const skins          = ['🏻', '🏼', '🏽', '🏾', '🏿'];
 | 
				
			||||||
 | 
					const shortcodeMap   = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap)
 | 
					Object.keys(emojiIndex.emojis).forEach(key => {
 | 
				
			||||||
  .filter(c => !excluded.includes(c))
 | 
					  shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
 | 
				
			||||||
  .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]])
 | 
					});
 | 
				
			||||||
  .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] }))
 | 
					
 | 
				
			||||||
  .reduce((x, y) => Object.assign(x, y), { });
 | 
					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;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,7 +26,7 @@ export default class ActionBar extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    account: ImmutablePropTypes.map.isRequired,
 | 
					    account: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
    me: PropTypes.number.isRequired,
 | 
					    me: PropTypes.string.isRequired,
 | 
				
			||||||
    onFollow: PropTypes.func,
 | 
					    onFollow: PropTypes.func,
 | 
				
			||||||
    onBlock: PropTypes.func.isRequired,
 | 
					    onBlock: PropTypes.func.isRequired,
 | 
				
			||||||
    onMention: PropTypes.func.isRequired,
 | 
					    onMention: PropTypes.func.isRequired,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -80,7 +80,7 @@ export default class Header extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    account: ImmutablePropTypes.map,
 | 
					    account: ImmutablePropTypes.map,
 | 
				
			||||||
    me: PropTypes.number.isRequired,
 | 
					    me: PropTypes.string.isRequired,
 | 
				
			||||||
    onFollow: PropTypes.func.isRequired,
 | 
					    onFollow: PropTypes.func.isRequired,
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
    autoPlayGif: PropTypes.bool.isRequired,
 | 
					    autoPlayGif: PropTypes.bool.isRequired,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -16,9 +16,9 @@ import { ScrollContainer } from 'react-router-scroll';
 | 
				
			||||||
import LoadMore from '../../components/load_more';
 | 
					import LoadMore from '../../components/load_more';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = (state, props) => ({
 | 
					const mapStateToProps = (state, props) => ({
 | 
				
			||||||
  medias: getAccountGallery(state, Number(props.params.accountId)),
 | 
					  medias: getAccountGallery(state, props.params.accountId),
 | 
				
			||||||
  isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'isLoading']),
 | 
					  isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
 | 
				
			||||||
  hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'next']),
 | 
					  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
 | 
				
			||||||
  autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
 | 
					  autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -35,20 +35,20 @@ export default class AccountGallery extends ImmutablePureComponent {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentDidMount () {
 | 
					  componentDidMount () {
 | 
				
			||||||
    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
 | 
					    this.props.dispatch(fetchAccount(this.props.params.accountId));
 | 
				
			||||||
    this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId)));
 | 
					    this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillReceiveProps (nextProps) {
 | 
					  componentWillReceiveProps (nextProps) {
 | 
				
			||||||
    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
 | 
					    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
 | 
				
			||||||
      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
 | 
					      this.props.dispatch(fetchAccount(nextProps.params.accountId));
 | 
				
			||||||
      this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId)));
 | 
					      this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleScrollToBottom = () => {
 | 
					  handleScrollToBottom = () => {
 | 
				
			||||||
    if (this.props.hasMore) {
 | 
					    if (this.props.hasMore) {
 | 
				
			||||||
      this.props.dispatch(expandAccountMediaTimeline(Number(this.props.params.accountId)));
 | 
					      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,7 +10,7 @@ export default class Header extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    account: ImmutablePropTypes.map,
 | 
					    account: ImmutablePropTypes.map,
 | 
				
			||||||
    me: PropTypes.number.isRequired,
 | 
					    me: PropTypes.string.isRequired,
 | 
				
			||||||
    onFollow: PropTypes.func.isRequired,
 | 
					    onFollow: PropTypes.func.isRequired,
 | 
				
			||||||
    onBlock: PropTypes.func.isRequired,
 | 
					    onBlock: PropTypes.func.isRequired,
 | 
				
			||||||
    onMention: PropTypes.func.isRequired,
 | 
					    onMention: PropTypes.func.isRequired,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,7 +26,7 @@ const makeMapStateToProps = () => {
 | 
				
			||||||
  const getAccount = makeGetAccount();
 | 
					  const getAccount = makeGetAccount();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const mapStateToProps = (state, { accountId }) => ({
 | 
					  const mapStateToProps = (state, { accountId }) => ({
 | 
				
			||||||
    account: getAccount(state, Number(accountId)),
 | 
					    account: getAccount(state, accountId),
 | 
				
			||||||
    me: state.getIn(['meta', 'me']),
 | 
					    me: state.getIn(['meta', 'me']),
 | 
				
			||||||
    unfollowModal: state.getIn(['meta', 'unfollow_modal']),
 | 
					    unfollowModal: state.getIn(['meta', 'unfollow_modal']),
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -13,9 +13,9 @@ import { List as ImmutableList } from 'immutable';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = (state, props) => ({
 | 
					const mapStateToProps = (state, props) => ({
 | 
				
			||||||
  statusIds: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'items'], ImmutableList()),
 | 
					  statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
 | 
				
			||||||
  isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'isLoading']),
 | 
					  isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
 | 
				
			||||||
  hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'next']),
 | 
					  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
 | 
				
			||||||
  me: state.getIn(['meta', 'me']),
 | 
					  me: state.getIn(['meta', 'me']),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -28,24 +28,24 @@ export default class AccountTimeline extends ImmutablePureComponent {
 | 
				
			||||||
    statusIds: ImmutablePropTypes.list,
 | 
					    statusIds: ImmutablePropTypes.list,
 | 
				
			||||||
    isLoading: PropTypes.bool,
 | 
					    isLoading: PropTypes.bool,
 | 
				
			||||||
    hasMore: PropTypes.bool,
 | 
					    hasMore: PropTypes.bool,
 | 
				
			||||||
    me: PropTypes.number.isRequired,
 | 
					    me: PropTypes.string.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillMount () {
 | 
					  componentWillMount () {
 | 
				
			||||||
    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
 | 
					    this.props.dispatch(fetchAccount(this.props.params.accountId));
 | 
				
			||||||
    this.props.dispatch(refreshAccountTimeline(Number(this.props.params.accountId)));
 | 
					    this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillReceiveProps (nextProps) {
 | 
					  componentWillReceiveProps (nextProps) {
 | 
				
			||||||
    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
 | 
					    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
 | 
				
			||||||
      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
 | 
					      this.props.dispatch(fetchAccount(nextProps.params.accountId));
 | 
				
			||||||
      this.props.dispatch(refreshAccountTimeline(Number(nextProps.params.accountId)));
 | 
					      this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleScrollToBottom = () => {
 | 
					  handleScrollToBottom = () => {
 | 
				
			||||||
    if (!this.props.isLoading && this.props.hasMore) {
 | 
					    if (!this.props.isLoading && this.props.hasMore) {
 | 
				
			||||||
      this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
 | 
					      this.props.dispatch(expandAccountTimeline(this.props.params.accountId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,38 +0,0 @@
 | 
				
			||||||
import React from 'react';
 | 
					 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					 | 
				
			||||||
import emojione from 'emojione';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// This is bad, but I don't know how to make it work without importing the entirety of emojione.
 | 
					 | 
				
			||||||
// taken from some old version of mastodon before they gutted emojione to "emojione_light"
 | 
					 | 
				
			||||||
const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => {
 | 
					 | 
				
			||||||
  if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) {
 | 
					 | 
				
			||||||
    return shortname;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1];
 | 
					 | 
				
			||||||
  const alt     = emojione.convert(unicode.toUpperCase());
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${unicode}.svg" />`;
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default class AutosuggestShortcode extends ImmutablePureComponent {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  static propTypes = {
 | 
					 | 
				
			||||||
    shortcode: PropTypes.string.isRequired,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  render () {
 | 
					 | 
				
			||||||
    const { shortcode } = this.props;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let emoji = shortnameToImage(shortcode);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className='autosuggest-account'>
 | 
					 | 
				
			||||||
        <div className='autosuggest-account-icon' dangerouslySetInnerHTML={{ __html: emoji }} />
 | 
					 | 
				
			||||||
        {shortcode}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -13,7 +13,7 @@ import SpoilerButtonContainer from '../containers/spoiler_button_container';
 | 
				
			||||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
 | 
					import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
 | 
				
			||||||
import ComposeAdvancedOptionsContainer from '../../../../glitch/components/compose/advanced_options/container';
 | 
					import ComposeAdvancedOptionsContainer from '../../../../glitch/components/compose/advanced_options/container';
 | 
				
			||||||
import SensitiveButtonContainer from '../containers/sensitive_button_container';
 | 
					import SensitiveButtonContainer from '../containers/sensitive_button_container';
 | 
				
			||||||
import EmojiPickerDropdown from './emoji_picker_dropdown';
 | 
					import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
 | 
				
			||||||
import UploadFormContainer from '../containers/upload_form_container';
 | 
					import UploadFormContainer from '../containers/upload_form_container';
 | 
				
			||||||
import WarningContainer from '../containers/warning_container';
 | 
					import WarningContainer from '../containers/warning_container';
 | 
				
			||||||
import { isMobile } from '../../../is_mobile';
 | 
					import { isMobile } from '../../../is_mobile';
 | 
				
			||||||
| 
						 | 
					@ -46,7 +46,7 @@ export default class ComposeForm extends ImmutablePureComponent {
 | 
				
			||||||
    preselectDate: PropTypes.instanceOf(Date),
 | 
					    preselectDate: PropTypes.instanceOf(Date),
 | 
				
			||||||
    is_submitting: PropTypes.bool,
 | 
					    is_submitting: PropTypes.bool,
 | 
				
			||||||
    is_uploading: PropTypes.bool,
 | 
					    is_uploading: PropTypes.bool,
 | 
				
			||||||
    me: PropTypes.number,
 | 
					    me: PropTypes.string,
 | 
				
			||||||
    onChange: PropTypes.func.isRequired,
 | 
					    onChange: PropTypes.func.isRequired,
 | 
				
			||||||
    onSubmit: PropTypes.func.isRequired,
 | 
					    onSubmit: PropTypes.func.isRequired,
 | 
				
			||||||
    onClearSuggestions: PropTypes.func.isRequired,
 | 
					    onClearSuggestions: PropTypes.func.isRequired,
 | 
				
			||||||
| 
						 | 
					@ -98,10 +98,6 @@ export default class ComposeForm extends ImmutablePureComponent {
 | 
				
			||||||
    this.props.onFetchSuggestions(token);
 | 
					    this.props.onFetchSuggestions(token);
 | 
				
			||||||
  }, 500, { trailing: true })
 | 
					  }, 500, { trailing: true })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onLocalSuggestionsFetchRequested = debounce((token) => {
 | 
					 | 
				
			||||||
    this.props.onFetchSuggestions(token);
 | 
					 | 
				
			||||||
  }, 100, { trailing: true })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  onSuggestionSelected = (tokenStart, token, value) => {
 | 
					  onSuggestionSelected = (tokenStart, token, value) => {
 | 
				
			||||||
    this._restoreCaret = null;
 | 
					    this._restoreCaret = null;
 | 
				
			||||||
    this.props.onSuggestionSelected(tokenStart, token, value);
 | 
					    this.props.onSuggestionSelected(tokenStart, token, value);
 | 
				
			||||||
| 
						 | 
					@ -154,7 +150,7 @@ export default class ComposeForm extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleEmojiPick = (data) => {
 | 
					  handleEmojiPick = (data) => {
 | 
				
			||||||
    const position     = this.autosuggestTextarea.textarea.selectionStart;
 | 
					    const position     = this.autosuggestTextarea.textarea.selectionStart;
 | 
				
			||||||
    const emojiChar    = data.unicode.split('-').map(code => String.fromCodePoint(parseInt(code, 16))).join('');
 | 
					    const emojiChar    = data.native;
 | 
				
			||||||
    this._restoreCaret = position + emojiChar.length + 1;
 | 
					    this._restoreCaret = position + emojiChar.length + 1;
 | 
				
			||||||
    this.props.onPickEmoji(position, data);
 | 
					    this.props.onPickEmoji(position, data);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -238,7 +234,6 @@ export default class ComposeForm extends ImmutablePureComponent {
 | 
				
			||||||
            suggestions={this.props.suggestions}
 | 
					            suggestions={this.props.suggestions}
 | 
				
			||||||
            onKeyDown={this.handleKeyDown}
 | 
					            onKeyDown={this.handleKeyDown}
 | 
				
			||||||
            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
 | 
					            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
 | 
				
			||||||
            onLocalSuggestionsFetchRequested={this.onLocalSuggestionsFetchRequested}
 | 
					 | 
				
			||||||
            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
 | 
					            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
 | 
				
			||||||
            onSuggestionSelected={this.onSuggestionSelected}
 | 
					            onSuggestionSelected={this.onSuggestionSelected}
 | 
				
			||||||
            onPaste={onPaste}
 | 
					            onPaste={onPaste}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,12 +1,19 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
 | 
					 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import { defineMessages, injectIntl } from 'react-intl';
 | 
					import { defineMessages, injectIntl } from 'react-intl';
 | 
				
			||||||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
 | 
					import { Picker, Emoji } from 'emoji-mart';
 | 
				
			||||||
 | 
					import { Overlay } from 'react-overlays';
 | 
				
			||||||
 | 
					import classNames from 'classnames';
 | 
				
			||||||
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
 | 
					import detectPassiveEvents from 'detect-passive-events';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messages = defineMessages({
 | 
					const messages = defineMessages({
 | 
				
			||||||
  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
 | 
					  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
 | 
				
			||||||
  emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
 | 
					  emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
 | 
				
			||||||
 | 
					  emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
 | 
				
			||||||
 | 
					  custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
 | 
				
			||||||
 | 
					  recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
 | 
				
			||||||
 | 
					  search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
 | 
				
			||||||
  people: { id: 'emoji_button.people', defaultMessage: 'People' },
 | 
					  people: { id: 'emoji_button.people', defaultMessage: 'People' },
 | 
				
			||||||
  nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
 | 
					  nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
 | 
				
			||||||
  food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
 | 
					  food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
 | 
				
			||||||
| 
						 | 
					@ -17,48 +24,250 @@ const messages = defineMessages({
 | 
				
			||||||
  flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
 | 
					  flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const settings = {
 | 
					const assetHost = process.env.CDN_HOST || '';
 | 
				
			||||||
  imageType: 'png',
 | 
					const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
 | 
				
			||||||
  sprites: false,
 | 
					const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 | 
				
			||||||
  imagePathPNG: '/emoji/',
 | 
					
 | 
				
			||||||
 | 
					class ModifierPickerMenu extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    active: PropTypes.bool,
 | 
				
			||||||
 | 
					    onSelect: PropTypes.func.isRequired,
 | 
				
			||||||
 | 
					    onClose: PropTypes.func.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
let EmojiPicker; // load asynchronously
 | 
					  handleClick = (e) => {
 | 
				
			||||||
 | 
					    const modifier = [].slice.call(e.currentTarget.parentNode.children).indexOf(e.target) + 1;
 | 
				
			||||||
 | 
					    this.props.onSelect(modifier);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentWillReceiveProps (nextProps) {
 | 
				
			||||||
 | 
					    if (nextProps.active) {
 | 
				
			||||||
 | 
					      this.attachListeners();
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      this.removeListeners();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentWillUnmount () {
 | 
				
			||||||
 | 
					    this.removeListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleDocumentClick = e => {
 | 
				
			||||||
 | 
					    if (this.node && !this.node.contains(e.target)) {
 | 
				
			||||||
 | 
					      this.props.onClose();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  attachListeners () {
 | 
				
			||||||
 | 
					    document.addEventListener('click', this.handleDocumentClick, false);
 | 
				
			||||||
 | 
					    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  removeListeners () {
 | 
				
			||||||
 | 
					    document.removeEventListener('click', this.handleDocumentClick, false);
 | 
				
			||||||
 | 
					    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setRef = c => {
 | 
				
			||||||
 | 
					    this.node = c;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { active } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <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}><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}><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}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ModifierPicker extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    active: PropTypes.bool,
 | 
				
			||||||
 | 
					    modifier: PropTypes.number,
 | 
				
			||||||
 | 
					    onChange: PropTypes.func,
 | 
				
			||||||
 | 
					    onClose: PropTypes.func,
 | 
				
			||||||
 | 
					    onOpen: PropTypes.func,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleClick = () => {
 | 
				
			||||||
 | 
					    if (this.props.active) {
 | 
				
			||||||
 | 
					      this.props.onClose();
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      this.props.onOpen();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleSelect = modifier => {
 | 
				
			||||||
 | 
					    this.props.onChange(modifier);
 | 
				
			||||||
 | 
					    this.props.onClose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { active, modifier } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div className='emoji-picker-dropdown__modifiers'>
 | 
				
			||||||
 | 
					        <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
 | 
				
			||||||
 | 
					        <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@injectIntl
 | 
				
			||||||
 | 
					class EmojiPickerMenu extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    custom_emojis: ImmutablePropTypes.list,
 | 
				
			||||||
 | 
					    onClose: PropTypes.func.isRequired,
 | 
				
			||||||
 | 
					    onPick: PropTypes.func.isRequired,
 | 
				
			||||||
 | 
					    style: PropTypes.object,
 | 
				
			||||||
 | 
					    placement: PropTypes.string,
 | 
				
			||||||
 | 
					    arrowOffsetLeft: PropTypes.string,
 | 
				
			||||||
 | 
					    arrowOffsetTop: PropTypes.string,
 | 
				
			||||||
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static defaultProps = {
 | 
				
			||||||
 | 
					    style: {},
 | 
				
			||||||
 | 
					    placement: 'bottom',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  state = {
 | 
				
			||||||
 | 
					    modifierOpen: false,
 | 
				
			||||||
 | 
					    modifier: 1,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleDocumentClick = e => {
 | 
				
			||||||
 | 
					    if (this.node && !this.node.contains(e.target)) {
 | 
				
			||||||
 | 
					      this.props.onClose();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getI18n = () => {
 | 
				
			||||||
 | 
					    const { intl } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      search: intl.formatMessage(messages.emoji_search),
 | 
				
			||||||
 | 
					      notfound: intl.formatMessage(messages.emoji_not_found),
 | 
				
			||||||
 | 
					      categories: {
 | 
				
			||||||
 | 
					        search: intl.formatMessage(messages.search_results),
 | 
				
			||||||
 | 
					        recent: intl.formatMessage(messages.recent),
 | 
				
			||||||
 | 
					        people: intl.formatMessage(messages.people),
 | 
				
			||||||
 | 
					        nature: intl.formatMessage(messages.nature),
 | 
				
			||||||
 | 
					        foods: intl.formatMessage(messages.food),
 | 
				
			||||||
 | 
					        activity: intl.formatMessage(messages.activity),
 | 
				
			||||||
 | 
					        places: intl.formatMessage(messages.travel),
 | 
				
			||||||
 | 
					        objects: intl.formatMessage(messages.objects),
 | 
				
			||||||
 | 
					        symbols: intl.formatMessage(messages.symbols),
 | 
				
			||||||
 | 
					        flags: intl.formatMessage(messages.flags),
 | 
				
			||||||
 | 
					        custom: intl.formatMessage(messages.custom),
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleClick = emoji => {
 | 
				
			||||||
 | 
					    if (!emoji.native) {
 | 
				
			||||||
 | 
					      emoji.native = emoji.colons;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.props.onClose();
 | 
				
			||||||
 | 
					    this.props.onPick(emoji);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleModifierOpen = () => {
 | 
				
			||||||
 | 
					    this.setState({ modifierOpen: true });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleModifierClose = () => {
 | 
				
			||||||
 | 
					    this.setState({ modifierOpen: false });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleModifierChange = modifier => {
 | 
				
			||||||
 | 
					    if (modifier !== this.state.modifier) {
 | 
				
			||||||
 | 
					      this.setState({ modifier });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { style, intl } = this.props;
 | 
				
			||||||
 | 
					    const title = intl.formatMessage(messages.emoji);
 | 
				
			||||||
 | 
					    const { modifierOpen, modifier } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
 | 
				
			||||||
 | 
					        <Picker
 | 
				
			||||||
 | 
					          perLine={8}
 | 
				
			||||||
 | 
					          emojiSize={22}
 | 
				
			||||||
 | 
					          sheetSize={32}
 | 
				
			||||||
 | 
					          color=''
 | 
				
			||||||
 | 
					          emoji=''
 | 
				
			||||||
 | 
					          set='twitter'
 | 
				
			||||||
 | 
					          title={title}
 | 
				
			||||||
 | 
					          i18n={this.getI18n()}
 | 
				
			||||||
 | 
					          onClick={this.handleClick}
 | 
				
			||||||
 | 
					          skin={modifier}
 | 
				
			||||||
 | 
					          backgroundImageFn={backgroundImageFn}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <ModifierPicker
 | 
				
			||||||
 | 
					          active={modifierOpen}
 | 
				
			||||||
 | 
					          modifier={modifier}
 | 
				
			||||||
 | 
					          onOpen={this.handleModifierOpen}
 | 
				
			||||||
 | 
					          onClose={this.handleModifierClose}
 | 
				
			||||||
 | 
					          onChange={this.handleModifierChange}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@injectIntl
 | 
					@injectIntl
 | 
				
			||||||
export default class EmojiPickerDropdown extends React.PureComponent {
 | 
					export default class EmojiPickerDropdown extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    custom_emojis: ImmutablePropTypes.list,
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
    onPickEmoji: PropTypes.func.isRequired,
 | 
					    onPickEmoji: PropTypes.func.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  state = {
 | 
					  state = {
 | 
				
			||||||
    active: false,
 | 
					    active: false,
 | 
				
			||||||
    loading: false,
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  setRef = (c) => {
 | 
					  setRef = (c) => {
 | 
				
			||||||
    this.dropdown = c;
 | 
					    this.dropdown = c;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleChange = (data) => {
 | 
					 | 
				
			||||||
    this.dropdown.hide();
 | 
					 | 
				
			||||||
    this.props.onPickEmoji(data);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  onShowDropdown = () => {
 | 
					  onShowDropdown = () => {
 | 
				
			||||||
    this.setState({ active: true });
 | 
					    this.setState({ active: true });
 | 
				
			||||||
    if (!EmojiPicker) {
 | 
					 | 
				
			||||||
      this.setState({ loading: true });
 | 
					 | 
				
			||||||
      EmojiPickerAsync().then(TheEmojiPicker => {
 | 
					 | 
				
			||||||
        EmojiPicker = TheEmojiPicker.default;
 | 
					 | 
				
			||||||
        this.setState({ loading: false });
 | 
					 | 
				
			||||||
      }).catch(() => {
 | 
					 | 
				
			||||||
        // TODO: show the user an error?
 | 
					 | 
				
			||||||
        this.setState({ loading: false });
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onHideDropdown = () => {
 | 
					  onHideDropdown = () => {
 | 
				
			||||||
| 
						 | 
					@ -66,7 +275,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onToggle = (e) => {
 | 
					  onToggle = (e) => {
 | 
				
			||||||
    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
 | 
					    if (!e.key || e.key === 'Enter') {
 | 
				
			||||||
      if (this.state.active) {
 | 
					      if (this.state.active) {
 | 
				
			||||||
        this.onHideDropdown();
 | 
					        this.onHideDropdown();
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
| 
						 | 
					@ -75,70 +284,43 @@ export default class EmojiPickerDropdown extends React.PureComponent {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onEmojiPickerKeyDown = (e) => {
 | 
					  handleKeyDown = e => {
 | 
				
			||||||
    if (e.key === 'Escape') {
 | 
					    if (e.key === 'Escape') {
 | 
				
			||||||
      this.onHideDropdown();
 | 
					      this.onHideDropdown();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setTargetRef = c => {
 | 
				
			||||||
 | 
					    this.target = c;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  findTarget = () => {
 | 
				
			||||||
 | 
					    return this.target;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { intl } = this.props;
 | 
					    const { intl, onPickEmoji } = this.props;
 | 
				
			||||||
 | 
					 | 
				
			||||||
    const categories = {
 | 
					 | 
				
			||||||
      people: {
 | 
					 | 
				
			||||||
        title: intl.formatMessage(messages.people),
 | 
					 | 
				
			||||||
        emoji: 'smile',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      nature: {
 | 
					 | 
				
			||||||
        title: intl.formatMessage(messages.nature),
 | 
					 | 
				
			||||||
        emoji: 'hamster',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      food: {
 | 
					 | 
				
			||||||
        title: intl.formatMessage(messages.food),
 | 
					 | 
				
			||||||
        emoji: 'pizza',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      activity: {
 | 
					 | 
				
			||||||
        title: intl.formatMessage(messages.activity),
 | 
					 | 
				
			||||||
        emoji: 'soccer',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      travel: {
 | 
					 | 
				
			||||||
        title: intl.formatMessage(messages.travel),
 | 
					 | 
				
			||||||
        emoji: 'earth_americas',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      objects: {
 | 
					 | 
				
			||||||
        title: intl.formatMessage(messages.objects),
 | 
					 | 
				
			||||||
        emoji: 'bulb',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      symbols: {
 | 
					 | 
				
			||||||
        title: intl.formatMessage(messages.symbols),
 | 
					 | 
				
			||||||
        emoji: 'clock9',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      flags: {
 | 
					 | 
				
			||||||
        title: intl.formatMessage(messages.flags),
 | 
					 | 
				
			||||||
        emoji: 'flag_gb',
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const { active, loading } = this.state;
 | 
					 | 
				
			||||||
    const title = intl.formatMessage(messages.emoji);
 | 
					    const title = intl.formatMessage(messages.emoji);
 | 
				
			||||||
 | 
					    const { active } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Dropdown ref={this.setRef} className='emoji-picker__dropdown' active={active && !loading} onShow={this.onShowDropdown} onHide={this.onHideDropdown}>
 | 
					      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
 | 
				
			||||||
        <DropdownTrigger className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' 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 ${active && loading ? 'pulse-loading' : ''}`}
 | 
					            className='emojione'
 | 
				
			||||||
            alt='🙂'
 | 
					            alt='🙂'
 | 
				
			||||||
            src='/emoji/1f602.svg'
 | 
					            src={`${assetHost}/emoji/1f602.svg`}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </DropdownTrigger>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <DropdownContent className='dropdown__left'>
 | 
					        <Overlay show={active} placement='bottom' target={this.findTarget}>
 | 
				
			||||||
          {
 | 
					          <EmojiPickerMenu
 | 
				
			||||||
            this.state.active && !this.state.loading &&
 | 
					            custom_emojis={this.props.custom_emojis}
 | 
				
			||||||
            (<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} onKeyDown={this.onEmojiPickerKeyDown} categories={categories} search />)
 | 
					            onClose={this.onHideDropdown}
 | 
				
			||||||
          }
 | 
					            onPick={onPickEmoji}
 | 
				
			||||||
        </DropdownContent>
 | 
					          />
 | 
				
			||||||
      </Dropdown>
 | 
					        </Overlay>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,6 +2,7 @@ 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 detectPassiveEvents from 'detect-passive-events';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messages = defineMessages({
 | 
					const messages = defineMessages({
 | 
				
			||||||
  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
 | 
					  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
 | 
				
			||||||
| 
						 | 
					@ -89,12 +90,12 @@ export default class PrivacyDropdown extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentDidMount () {
 | 
					  componentDidMount () {
 | 
				
			||||||
    window.addEventListener('click', this.onGlobalClick);
 | 
					    window.addEventListener('click', this.onGlobalClick);
 | 
				
			||||||
    window.addEventListener('touchstart', this.onGlobalClick);
 | 
					    window.addEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillUnmount () {
 | 
					  componentWillUnmount () {
 | 
				
			||||||
    window.removeEventListener('click', this.onGlobalClick);
 | 
					    window.removeEventListener('click', this.onGlobalClick);
 | 
				
			||||||
    window.removeEventListener('touchstart', this.onGlobalClick);
 | 
					    window.removeEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  setRef = (c) => {
 | 
					  setRef = (c) => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -21,7 +21,7 @@ export default class UploadForm extends React.PureComponent {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onRemoveFile = (e) => {
 | 
					  onRemoveFile = (e) => {
 | 
				
			||||||
    const id = Number(e.currentTarget.parentElement.getAttribute('data-id'));
 | 
					    const id = e.currentTarget.parentElement.getAttribute('data-id');
 | 
				
			||||||
    this.props.onRemoveFile(id);
 | 
					    this.props.onRemoveFile(id);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					import { connect } from 'react-redux';
 | 
				
			||||||
 | 
					import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const mapStateToProps = state => ({
 | 
				
			||||||
 | 
					  custom_emojis: state.get('custom_emojis'),
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default connect(mapStateToProps)(EmojiPickerDropdown);
 | 
				
			||||||
| 
						 | 
					@ -1,51 +1,23 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
					 | 
				
			||||||
import { connect } from 'react-redux';
 | 
					import { connect } from 'react-redux';
 | 
				
			||||||
import Warning from '../components/warning';
 | 
					import Warning from '../components/warning';
 | 
				
			||||||
import { createSelector } from 'reselect';
 | 
					 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import { FormattedMessage } from 'react-intl';
 | 
					import { FormattedMessage } from 'react-intl';
 | 
				
			||||||
import { OrderedSet } from 'immutable';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
 | 
					const mapStateToProps = state => ({
 | 
				
			||||||
 | 
					  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']),
 | 
				
			||||||
const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => {
 | 
					 | 
				
			||||||
  return OrderedSet(mentionedUsernamesWithDomains !== null ? mentionedUsernamesWithDomains.map(item => item.split('@')[2]) : []);
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = state => {
 | 
					const WarningWrapper = ({ needsLockWarning }) => {
 | 
				
			||||||
  const mentionedUsernames = getMentionedUsernames(state);
 | 
					 | 
				
			||||||
  const mentionedUsernamesWithDomains = getMentionedDomains(state);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null,
 | 
					 | 
				
			||||||
    mentionedDomains: mentionedUsernamesWithDomains,
 | 
					 | 
				
			||||||
    needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']),
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => {
 | 
					 | 
				
			||||||
  if (needsLockWarning) {
 | 
					  if (needsLockWarning) {
 | 
				
			||||||
    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
 | 
					    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
 | 
				
			||||||
  } else if (needsLeakWarning) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <Warning
 | 
					 | 
				
			||||||
        message={<FormattedMessage
 | 
					 | 
				
			||||||
          id='compose_form.privacy_disclaimer'
 | 
					 | 
				
			||||||
          defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.'
 | 
					 | 
				
			||||||
          values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.size }}
 | 
					 | 
				
			||||||
        />}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return null;
 | 
					  return null;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
WarningWrapper.propTypes = {
 | 
					WarningWrapper.propTypes = {
 | 
				
			||||||
  needsLeakWarning: PropTypes.bool,
 | 
					 | 
				
			||||||
  needsLockWarning: PropTypes.bool,
 | 
					  needsLockWarning: PropTypes.bool,
 | 
				
			||||||
  mentionedDomains: ImmutablePropTypes.orderedSet.isRequired,
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default connect(mapStateToProps)(WarningWrapper);
 | 
					export default connect(mapStateToProps)(WarningWrapper);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,9 @@
 | 
				
			||||||
 | 
					import { urlRegex } from './url_regex';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
 | 
					const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function countableText(inputText) {
 | 
					export function countableText(inputText) {
 | 
				
			||||||
  return inputText
 | 
					  return inputText
 | 
				
			||||||
    .replace(/https?:\/\/\S+/g, urlPlaceholder)
 | 
					    .replace(urlRegex, urlPlaceholder)
 | 
				
			||||||
    .replace(/(?:^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+)/ig, '@$2');
 | 
					    .replace(/(?:^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+)/ig, '@$2');
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										196
									
								
								app/javascript/mastodon/features/compose/util/url_regex.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
					@ -0,0 +1,196 @@
 | 
				
			||||||
 | 
					const regexen = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const regexSupplant = function(regex, flags) {
 | 
				
			||||||
 | 
					  flags = flags || '';
 | 
				
			||||||
 | 
					  if (typeof regex !== 'string') {
 | 
				
			||||||
 | 
					    if (regex.global && flags.indexOf('g') < 0) {
 | 
				
			||||||
 | 
					      flags += 'g';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (regex.ignoreCase && flags.indexOf('i') < 0) {
 | 
				
			||||||
 | 
					      flags += 'i';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (regex.multiline && flags.indexOf('m') < 0) {
 | 
				
			||||||
 | 
					      flags += 'm';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    regex = regex.source;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) {
 | 
				
			||||||
 | 
					    var newRegex = regexen[name] || '';
 | 
				
			||||||
 | 
					    if (typeof newRegex !== 'string') {
 | 
				
			||||||
 | 
					      newRegex = newRegex.source;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return newRegex;
 | 
				
			||||||
 | 
					  }), flags);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const stringSupplant = function(str, values) {
 | 
				
			||||||
 | 
					  return str.replace(/#\{(\w+)\}/g, function(match, name) {
 | 
				
			||||||
 | 
					    return values[name] || '';
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const urlRegex = (function() {
 | 
				
			||||||
 | 
					  regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/;
 | 
				
			||||||
 | 
					  regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/;
 | 
				
			||||||
 | 
					  regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/;
 | 
				
			||||||
 | 
					  regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/);
 | 
				
			||||||
 | 
					  regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen);
 | 
				
			||||||
 | 
					  regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/);
 | 
				
			||||||
 | 
					  regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/);
 | 
				
			||||||
 | 
					  regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/);
 | 
				
			||||||
 | 
					  regexen.validGTLD = regexSupplant(RegExp(
 | 
				
			||||||
 | 
					  '(?:(?:' +
 | 
				
			||||||
 | 
					    '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' +
 | 
				
			||||||
 | 
					    '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' +
 | 
				
			||||||
 | 
					    'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' +
 | 
				
			||||||
 | 
					    'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' +
 | 
				
			||||||
 | 
					    'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' +
 | 
				
			||||||
 | 
					    'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' +
 | 
				
			||||||
 | 
					    'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' +
 | 
				
			||||||
 | 
					    'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' +
 | 
				
			||||||
 | 
					    'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' +
 | 
				
			||||||
 | 
					    'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' +
 | 
				
			||||||
 | 
					    'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' +
 | 
				
			||||||
 | 
					    'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' +
 | 
				
			||||||
 | 
					    'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' +
 | 
				
			||||||
 | 
					    'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' +
 | 
				
			||||||
 | 
					    'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' +
 | 
				
			||||||
 | 
					    'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' +
 | 
				
			||||||
 | 
					    'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' +
 | 
				
			||||||
 | 
					    'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' +
 | 
				
			||||||
 | 
					    'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' +
 | 
				
			||||||
 | 
					    'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' +
 | 
				
			||||||
 | 
					    'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' +
 | 
				
			||||||
 | 
					    'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' +
 | 
				
			||||||
 | 
					    'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' +
 | 
				
			||||||
 | 
					    'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' +
 | 
				
			||||||
 | 
					    'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' +
 | 
				
			||||||
 | 
					    'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' +
 | 
				
			||||||
 | 
					    'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' +
 | 
				
			||||||
 | 
					    'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' +
 | 
				
			||||||
 | 
					    'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' +
 | 
				
			||||||
 | 
					    'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' +
 | 
				
			||||||
 | 
					    'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' +
 | 
				
			||||||
 | 
					    'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' +
 | 
				
			||||||
 | 
					    'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' +
 | 
				
			||||||
 | 
					    'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' +
 | 
				
			||||||
 | 
					    'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' +
 | 
				
			||||||
 | 
					    'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' +
 | 
				
			||||||
 | 
					    'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' +
 | 
				
			||||||
 | 
					    'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' +
 | 
				
			||||||
 | 
					    'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' +
 | 
				
			||||||
 | 
					    'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' +
 | 
				
			||||||
 | 
					    'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' +
 | 
				
			||||||
 | 
					    'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' +
 | 
				
			||||||
 | 
					    'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' +
 | 
				
			||||||
 | 
					    'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' +
 | 
				
			||||||
 | 
					    'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' +
 | 
				
			||||||
 | 
					    'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' +
 | 
				
			||||||
 | 
					    'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' +
 | 
				
			||||||
 | 
					    'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' +
 | 
				
			||||||
 | 
					    'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' +
 | 
				
			||||||
 | 
					    'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' +
 | 
				
			||||||
 | 
					    'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' +
 | 
				
			||||||
 | 
					    'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' +
 | 
				
			||||||
 | 
					    'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' +
 | 
				
			||||||
 | 
					    'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' +
 | 
				
			||||||
 | 
					    'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' +
 | 
				
			||||||
 | 
					    'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' +
 | 
				
			||||||
 | 
					    'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' +
 | 
				
			||||||
 | 
					    'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' +
 | 
				
			||||||
 | 
					    'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' +
 | 
				
			||||||
 | 
					    'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' +
 | 
				
			||||||
 | 
					    'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' +
 | 
				
			||||||
 | 
					    'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' +
 | 
				
			||||||
 | 
					    'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' +
 | 
				
			||||||
 | 
					    'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' +
 | 
				
			||||||
 | 
					    'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' +
 | 
				
			||||||
 | 
					    'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' +
 | 
				
			||||||
 | 
					    'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' +
 | 
				
			||||||
 | 
					    'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' +
 | 
				
			||||||
 | 
					    'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' +
 | 
				
			||||||
 | 
					    'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' +
 | 
				
			||||||
 | 
					    'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' +
 | 
				
			||||||
 | 
					    'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' +
 | 
				
			||||||
 | 
					    'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' +
 | 
				
			||||||
 | 
					    'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' +
 | 
				
			||||||
 | 
					    'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' +
 | 
				
			||||||
 | 
					    'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' +
 | 
				
			||||||
 | 
					    'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' +
 | 
				
			||||||
 | 
					    'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' +
 | 
				
			||||||
 | 
					    'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' +
 | 
				
			||||||
 | 
					    'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' +
 | 
				
			||||||
 | 
					    'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' +
 | 
				
			||||||
 | 
					    'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' +
 | 
				
			||||||
 | 
					    'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' +
 | 
				
			||||||
 | 
					    'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' +
 | 
				
			||||||
 | 
					    'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' +
 | 
				
			||||||
 | 
					    'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' +
 | 
				
			||||||
 | 
					    'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' +
 | 
				
			||||||
 | 
					    'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' +
 | 
				
			||||||
 | 
					  ')(?=[^0-9a-zA-Z@]|$))'));
 | 
				
			||||||
 | 
					  regexen.validCCTLD = regexSupplant(RegExp(
 | 
				
			||||||
 | 
					  '(?:(?:' +
 | 
				
			||||||
 | 
					      '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' +
 | 
				
			||||||
 | 
					      'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' +
 | 
				
			||||||
 | 
					      'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' +
 | 
				
			||||||
 | 
					      'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' +
 | 
				
			||||||
 | 
					      'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' +
 | 
				
			||||||
 | 
					      're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' +
 | 
				
			||||||
 | 
					      'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' +
 | 
				
			||||||
 | 
					      'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' +
 | 
				
			||||||
 | 
					      'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' +
 | 
				
			||||||
 | 
					      'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' +
 | 
				
			||||||
 | 
					      'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' +
 | 
				
			||||||
 | 
					  ')(?=[^0-9a-zA-Z@]|$))'));
 | 
				
			||||||
 | 
					  regexen.validPunycode = /(?:xn--[0-9a-z]+)/;
 | 
				
			||||||
 | 
					  regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/;
 | 
				
			||||||
 | 
					  regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/);
 | 
				
			||||||
 | 
					  regexen.validPortNumber = /[0-9]+/;
 | 
				
			||||||
 | 
					  regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/;
 | 
				
			||||||
 | 
					  regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}\(\)\?]/i);
 | 
				
			||||||
 | 
					  // Allow URL paths to contain up to two nested levels of balanced parens
 | 
				
			||||||
 | 
					  //  1. Used in Wikipedia URLs like /Primer_(film)
 | 
				
			||||||
 | 
					  //  2. Used in IIS sessions like /S(dfd346)/
 | 
				
			||||||
 | 
					  //  3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/
 | 
				
			||||||
 | 
					  regexen.validUrlBalancedParens = regexSupplant(
 | 
				
			||||||
 | 
					    '\\('                                   +
 | 
				
			||||||
 | 
					      '(?:'                                 +
 | 
				
			||||||
 | 
					        '#{validGeneralUrlPathChars}+'      +
 | 
				
			||||||
 | 
					        '|'                                 +
 | 
				
			||||||
 | 
					        // allow one nested level of balanced parentheses
 | 
				
			||||||
 | 
					        '(?:'                               +
 | 
				
			||||||
 | 
					          '#{validGeneralUrlPathChars}*'    +
 | 
				
			||||||
 | 
					          '\\('                             +
 | 
				
			||||||
 | 
					            '#{validGeneralUrlPathChars}+'  +
 | 
				
			||||||
 | 
					          '\\)'                             +
 | 
				
			||||||
 | 
					          '#{validGeneralUrlPathChars}*'    +
 | 
				
			||||||
 | 
					        ')'                                 +
 | 
				
			||||||
 | 
					      ')'                                   +
 | 
				
			||||||
 | 
					    '\\)'
 | 
				
			||||||
 | 
					  , 'i');
 | 
				
			||||||
 | 
					  // Valid end-of-path chracters (so /foo. does not gobble the period).
 | 
				
			||||||
 | 
					  // 1. Allow =&# for empty URL parameters and other URL-join artifacts
 | 
				
			||||||
 | 
					  regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}~&\|@]|(?:#{validUrlBalancedParens})/i);
 | 
				
			||||||
 | 
					  // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/
 | 
				
			||||||
 | 
					  regexen.validUrlPath = regexSupplant('(?:' +
 | 
				
			||||||
 | 
					    '(?:' +
 | 
				
			||||||
 | 
					      '#{validGeneralUrlPathChars}*' +
 | 
				
			||||||
 | 
					        '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' +
 | 
				
			||||||
 | 
					        '#{validUrlPathEndingChars}'+
 | 
				
			||||||
 | 
					      ')|(?:@#{validGeneralUrlPathChars}+\/)'+
 | 
				
			||||||
 | 
					    ')', 'i');
 | 
				
			||||||
 | 
					  regexen.validUrlQueryChars = /[a-z0-9!?\*'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i;
 | 
				
			||||||
 | 
					  regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i;
 | 
				
			||||||
 | 
					  regexen.validUrl = regexSupplant(
 | 
				
			||||||
 | 
					    '('                                                          + // $1 URL
 | 
				
			||||||
 | 
					      '(https?:\\/\\/)'                                          + // $2 Protocol
 | 
				
			||||||
 | 
					      '(#{validDomain})'                                         + // $3 Domain(s)
 | 
				
			||||||
 | 
					      '(?::(#{validPortNumber}))?'                               + // $4 Port number (optional)
 | 
				
			||||||
 | 
					      '(\\/#{validUrlPath}*)?'                                   + // $5 URL Path
 | 
				
			||||||
 | 
					      '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $6 Query String
 | 
				
			||||||
 | 
					    ')'
 | 
				
			||||||
 | 
					  , 'gi');
 | 
				
			||||||
 | 
					  return regexen.validUrl;
 | 
				
			||||||
 | 
					}());
 | 
				
			||||||
| 
						 | 
					@ -11,7 +11,7 @@ import ColumnBackButton from '../../components/column_back_button';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = (state, props) => ({
 | 
					const mapStateToProps = (state, props) => ({
 | 
				
			||||||
  accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)]),
 | 
					  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@connect(mapStateToProps)
 | 
					@connect(mapStateToProps)
 | 
				
			||||||
| 
						 | 
					@ -24,12 +24,12 @@ export default class Favourites extends ImmutablePureComponent {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillMount () {
 | 
					  componentWillMount () {
 | 
				
			||||||
    this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
 | 
					    this.props.dispatch(fetchFavourites(this.props.params.statusId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillReceiveProps (nextProps) {
 | 
					  componentWillReceiveProps (nextProps) {
 | 
				
			||||||
    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
 | 
					    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
 | 
				
			||||||
      this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
 | 
					      this.props.dispatch(fetchFavourites(nextProps.params.statusId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,8 +17,8 @@ import ColumnBackButton from '../../components/column_back_button';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = (state, props) => ({
 | 
					const mapStateToProps = (state, props) => ({
 | 
				
			||||||
  accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items']),
 | 
					  accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
 | 
				
			||||||
  hasMore: !!state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'next']),
 | 
					  hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@connect(mapStateToProps)
 | 
					@connect(mapStateToProps)
 | 
				
			||||||
| 
						 | 
					@ -32,14 +32,14 @@ export default class Followers extends ImmutablePureComponent {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillMount () {
 | 
					  componentWillMount () {
 | 
				
			||||||
    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
 | 
					    this.props.dispatch(fetchAccount(this.props.params.accountId));
 | 
				
			||||||
    this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
 | 
					    this.props.dispatch(fetchFollowers(this.props.params.accountId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillReceiveProps (nextProps) {
 | 
					  componentWillReceiveProps (nextProps) {
 | 
				
			||||||
    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
 | 
					    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
 | 
				
			||||||
      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
 | 
					      this.props.dispatch(fetchAccount(nextProps.params.accountId));
 | 
				
			||||||
      this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
 | 
					      this.props.dispatch(fetchFollowers(nextProps.params.accountId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -47,13 +47,13 @@ export default class Followers extends ImmutablePureComponent {
 | 
				
			||||||
    const { scrollTop, scrollHeight, clientHeight } = e.target;
 | 
					    const { scrollTop, scrollHeight, clientHeight } = e.target;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
 | 
					    if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
 | 
				
			||||||
      this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
 | 
					      this.props.dispatch(expandFollowers(this.props.params.accountId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleLoadMore = (e) => {
 | 
					  handleLoadMore = (e) => {
 | 
				
			||||||
    e.preventDefault();
 | 
					    e.preventDefault();
 | 
				
			||||||
    this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
 | 
					    this.props.dispatch(expandFollowers(this.props.params.accountId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,8 +17,8 @@ import ColumnBackButton from '../../components/column_back_button';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = (state, props) => ({
 | 
					const mapStateToProps = (state, props) => ({
 | 
				
			||||||
  accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items']),
 | 
					  accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
 | 
				
			||||||
  hasMore: !!state.getIn(['user_lists', 'following', Number(props.params.accountId), 'next']),
 | 
					  hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@connect(mapStateToProps)
 | 
					@connect(mapStateToProps)
 | 
				
			||||||
| 
						 | 
					@ -32,14 +32,14 @@ export default class Following extends ImmutablePureComponent {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillMount () {
 | 
					  componentWillMount () {
 | 
				
			||||||
    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
 | 
					    this.props.dispatch(fetchAccount(this.props.params.accountId));
 | 
				
			||||||
    this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
 | 
					    this.props.dispatch(fetchFollowing(this.props.params.accountId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillReceiveProps (nextProps) {
 | 
					  componentWillReceiveProps (nextProps) {
 | 
				
			||||||
    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
 | 
					    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
 | 
				
			||||||
      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
 | 
					      this.props.dispatch(fetchAccount(nextProps.params.accountId));
 | 
				
			||||||
      this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
 | 
					      this.props.dispatch(fetchFollowing(nextProps.params.accountId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -47,13 +47,13 @@ export default class Following extends ImmutablePureComponent {
 | 
				
			||||||
    const { scrollTop, scrollHeight, clientHeight } = e.target;
 | 
					    const { scrollTop, scrollHeight, clientHeight } = e.target;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
 | 
					    if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
 | 
				
			||||||
      this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
 | 
					      this.props.dispatch(expandFollowing(this.props.params.accountId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleLoadMore = (e) => {
 | 
					  handleLoadMore = (e) => {
 | 
				
			||||||
    e.preventDefault();
 | 
					    e.preventDefault();
 | 
				
			||||||
    this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
 | 
					    this.props.dispatch(expandFollowing(this.props.params.accountId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,7 +11,7 @@ import ColumnBackButton from '../../components/column_back_button';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = (state, props) => ({
 | 
					const mapStateToProps = (state, props) => ({
 | 
				
			||||||
  accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)]),
 | 
					  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@connect(mapStateToProps)
 | 
					@connect(mapStateToProps)
 | 
				
			||||||
| 
						 | 
					@ -24,12 +24,12 @@ export default class Reblogs extends ImmutablePureComponent {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillMount () {
 | 
					  componentWillMount () {
 | 
				
			||||||
    this.props.dispatch(fetchReblogs(Number(this.props.params.statusId)));
 | 
					    this.props.dispatch(fetchReblogs(this.props.params.statusId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillReceiveProps(nextProps) {
 | 
					  componentWillReceiveProps(nextProps) {
 | 
				
			||||||
    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
 | 
					    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
 | 
				
			||||||
      this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId)));
 | 
					      this.props.dispatch(fetchReblogs(nextProps.params.statusId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,6 +2,7 @@ import React from 'react';
 | 
				
			||||||
import ComposeFormContainer from '../../compose/containers/compose_form_container';
 | 
					import ComposeFormContainer from '../../compose/containers/compose_form_container';
 | 
				
			||||||
import NotificationsContainer from '../../ui/containers/notifications_container';
 | 
					import NotificationsContainer from '../../ui/containers/notifications_container';
 | 
				
			||||||
import LoadingBarContainer from '../../ui/containers/loading_bar_container';
 | 
					import LoadingBarContainer from '../../ui/containers/loading_bar_container';
 | 
				
			||||||
 | 
					import ModalContainer from '../../ui/containers/modal_container';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class Compose extends React.PureComponent {
 | 
					export default class Compose extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,6 +11,7 @@ export default class Compose extends React.PureComponent {
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <ComposeFormContainer />
 | 
					        <ComposeFormContainer />
 | 
				
			||||||
        <NotificationsContainer />
 | 
					        <NotificationsContainer />
 | 
				
			||||||
 | 
					        <ModalContainer />
 | 
				
			||||||
        <LoadingBarContainer className='loading-bar' />
 | 
					        <LoadingBarContainer className='loading-bar' />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -36,7 +36,7 @@ export default class ActionBar extends React.PureComponent {
 | 
				
			||||||
    onReport: PropTypes.func,
 | 
					    onReport: PropTypes.func,
 | 
				
			||||||
    onPin: PropTypes.func,
 | 
					    onPin: PropTypes.func,
 | 
				
			||||||
    onEmbed: PropTypes.func,
 | 
					    onEmbed: PropTypes.func,
 | 
				
			||||||
    me: PropTypes.number.isRequired,
 | 
					    me: PropTypes.string.isRequired,
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,5 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
import punycode from 'punycode';
 | 
					import punycode from 'punycode';
 | 
				
			||||||
import classnames from 'classnames';
 | 
					import classnames from 'classnames';
 | 
				
			||||||
| 
						 | 
					@ -22,10 +23,15 @@ export default class Card extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    card: ImmutablePropTypes.map,
 | 
					    card: ImmutablePropTypes.map,
 | 
				
			||||||
 | 
					    maxDescription: PropTypes.number,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static defaultProps = {
 | 
				
			||||||
 | 
					    maxDescription: 50,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  renderLink () {
 | 
					  renderLink () {
 | 
				
			||||||
    const { card } = this.props;
 | 
					    const { card, maxDescription } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let image    = '';
 | 
					    let image    = '';
 | 
				
			||||||
    let provider = card.get('provider_name');
 | 
					    let provider = card.get('provider_name');
 | 
				
			||||||
| 
						 | 
					@ -52,7 +58,7 @@ export default class Card extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className='status-card__content'>
 | 
					        <div className='status-card__content'>
 | 
				
			||||||
          <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>
 | 
					          <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>
 | 
				
			||||||
          <p className='status-card__description'>{(card.get('description') || '').substring(0, 50)}</p>
 | 
					          <p className='status-card__description'>{(card.get('description') || '').substring(0, maxDescription)}</p>
 | 
				
			||||||
          <span className='status-card__host'>{provider}</span>
 | 
					          <span className='status-card__host'>{provider}</span>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </a>
 | 
					      </a>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,6 +11,7 @@ import Link from 'react-router-dom/Link';
 | 
				
			||||||
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';
 | 
				
			||||||
 | 
					import Video from '../../video';
 | 
				
			||||||
import VisibilityIcon from '../../../../glitch/components/status/visibility_icon';
 | 
					import VisibilityIcon from '../../../../glitch/components/status/visibility_icon';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class DetailedStatus extends ImmutablePureComponent {
 | 
					export default class DetailedStatus extends ImmutablePureComponent {
 | 
				
			||||||
| 
						 | 
					@ -36,6 +37,10 @@ export default class DetailedStatus extends ImmutablePureComponent {
 | 
				
			||||||
    e.stopPropagation();
 | 
					    e.stopPropagation();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleOpenVideo = startTime => {
 | 
				
			||||||
 | 
					    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
 | 
					    const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
 | 
				
			||||||
    const { settings } = this.props;
 | 
					    const { settings } = this.props;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -38,10 +38,10 @@ const makeMapStateToProps = () => {
 | 
				
			||||||
  const getStatus = makeGetStatus();
 | 
					  const getStatus = makeGetStatus();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const mapStateToProps = (state, props) => ({
 | 
					  const mapStateToProps = (state, props) => ({
 | 
				
			||||||
    status: getStatus(state, Number(props.params.statusId)),
 | 
					    status: getStatus(state, props.params.statusId),
 | 
				
			||||||
    settings: state.get('local_settings'),
 | 
					    settings: state.get('local_settings'),
 | 
				
			||||||
    ancestorsIds: state.getIn(['contexts', 'ancestors', Number(props.params.statusId)]),
 | 
					    ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
 | 
				
			||||||
    descendantsIds: state.getIn(['contexts', 'descendants', Number(props.params.statusId)]),
 | 
					    descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
 | 
				
			||||||
    me: state.getIn(['meta', 'me']),
 | 
					    me: state.getIn(['meta', 'me']),
 | 
				
			||||||
    boostModal: state.getIn(['meta', 'boost_modal']),
 | 
					    boostModal: state.getIn(['meta', 'boost_modal']),
 | 
				
			||||||
    deleteModal: state.getIn(['meta', 'delete_modal']),
 | 
					    deleteModal: state.getIn(['meta', 'delete_modal']),
 | 
				
			||||||
| 
						 | 
					@ -66,7 +66,7 @@ export default class Status extends ImmutablePureComponent {
 | 
				
			||||||
    settings: ImmutablePropTypes.map.isRequired,
 | 
					    settings: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
    ancestorsIds: ImmutablePropTypes.list,
 | 
					    ancestorsIds: ImmutablePropTypes.list,
 | 
				
			||||||
    descendantsIds: ImmutablePropTypes.list,
 | 
					    descendantsIds: ImmutablePropTypes.list,
 | 
				
			||||||
    me: PropTypes.number,
 | 
					    me: PropTypes.string,
 | 
				
			||||||
    boostModal: PropTypes.bool,
 | 
					    boostModal: PropTypes.bool,
 | 
				
			||||||
    deleteModal: PropTypes.bool,
 | 
					    deleteModal: PropTypes.bool,
 | 
				
			||||||
    autoPlayGif: PropTypes.bool,
 | 
					    autoPlayGif: PropTypes.bool,
 | 
				
			||||||
| 
						 | 
					@ -74,12 +74,12 @@ export default class Status extends ImmutablePureComponent {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillMount () {
 | 
					  componentWillMount () {
 | 
				
			||||||
    this.props.dispatch(fetchStatus(Number(this.props.params.statusId)));
 | 
					    this.props.dispatch(fetchStatus(this.props.params.statusId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillReceiveProps (nextProps) {
 | 
					  componentWillReceiveProps (nextProps) {
 | 
				
			||||||
    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
 | 
					    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
 | 
				
			||||||
      this.props.dispatch(fetchStatus(Number(nextProps.params.statusId)));
 | 
					      this.props.dispatch(fetchStatus(nextProps.params.statusId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,32 +1,35 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
import StatusContent from '../../../components/status_content';
 | 
					import StatusContent from '../../../components/status_content';
 | 
				
			||||||
import Avatar from '../../../components/avatar';
 | 
					import Avatar from '../../../components/avatar';
 | 
				
			||||||
import RelativeTimestamp from '../../../components/relative_timestamp';
 | 
					import RelativeTimestamp from '../../../components/relative_timestamp';
 | 
				
			||||||
import DisplayName from '../../../components/display_name';
 | 
					import DisplayName from '../../../components/display_name';
 | 
				
			||||||
import IconButton from '../../../components/icon_button';
 | 
					import IconButton from '../../../components/icon_button';
 | 
				
			||||||
 | 
					import classNames from 'classnames';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class ActionsModal extends ImmutablePureComponent {
 | 
					export default class ActionsModal extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    status: ImmutablePropTypes.map,
 | 
				
			||||||
    actions: PropTypes.array,
 | 
					    actions: PropTypes.array,
 | 
				
			||||||
    onClick: PropTypes.func,
 | 
					    onClick: PropTypes.func,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  renderAction = (action, i) => {
 | 
					  renderAction = (action, i) => {
 | 
				
			||||||
    if (action === null) {
 | 
					    if (action === null) {
 | 
				
			||||||
      return <li key={`sep-${i}`} className='dropdown__sep' />;
 | 
					      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { icon = null, text, meta = null, active = false, href = '#' } = action;
 | 
					    const { icon = null, text, meta = null, active = false, href = '#' } = action;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <li key={`${text}-${i}`}>
 | 
					      <li key={`${text}-${i}`}>
 | 
				
			||||||
        <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={active && 'active'}>
 | 
					        <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
 | 
				
			||||||
          {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
 | 
					          {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
 | 
				
			||||||
          <div>
 | 
					          <div>
 | 
				
			||||||
            <div>{text}</div>
 | 
					            <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
 | 
				
			||||||
            <div>{meta}</div>
 | 
					            <div>{meta}</div>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,17 +3,28 @@ import PropTypes from 'prop-types';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import Column from '../../../components/column';
 | 
					import Column from '../../../components/column';
 | 
				
			||||||
import ColumnHeader from '../../../components/column_header';
 | 
					import ColumnHeader from '../../../components/column_header';
 | 
				
			||||||
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ColumnLoading = ({ title = '', icon = ' ' }) => (
 | 
					export default class ColumnLoading extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
 | 
				
			||||||
 | 
					    icon: PropTypes.string,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static defaultProps = {
 | 
				
			||||||
 | 
					    title: '',
 | 
				
			||||||
 | 
					    icon: '',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render() {
 | 
				
			||||||
 | 
					    let { title, icon } = this.props;
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
      <Column>
 | 
					      <Column>
 | 
				
			||||||
        <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} />
 | 
					        <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} />
 | 
				
			||||||
        <div className='scrollable' />
 | 
					        <div className='scrollable' />
 | 
				
			||||||
      </Column>
 | 
					      </Column>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ColumnLoading.propTypes = {
 | 
					}
 | 
				
			||||||
  title: PropTypes.node,
 | 
					 | 
				
			||||||
  icon: PropTypes.string,
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default ColumnLoading;
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -78,7 +78,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleChildrenContentChange() {
 | 
					  handleChildrenContentChange() {
 | 
				
			||||||
    if (!this.props.singleColumn) {
 | 
					    if (!this.props.singleColumn) {
 | 
				
			||||||
      scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
 | 
					      this._interruptScrollAnimation = scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,7 +10,10 @@ import ComposeForm from '../../compose/components/compose_form';
 | 
				
			||||||
import Search from '../../compose/components/search';
 | 
					import Search from '../../compose/components/search';
 | 
				
			||||||
import NavigationBar from '../../compose/components/navigation_bar';
 | 
					import NavigationBar from '../../compose/components/navigation_bar';
 | 
				
			||||||
import ColumnHeader from './column_header';
 | 
					import ColumnHeader from './column_header';
 | 
				
			||||||
import { List as ImmutableList } from 'immutable';
 | 
					import {
 | 
				
			||||||
 | 
					  List as ImmutableList,
 | 
				
			||||||
 | 
					  Map as ImmutableMap,
 | 
				
			||||||
 | 
					} from 'immutable';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const noop = () => { };
 | 
					const noop = () => { };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -59,7 +62,9 @@ const PageTwo = ({ me }) => (
 | 
				
			||||||
        onClearSuggestions={noop}
 | 
					        onClearSuggestions={noop}
 | 
				
			||||||
        onFetchSuggestions={noop}
 | 
					        onFetchSuggestions={noop}
 | 
				
			||||||
        onSuggestionSelected={noop}
 | 
					        onSuggestionSelected={noop}
 | 
				
			||||||
 | 
					        onPrivacyChange={noop}
 | 
				
			||||||
        showSearch
 | 
					        showSearch
 | 
				
			||||||
 | 
					        settings={ImmutableMap.of('side_arm', 'none')}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,35 +1,29 @@
 | 
				
			||||||
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 PropTypes from 'prop-types';
 | 
				
			||||||
import ExtendedVideoPlayer from '../../../components/extended_video_player';
 | 
					import Video from '../../video';
 | 
				
			||||||
import { defineMessages, injectIntl } from 'react-intl';
 | 
					 | 
				
			||||||
import IconButton from '../../../components/icon_button';
 | 
					 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messages = defineMessages({
 | 
					 | 
				
			||||||
  close: { id: 'lightbox.close', defaultMessage: 'Close' },
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@injectIntl
 | 
					 | 
				
			||||||
export default class VideoModal extends ImmutablePureComponent {
 | 
					export default class VideoModal extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    media: ImmutablePropTypes.map.isRequired,
 | 
					    media: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
    time: PropTypes.number,
 | 
					    time: PropTypes.number,
 | 
				
			||||||
    onClose: PropTypes.func.isRequired,
 | 
					    onClose: PropTypes.func.isRequired,
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { media, intl, time, onClose } = this.props;
 | 
					    const { media, time, onClose } = this.props;
 | 
				
			||||||
 | 
					 | 
				
			||||||
    const url = media.get('url');
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className='modal-root__modal media-modal'>
 | 
					      <div className='modal-root__modal media-modal'>
 | 
				
			||||||
        <div>
 | 
					        <div>
 | 
				
			||||||
          <div className='media-modal__close'><IconButton title={intl.formatMessage(messages.close)} icon='times' overlay onClick={onClose} /></div>
 | 
					          <Video
 | 
				
			||||||
          <ExtendedVideoPlayer src={url} muted={false} controls time={time} />
 | 
					            preview={media.get('preview_url')}
 | 
				
			||||||
 | 
					            src={media.get('url')}
 | 
				
			||||||
 | 
					            startTime={time}
 | 
				
			||||||
 | 
					            onCloseVideo={onClose}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,7 +11,7 @@ import { debounce } from 'lodash';
 | 
				
			||||||
import { uploadCompose } from '../../actions/compose';
 | 
					import { uploadCompose } from '../../actions/compose';
 | 
				
			||||||
import { refreshHomeTimeline } from '../../actions/timelines';
 | 
					import { refreshHomeTimeline } from '../../actions/timelines';
 | 
				
			||||||
import { refreshNotifications } from '../../actions/notifications';
 | 
					import { refreshNotifications } from '../../actions/notifications';
 | 
				
			||||||
import { clearStatusesHeight } from '../../actions/statuses';
 | 
					import { clearHeight } from '../../actions/height_cache';
 | 
				
			||||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 | 
					import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 | 
				
			||||||
import UploadArea from './components/upload_area';
 | 
					import UploadArea from './components/upload_area';
 | 
				
			||||||
import ColumnsAreaContainer from './containers/columns_area_container';
 | 
					import ColumnsAreaContainer from './containers/columns_area_container';
 | 
				
			||||||
| 
						 | 
					@ -57,7 +57,7 @@ export default class UI extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static contextTypes = {
 | 
					  static contextTypes = {
 | 
				
			||||||
    router: PropTypes.object.isRequired,
 | 
					    router: PropTypes.object.isRequired,
 | 
				
			||||||
  }
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    dispatch: PropTypes.func.isRequired,
 | 
					    dispatch: PropTypes.func.isRequired,
 | 
				
			||||||
| 
						 | 
					@ -77,7 +77,7 @@ export default class UI extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleResize = debounce(() => {
 | 
					  handleResize = debounce(() => {
 | 
				
			||||||
    // The cached heights are no longer accurate, invalidate
 | 
					    // The cached heights are no longer accurate, invalidate
 | 
				
			||||||
    this.props.dispatch(clearStatusesHeight());
 | 
					    this.props.dispatch(clearHeight());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.setState({ width: window.innerWidth });
 | 
					    this.setState({ width: window.innerWidth });
 | 
				
			||||||
  }, 500, {
 | 
					  }, 500, {
 | 
				
			||||||
| 
						 | 
					@ -193,14 +193,18 @@ export default class UI extends React.PureComponent {
 | 
				
			||||||
    document.removeEventListener('dragend', this.handleDragEnd);
 | 
					    document.removeEventListener('dragend', this.handleDragEnd);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  setRef = (c) => {
 | 
					  setRef = c => {
 | 
				
			||||||
    this.node = c;
 | 
					    this.node = c;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  setColumnsAreaRef = (c) => {
 | 
					  setColumnsAreaRef = c => {
 | 
				
			||||||
    this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
 | 
					    this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setOverlayRef = c => {
 | 
				
			||||||
 | 
					    this.overlay = c;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { width, draggingOver } = this.state;
 | 
					    const { width, draggingOver } = this.state;
 | 
				
			||||||
    const { children, layout, isWide, navbarUnder } = this.props;
 | 
					    const { children, layout, isWide, navbarUnder } = this.props;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,3 @@
 | 
				
			||||||
export function EmojiPicker () {
 | 
					 | 
				
			||||||
  return import(/* webpackChunkName: "emojione_picker" */'emojione-picker');
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function Compose () {
 | 
					export function Compose () {
 | 
				
			||||||
  return import(/* webpackChunkName: "features/compose" */'../../compose');
 | 
					  return import(/* webpackChunkName: "features/compose" */'../../compose');
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -109,6 +105,10 @@ export function VideoPlayer () {
 | 
				
			||||||
  return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
 | 
					  return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function Video () {
 | 
				
			||||||
 | 
					  return import(/* webpackChunkName: "features/video" */'../../video');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function EmbedModal () {
 | 
					export function EmbedModal () {
 | 
				
			||||||
  return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
 | 
					  return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										304
									
								
								app/javascript/mastodon/features/video/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
					@ -0,0 +1,304 @@
 | 
				
			||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
 | 
					import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
				
			||||||
 | 
					import { throttle } from 'lodash';
 | 
				
			||||||
 | 
					import classNames from 'classnames';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const messages = defineMessages({
 | 
				
			||||||
 | 
					  play: { id: 'video.play', defaultMessage: 'Play' },
 | 
				
			||||||
 | 
					  pause: { id: 'video.pause', defaultMessage: 'Pause' },
 | 
				
			||||||
 | 
					  mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
 | 
				
			||||||
 | 
					  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
 | 
				
			||||||
 | 
					  hide: { id: 'video.hide', defaultMessage: 'Hide video' },
 | 
				
			||||||
 | 
					  expand: { id: 'video.expand', defaultMessage: 'Expand video' },
 | 
				
			||||||
 | 
					  close: { id: 'video.close', defaultMessage: 'Close video' },
 | 
				
			||||||
 | 
					  fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
 | 
				
			||||||
 | 
					  exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const findElementPosition = el => {
 | 
				
			||||||
 | 
					  let box;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (el.getBoundingClientRect && el.parentNode) {
 | 
				
			||||||
 | 
					    box = el.getBoundingClientRect();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!box) {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      left: 0,
 | 
				
			||||||
 | 
					      top: 0,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const docEl = document.documentElement;
 | 
				
			||||||
 | 
					  const body  = document.body;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const clientLeft = docEl.clientLeft || body.clientLeft || 0;
 | 
				
			||||||
 | 
					  const scrollLeft = window.pageXOffset || body.scrollLeft;
 | 
				
			||||||
 | 
					  const left       = (box.left + scrollLeft) - clientLeft;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const clientTop = docEl.clientTop || body.clientTop || 0;
 | 
				
			||||||
 | 
					  const scrollTop = window.pageYOffset || body.scrollTop;
 | 
				
			||||||
 | 
					  const top       = (box.top + scrollTop) - clientTop;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    left: Math.round(left),
 | 
				
			||||||
 | 
					    top: Math.round(top),
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getPointerPosition = (el, event) => {
 | 
				
			||||||
 | 
					  const position = {};
 | 
				
			||||||
 | 
					  const box = findElementPosition(el);
 | 
				
			||||||
 | 
					  const boxW = el.offsetWidth;
 | 
				
			||||||
 | 
					  const boxH = el.offsetHeight;
 | 
				
			||||||
 | 
					  const boxY = box.top;
 | 
				
			||||||
 | 
					  const boxX = box.left;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let pageY = event.pageY;
 | 
				
			||||||
 | 
					  let pageX = event.pageX;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (event.changedTouches) {
 | 
				
			||||||
 | 
					    pageX = event.changedTouches[0].pageX;
 | 
				
			||||||
 | 
					    pageY = event.changedTouches[0].pageY;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
 | 
				
			||||||
 | 
					  position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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
 | 
				
			||||||
 | 
					export default class Video extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    preview: PropTypes.string,
 | 
				
			||||||
 | 
					    src: PropTypes.string.isRequired,
 | 
				
			||||||
 | 
					    width: PropTypes.number,
 | 
				
			||||||
 | 
					    height: PropTypes.number,
 | 
				
			||||||
 | 
					    sensitive: PropTypes.bool,
 | 
				
			||||||
 | 
					    startTime: PropTypes.number,
 | 
				
			||||||
 | 
					    onOpenVideo: PropTypes.func,
 | 
				
			||||||
 | 
					    onCloseVideo: PropTypes.func,
 | 
				
			||||||
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  state = {
 | 
				
			||||||
 | 
					    progress: 0,
 | 
				
			||||||
 | 
					    paused: true,
 | 
				
			||||||
 | 
					    dragging: false,
 | 
				
			||||||
 | 
					    fullscreen: false,
 | 
				
			||||||
 | 
					    hovered: false,
 | 
				
			||||||
 | 
					    muted: false,
 | 
				
			||||||
 | 
					    revealed: !this.props.sensitive,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setPlayerRef = c => {
 | 
				
			||||||
 | 
					    this.player = c;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setVideoRef = c => {
 | 
				
			||||||
 | 
					    this.video = c;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setSeekRef = c => {
 | 
				
			||||||
 | 
					    this.seek = c;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handlePlay = () => {
 | 
				
			||||||
 | 
					    this.setState({ paused: false });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handlePause = () => {
 | 
				
			||||||
 | 
					    this.setState({ paused: true });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleTimeUpdate = () => {
 | 
				
			||||||
 | 
					    this.setState({ progress: 100 * (this.video.currentTime / this.video.duration) });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleMouseDown = e => {
 | 
				
			||||||
 | 
					    document.addEventListener('mousemove', this.handleMouseMove, true);
 | 
				
			||||||
 | 
					    document.addEventListener('mouseup', this.handleMouseUp, true);
 | 
				
			||||||
 | 
					    document.addEventListener('touchmove', this.handleMouseMove, true);
 | 
				
			||||||
 | 
					    document.addEventListener('touchend', this.handleMouseUp, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.setState({ dragging: true });
 | 
				
			||||||
 | 
					    this.video.pause();
 | 
				
			||||||
 | 
					    this.handleMouseMove(e);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleMouseUp = () => {
 | 
				
			||||||
 | 
					    document.removeEventListener('mousemove', this.handleMouseMove, true);
 | 
				
			||||||
 | 
					    document.removeEventListener('mouseup', this.handleMouseUp, true);
 | 
				
			||||||
 | 
					    document.removeEventListener('touchmove', this.handleMouseMove, true);
 | 
				
			||||||
 | 
					    document.removeEventListener('touchend', this.handleMouseUp, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.setState({ dragging: false });
 | 
				
			||||||
 | 
					    this.video.play();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleMouseMove = throttle(e => {
 | 
				
			||||||
 | 
					    const { x } = getPointerPosition(this.seek, e);
 | 
				
			||||||
 | 
					    this.video.currentTime = this.video.duration * x;
 | 
				
			||||||
 | 
					    this.setState({ progress: x * 100 });
 | 
				
			||||||
 | 
					  }, 60);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  togglePlay = () => {
 | 
				
			||||||
 | 
					    if (this.state.paused) {
 | 
				
			||||||
 | 
					      this.video.play();
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      this.video.pause();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  toggleFullscreen = () => {
 | 
				
			||||||
 | 
					    if (isFullscreen()) {
 | 
				
			||||||
 | 
					      exitFullscreen();
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      requestFullscreen(this.player);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentDidMount () {
 | 
				
			||||||
 | 
					    document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
 | 
				
			||||||
 | 
					    document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
 | 
				
			||||||
 | 
					    document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
 | 
				
			||||||
 | 
					    document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentWillUnmount () {
 | 
				
			||||||
 | 
					    document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
 | 
				
			||||||
 | 
					    document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
 | 
				
			||||||
 | 
					    document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
 | 
				
			||||||
 | 
					    document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleFullscreenChange = () => {
 | 
				
			||||||
 | 
					    this.setState({ fullscreen: isFullscreen() });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleMouseEnter = () => {
 | 
				
			||||||
 | 
					    this.setState({ hovered: true });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleMouseLeave = () => {
 | 
				
			||||||
 | 
					    this.setState({ hovered: false });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  toggleMute = () => {
 | 
				
			||||||
 | 
					    this.video.muted = !this.video.muted;
 | 
				
			||||||
 | 
					    this.setState({ muted: this.video.muted });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  toggleReveal = () => {
 | 
				
			||||||
 | 
					    if (this.state.revealed) {
 | 
				
			||||||
 | 
					      this.video.pause();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.setState({ revealed: !this.state.revealed });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleLoadedData = () => {
 | 
				
			||||||
 | 
					    if (this.props.startTime) {
 | 
				
			||||||
 | 
					      this.video.currentTime = this.props.startTime;
 | 
				
			||||||
 | 
					      this.video.play();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleOpenVideo = () => {
 | 
				
			||||||
 | 
					    this.video.pause();
 | 
				
			||||||
 | 
					    this.props.onOpenVideo(this.video.currentTime);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleCloseVideo = () => {
 | 
				
			||||||
 | 
					    this.video.pause();
 | 
				
			||||||
 | 
					    this.props.onCloseVideo();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props;
 | 
				
			||||||
 | 
					    const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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}>
 | 
				
			||||||
 | 
					        <video
 | 
				
			||||||
 | 
					          ref={this.setVideoRef}
 | 
				
			||||||
 | 
					          src={src}
 | 
				
			||||||
 | 
					          poster={preview}
 | 
				
			||||||
 | 
					          preload={!!startTime}
 | 
				
			||||||
 | 
					          loop
 | 
				
			||||||
 | 
					          role='button'
 | 
				
			||||||
 | 
					          tabIndex='0'
 | 
				
			||||||
 | 
					          width={width}
 | 
				
			||||||
 | 
					          height={height}
 | 
				
			||||||
 | 
					          onClick={this.togglePlay}
 | 
				
			||||||
 | 
					          onPlay={this.handlePlay}
 | 
				
			||||||
 | 
					          onPause={this.handlePause}
 | 
				
			||||||
 | 
					          onTimeUpdate={this.handleTimeUpdate}
 | 
				
			||||||
 | 
					          onLoadedData={this.handleLoadedData}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
 | 
				
			||||||
 | 
					          <span className='video-player__spoiler__title'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
 | 
				
			||||||
 | 
					          <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <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__progress' style={{ width: `${progress}%` }} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <span
 | 
				
			||||||
 | 
					              className={classNames('video-player__seek__handle', { active: dragging })}
 | 
				
			||||||
 | 
					              tabIndex='0'
 | 
				
			||||||
 | 
					              style={{ left: `${progress}%` }}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div className='video-player__buttons left'>
 | 
				
			||||||
 | 
					            <button aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button>
 | 
				
			||||||
 | 
					            <button aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button>
 | 
				
			||||||
 | 
					            {!onCloseVideo && <button aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div className='video-player__buttons right'>
 | 
				
			||||||
 | 
					            {(!fullscreen && onOpenVideo) && <button aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>}
 | 
				
			||||||
 | 
					            {onCloseVideo && <button aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-times' /></button>}
 | 
				
			||||||
 | 
					            <button aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,6 @@
 | 
				
			||||||
const LAYOUT_BREAKPOINT = 1024;
 | 
					import detectPassiveEvents from 'detect-passive-events';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const LAYOUT_BREAKPOINT = 630;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function isMobile(width, columns) {
 | 
					export function isMobile(width, columns) {
 | 
				
			||||||
  switch (columns) {
 | 
					  switch (columns) {
 | 
				
			||||||
| 
						 | 
					@ -12,11 +14,16 @@ export function isMobile(width, columns) {
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
 | 
					const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
 | 
				
			||||||
let userTouching = false;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
window.addEventListener('touchstart', () => {
 | 
					let userTouching = false;
 | 
				
			||||||
 | 
					let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function touchListener() {
 | 
				
			||||||
  userTouching = true;
 | 
					  userTouching = true;
 | 
				
			||||||
}, { once: true });
 | 
					  window.removeEventListener('touchstart', touchListener, listenerOptions);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					window.addEventListener('touchstart', touchListener, listenerOptions);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function isUserTouching() {
 | 
					export function isUserTouching() {
 | 
				
			||||||
  return userTouching;
 | 
					  return userTouching;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,6 +33,7 @@
 | 
				
			||||||
  "column.home": "الرئيسية",
 | 
					  "column.home": "الرئيسية",
 | 
				
			||||||
  "column.mutes": "الحسابات المكتومة",
 | 
					  "column.mutes": "الحسابات المكتومة",
 | 
				
			||||||
  "column.notifications": "الإشعارات",
 | 
					  "column.notifications": "الإشعارات",
 | 
				
			||||||
 | 
					  "column.pins": "Pinned toot",
 | 
				
			||||||
  "column.public": "الخيط العام الموحد",
 | 
					  "column.public": "الخيط العام الموحد",
 | 
				
			||||||
  "column_back_button.label": "العودة",
 | 
					  "column_back_button.label": "العودة",
 | 
				
			||||||
  "column_header.hide_settings": "Hide settings",
 | 
					  "column_header.hide_settings": "Hide settings",
 | 
				
			||||||
| 
						 | 
					@ -46,7 +47,6 @@
 | 
				
			||||||
  "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
 | 
					  "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
 | 
				
			||||||
  "compose_form.lock_disclaimer.lock": "مقفل",
 | 
					  "compose_form.lock_disclaimer.lock": "مقفل",
 | 
				
			||||||
  "compose_form.placeholder": "فيمَ تفكّر؟",
 | 
					  "compose_form.placeholder": "فيمَ تفكّر؟",
 | 
				
			||||||
  "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
 | 
					 | 
				
			||||||
  "compose_form.publish": "بوّق",
 | 
					  "compose_form.publish": "بوّق",
 | 
				
			||||||
  "compose_form.publish_loud": "{publish}!",
 | 
					  "compose_form.publish_loud": "{publish}!",
 | 
				
			||||||
  "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس",
 | 
					  "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس",
 | 
				
			||||||
| 
						 | 
					@ -66,13 +66,17 @@
 | 
				
			||||||
  "embed.instructions": "Embed this status on your website by copying the code below.",
 | 
					  "embed.instructions": "Embed this status on your website by copying the code below.",
 | 
				
			||||||
  "embed.preview": "Here is what it will look like:",
 | 
					  "embed.preview": "Here is what it will look like:",
 | 
				
			||||||
  "emoji_button.activity": "الأنشطة",
 | 
					  "emoji_button.activity": "الأنشطة",
 | 
				
			||||||
 | 
					  "emoji_button.custom": "Custom",
 | 
				
			||||||
  "emoji_button.flags": "الأعلام",
 | 
					  "emoji_button.flags": "الأعلام",
 | 
				
			||||||
  "emoji_button.food": "الطعام والشراب",
 | 
					  "emoji_button.food": "الطعام والشراب",
 | 
				
			||||||
  "emoji_button.label": "أدرج إيموجي",
 | 
					  "emoji_button.label": "أدرج إيموجي",
 | 
				
			||||||
  "emoji_button.nature": "الطبيعة",
 | 
					  "emoji_button.nature": "الطبيعة",
 | 
				
			||||||
 | 
					  "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.search": "ابحث...",
 | 
					  "emoji_button.search": "ابحث...",
 | 
				
			||||||
 | 
					  "emoji_button.search_results": "Search results",
 | 
				
			||||||
  "emoji_button.symbols": "رموز",
 | 
					  "emoji_button.symbols": "رموز",
 | 
				
			||||||
  "emoji_button.travel": "أماكن و أسفار",
 | 
					  "emoji_button.travel": "أماكن و أسفار",
 | 
				
			||||||
  "empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.",
 | 
					  "empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.",
 | 
				
			||||||
| 
						 | 
					@ -109,6 +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.preferences": "التفضيلات",
 | 
					  "navigation_bar.preferences": "التفضيلات",
 | 
				
			||||||
  "navigation_bar.public_timeline": "الخيط العام الموحد",
 | 
					  "navigation_bar.public_timeline": "الخيط العام الموحد",
 | 
				
			||||||
  "notification.favourite": "{name} أعجب بمنشورك",
 | 
					  "notification.favourite": "{name} أعجب بمنشورك",
 | 
				
			||||||
| 
						 | 
					@ -193,6 +198,15 @@
 | 
				
			||||||
  "upload_button.label": "إضافة وسائط",
 | 
					  "upload_button.label": "إضافة وسائط",
 | 
				
			||||||
  "upload_form.undo": "إلغاء",
 | 
					  "upload_form.undo": "إلغاء",
 | 
				
			||||||
  "upload_progress.label": "يرفع...",
 | 
					  "upload_progress.label": "يرفع...",
 | 
				
			||||||
 | 
					  "video.close": "Close video",
 | 
				
			||||||
 | 
					  "video.exit_fullscreen": "Exit full screen",
 | 
				
			||||||
 | 
					  "video.expand": "Expand video",
 | 
				
			||||||
 | 
					  "video.fullscreen": "Full screen",
 | 
				
			||||||
 | 
					  "video.hide": "Hide video",
 | 
				
			||||||
 | 
					  "video.mute": "Mute sound",
 | 
				
			||||||
 | 
					  "video.pause": "Pause",
 | 
				
			||||||
 | 
					  "video.play": "Play",
 | 
				
			||||||
 | 
					  "video.unmute": "Unmute sound",
 | 
				
			||||||
  "video_player.expand": "وسّع الفيديو",
 | 
					  "video_player.expand": "وسّع الفيديو",
 | 
				
			||||||
  "video_player.toggle_sound": "تبديل الصوت",
 | 
					  "video_player.toggle_sound": "تبديل الصوت",
 | 
				
			||||||
  "video_player.toggle_visible": "إظهار / إخفاء الفيديو",
 | 
					  "video_player.toggle_visible": "إظهار / إخفاء الفيديو",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,6 +33,7 @@
 | 
				
			||||||
  "column.home": "Начало",
 | 
					  "column.home": "Начало",
 | 
				
			||||||
  "column.mutes": "Muted users",
 | 
					  "column.mutes": "Muted users",
 | 
				
			||||||
  "column.notifications": "Известия",
 | 
					  "column.notifications": "Известия",
 | 
				
			||||||
 | 
					  "column.pins": "Pinned toot",
 | 
				
			||||||
  "column.public": "Публичен канал",
 | 
					  "column.public": "Публичен канал",
 | 
				
			||||||
  "column_back_button.label": "Назад",
 | 
					  "column_back_button.label": "Назад",
 | 
				
			||||||
  "column_header.hide_settings": "Hide settings",
 | 
					  "column_header.hide_settings": "Hide settings",
 | 
				
			||||||
| 
						 | 
					@ -46,7 +47,6 @@
 | 
				
			||||||
  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
 | 
					  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
 | 
				
			||||||
  "compose_form.lock_disclaimer.lock": "locked",
 | 
					  "compose_form.lock_disclaimer.lock": "locked",
 | 
				
			||||||
  "compose_form.placeholder": "Какво си мислиш?",
 | 
					  "compose_form.placeholder": "Какво си мислиш?",
 | 
				
			||||||
  "compose_form.privacy_disclaimer": "Поверителни публикации ще бъдат изпратени до споменатите потребители на {domains}. Доверяваш ли се на {domainsCount, plural, one {that server} other {those servers}}, че няма да издаде твоята публикация?",
 | 
					 | 
				
			||||||
  "compose_form.publish": "Раздумай",
 | 
					  "compose_form.publish": "Раздумай",
 | 
				
			||||||
  "compose_form.publish_loud": "{publish}!",
 | 
					  "compose_form.publish_loud": "{publish}!",
 | 
				
			||||||
  "compose_form.sensitive": "Отбележи съдържанието като деликатно",
 | 
					  "compose_form.sensitive": "Отбележи съдържанието като деликатно",
 | 
				
			||||||
| 
						 | 
					@ -66,13 +66,17 @@
 | 
				
			||||||
  "embed.instructions": "Embed this status on your website by copying the code below.",
 | 
					  "embed.instructions": "Embed this status on your website by copying the code below.",
 | 
				
			||||||
  "embed.preview": "Here is what it will look like:",
 | 
					  "embed.preview": "Here is what it will look like:",
 | 
				
			||||||
  "emoji_button.activity": "Activity",
 | 
					  "emoji_button.activity": "Activity",
 | 
				
			||||||
 | 
					  "emoji_button.custom": "Custom",
 | 
				
			||||||
  "emoji_button.flags": "Flags",
 | 
					  "emoji_button.flags": "Flags",
 | 
				
			||||||
  "emoji_button.food": "Food & Drink",
 | 
					  "emoji_button.food": "Food & Drink",
 | 
				
			||||||
  "emoji_button.label": "Insert emoji",
 | 
					  "emoji_button.label": "Insert emoji",
 | 
				
			||||||
  "emoji_button.nature": "Nature",
 | 
					  "emoji_button.nature": "Nature",
 | 
				
			||||||
 | 
					  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
 | 
				
			||||||
  "emoji_button.objects": "Objects",
 | 
					  "emoji_button.objects": "Objects",
 | 
				
			||||||
  "emoji_button.people": "People",
 | 
					  "emoji_button.people": "People",
 | 
				
			||||||
 | 
					  "emoji_button.recent": "Frequently used",
 | 
				
			||||||
  "emoji_button.search": "Search...",
 | 
					  "emoji_button.search": "Search...",
 | 
				
			||||||
 | 
					  "emoji_button.search_results": "Search results",
 | 
				
			||||||
  "emoji_button.symbols": "Symbols",
 | 
					  "emoji_button.symbols": "Symbols",
 | 
				
			||||||
  "emoji_button.travel": "Travel & Places",
 | 
					  "emoji_button.travel": "Travel & Places",
 | 
				
			||||||
  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
 | 
					  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
 | 
				
			||||||
| 
						 | 
					@ -109,6 +113,7 @@
 | 
				
			||||||
  "navigation_bar.info": "Extended information",
 | 
					  "navigation_bar.info": "Extended information",
 | 
				
			||||||
  "navigation_bar.logout": "Излизане",
 | 
					  "navigation_bar.logout": "Излизане",
 | 
				
			||||||
  "navigation_bar.mutes": "Muted users",
 | 
					  "navigation_bar.mutes": "Muted users",
 | 
				
			||||||
 | 
					  "navigation_bar.pins": "Pinned toots",
 | 
				
			||||||
  "navigation_bar.preferences": "Предпочитания",
 | 
					  "navigation_bar.preferences": "Предпочитания",
 | 
				
			||||||
  "navigation_bar.public_timeline": "Публичен канал",
 | 
					  "navigation_bar.public_timeline": "Публичен канал",
 | 
				
			||||||
  "notification.favourite": "{name} хареса твоята публикация",
 | 
					  "notification.favourite": "{name} хареса твоята публикация",
 | 
				
			||||||
| 
						 | 
					@ -193,6 +198,15 @@
 | 
				
			||||||
  "upload_button.label": "Добави медия",
 | 
					  "upload_button.label": "Добави медия",
 | 
				
			||||||
  "upload_form.undo": "Отмяна",
 | 
					  "upload_form.undo": "Отмяна",
 | 
				
			||||||
  "upload_progress.label": "Uploading...",
 | 
					  "upload_progress.label": "Uploading...",
 | 
				
			||||||
 | 
					  "video.close": "Close video",
 | 
				
			||||||
 | 
					  "video.exit_fullscreen": "Exit full screen",
 | 
				
			||||||
 | 
					  "video.expand": "Expand video",
 | 
				
			||||||
 | 
					  "video.fullscreen": "Full screen",
 | 
				
			||||||
 | 
					  "video.hide": "Hide video",
 | 
				
			||||||
 | 
					  "video.mute": "Mute sound",
 | 
				
			||||||
 | 
					  "video.pause": "Pause",
 | 
				
			||||||
 | 
					  "video.play": "Play",
 | 
				
			||||||
 | 
					  "video.unmute": "Unmute sound",
 | 
				
			||||||
  "video_player.expand": "Expand video",
 | 
					  "video_player.expand": "Expand video",
 | 
				
			||||||
  "video_player.toggle_sound": "Звук",
 | 
					  "video_player.toggle_sound": "Звук",
 | 
				
			||||||
  "video_player.toggle_visible": "Toggle visibility",
 | 
					  "video_player.toggle_visible": "Toggle visibility",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,6 +33,7 @@
 | 
				
			||||||
  "column.home": "Inici",
 | 
					  "column.home": "Inici",
 | 
				
			||||||
  "column.mutes": "Usuaris silenciats",
 | 
					  "column.mutes": "Usuaris silenciats",
 | 
				
			||||||
  "column.notifications": "Notificacions",
 | 
					  "column.notifications": "Notificacions",
 | 
				
			||||||
 | 
					  "column.pins": "Pinned toot",
 | 
				
			||||||
  "column.public": "Línia de temps federada",
 | 
					  "column.public": "Línia de temps federada",
 | 
				
			||||||
  "column_back_button.label": "Enrere",
 | 
					  "column_back_button.label": "Enrere",
 | 
				
			||||||
  "column_header.hide_settings": "Hide settings",
 | 
					  "column_header.hide_settings": "Hide settings",
 | 
				
			||||||
| 
						 | 
					@ -46,7 +47,6 @@
 | 
				
			||||||
  "compose_form.lock_disclaimer": "El teu compte no està bloquejat {locked}. Tothom pot seguir-te i veure els teus missatges a seguidors.",
 | 
					  "compose_form.lock_disclaimer": "El teu compte no està bloquejat {locked}. Tothom pot seguir-te i veure els teus missatges a seguidors.",
 | 
				
			||||||
  "compose_form.lock_disclaimer.lock": "bloquejat",
 | 
					  "compose_form.lock_disclaimer.lock": "bloquejat",
 | 
				
			||||||
  "compose_form.placeholder": "En què estàs pensant?",
 | 
					  "compose_form.placeholder": "En què estàs pensant?",
 | 
				
			||||||
  "compose_form.privacy_disclaimer": "El teu missatge serà lliurat als usuaris esmentats en els dominis {domains}. Confies en {domainsCount, plural, one {that server} other {those servers}}? Els missatges privats només funcionen en instàncies Mastodon. Si {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, res indicarà que el teu missatge no es públic i pot ser impulsat (boosted) o ser visible per destinataris no desitjats.",
 | 
					 | 
				
			||||||
  "compose_form.publish": "Toot",
 | 
					  "compose_form.publish": "Toot",
 | 
				
			||||||
  "compose_form.publish_loud": "{publish}!",
 | 
					  "compose_form.publish_loud": "{publish}!",
 | 
				
			||||||
  "compose_form.sensitive": "Marcar multimèdia com a sensible",
 | 
					  "compose_form.sensitive": "Marcar multimèdia com a sensible",
 | 
				
			||||||
| 
						 | 
					@ -66,13 +66,17 @@
 | 
				
			||||||
  "embed.instructions": "Embed this status on your website by copying the code below.",
 | 
					  "embed.instructions": "Embed this status on your website by copying the code below.",
 | 
				
			||||||
  "embed.preview": "Here is what it will look like:",
 | 
					  "embed.preview": "Here is what it will look like:",
 | 
				
			||||||
  "emoji_button.activity": "Activitat",
 | 
					  "emoji_button.activity": "Activitat",
 | 
				
			||||||
 | 
					  "emoji_button.custom": "Custom",
 | 
				
			||||||
  "emoji_button.flags": "Flags",
 | 
					  "emoji_button.flags": "Flags",
 | 
				
			||||||
  "emoji_button.food": "Menjar i Beure",
 | 
					  "emoji_button.food": "Menjar i Beure",
 | 
				
			||||||
  "emoji_button.label": "Inserir emoji",
 | 
					  "emoji_button.label": "Inserir emoji",
 | 
				
			||||||
  "emoji_button.nature": "Natura",
 | 
					  "emoji_button.nature": "Natura",
 | 
				
			||||||
 | 
					  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
 | 
				
			||||||
  "emoji_button.objects": "Objectes",
 | 
					  "emoji_button.objects": "Objectes",
 | 
				
			||||||
  "emoji_button.people": "Gent",
 | 
					  "emoji_button.people": "Gent",
 | 
				
			||||||
 | 
					  "emoji_button.recent": "Frequently used",
 | 
				
			||||||
  "emoji_button.search": "Cercar...",
 | 
					  "emoji_button.search": "Cercar...",
 | 
				
			||||||
 | 
					  "emoji_button.search_results": "Search results",
 | 
				
			||||||
  "emoji_button.symbols": "Símbols",
 | 
					  "emoji_button.symbols": "Símbols",
 | 
				
			||||||
  "emoji_button.travel": "Viatges i Llocs",
 | 
					  "emoji_button.travel": "Viatges i Llocs",
 | 
				
			||||||
  "empty_column.community": "La línia de temps local és buida. Escriu alguna cosa públicament per fer rodar la pilota!",
 | 
					  "empty_column.community": "La línia de temps local és buida. Escriu alguna cosa públicament per fer rodar la pilota!",
 | 
				
			||||||
| 
						 | 
					@ -109,6 +113,7 @@
 | 
				
			||||||
  "navigation_bar.info": "Informació addicional",
 | 
					  "navigation_bar.info": "Informació addicional",
 | 
				
			||||||
  "navigation_bar.logout": "Tancar sessió",
 | 
					  "navigation_bar.logout": "Tancar sessió",
 | 
				
			||||||
  "navigation_bar.mutes": "Usuaris silenciats",
 | 
					  "navigation_bar.mutes": "Usuaris silenciats",
 | 
				
			||||||
 | 
					  "navigation_bar.pins": "Pinned toots",
 | 
				
			||||||
  "navigation_bar.preferences": "Preferències",
 | 
					  "navigation_bar.preferences": "Preferències",
 | 
				
			||||||
  "navigation_bar.public_timeline": "Línia de temps federada",
 | 
					  "navigation_bar.public_timeline": "Línia de temps federada",
 | 
				
			||||||
  "notification.favourite": "{name} ha afavorit el teu estat",
 | 
					  "notification.favourite": "{name} ha afavorit el teu estat",
 | 
				
			||||||
| 
						 | 
					@ -193,6 +198,15 @@
 | 
				
			||||||
  "upload_button.label": "Afegir multimèdia",
 | 
					  "upload_button.label": "Afegir multimèdia",
 | 
				
			||||||
  "upload_form.undo": "Desfer",
 | 
					  "upload_form.undo": "Desfer",
 | 
				
			||||||
  "upload_progress.label": "Pujant...",
 | 
					  "upload_progress.label": "Pujant...",
 | 
				
			||||||
 | 
					  "video.close": "Close video",
 | 
				
			||||||
 | 
					  "video.exit_fullscreen": "Exit full screen",
 | 
				
			||||||
 | 
					  "video.expand": "Expand video",
 | 
				
			||||||
 | 
					  "video.fullscreen": "Full screen",
 | 
				
			||||||
 | 
					  "video.hide": "Hide video",
 | 
				
			||||||
 | 
					  "video.mute": "Mute sound",
 | 
				
			||||||
 | 
					  "video.pause": "Pause",
 | 
				
			||||||
 | 
					  "video.play": "Play",
 | 
				
			||||||
 | 
					  "video.unmute": "Unmute sound",
 | 
				
			||||||
  "video_player.expand": "Ampliar el vídeo",
 | 
					  "video_player.expand": "Ampliar el vídeo",
 | 
				
			||||||
  "video_player.toggle_sound": "Alternar so",
 | 
					  "video_player.toggle_sound": "Alternar so",
 | 
				
			||||||
  "video_player.toggle_visible": "Alternar visibilitat",
 | 
					  "video_player.toggle_visible": "Alternar visibilitat",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,6 +33,7 @@
 | 
				
			||||||
  "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.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",
 | 
				
			||||||
| 
						 | 
					@ -46,7 +47,6 @@
 | 
				
			||||||
  "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}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.",
 | 
				
			||||||
  "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.privacy_disclaimer": "Dein privater Status wird an die genannten Profile auf den Domains {domains} zugestellt. Vertraust du {domainsCount, plural, one {diesem Server} other {diesen Servern}}? Private Beiträge funktionieren nur auf Mastodon-Instanzen. Wenn {domains} {domainsCount, plural, one {keine Mastodon-Instanz ist} other {keine Mastodon-Instanzen sind}}, wird es dort kein Anzeichen geben, dass dein Beitrag privat ist und er könnte geteilt oder anderweitig für unerwünschte Empfänger sichtbar gemacht werden.",
 | 
					 | 
				
			||||||
  "compose_form.publish": "Tröt",
 | 
					  "compose_form.publish": "Tröt",
 | 
				
			||||||
  "compose_form.publish_loud": "{publish}!",
 | 
					  "compose_form.publish_loud": "{publish}!",
 | 
				
			||||||
  "compose_form.sensitive": "Medien als heikel markieren",
 | 
					  "compose_form.sensitive": "Medien als heikel markieren",
 | 
				
			||||||
| 
						 | 
					@ -66,13 +66,17 @@
 | 
				
			||||||
  "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, in dem 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.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.objects": "Dinge",
 | 
					  "emoji_button.objects": "Dinge",
 | 
				
			||||||
  "emoji_button.people": "Leute",
 | 
					  "emoji_button.people": "Leute",
 | 
				
			||||||
 | 
					  "emoji_button.recent": "Frequently used",
 | 
				
			||||||
  "emoji_button.search": "Suche…",
 | 
					  "emoji_button.search": "Suche…",
 | 
				
			||||||
 | 
					  "emoji_button.search_results": "Search results",
 | 
				
			||||||
  "emoji_button.symbols": "Symbole",
 | 
					  "emoji_button.symbols": "Symbole",
 | 
				
			||||||
  "emoji_button.travel": "Reise und Orte",
 | 
					  "emoji_button.travel": "Reise 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 etwas öffentlich, um den Ball ins Rollen zu bringen!",
 | 
				
			||||||
| 
						 | 
					@ -109,6 +113,7 @@
 | 
				
			||||||
  "navigation_bar.info": "Erweiterte Informationen",
 | 
					  "navigation_bar.info": "Erweiterte Informationen",
 | 
				
			||||||
  "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.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} favorisierte deinen Status",
 | 
				
			||||||
| 
						 | 
					@ -193,6 +198,15 @@
 | 
				
			||||||
  "upload_button.label": "Mediendatei hinzufügen",
 | 
					  "upload_button.label": "Mediendatei hinzufügen",
 | 
				
			||||||
  "upload_form.undo": "Entfernen",
 | 
					  "upload_form.undo": "Entfernen",
 | 
				
			||||||
  "upload_progress.label": "Lade hoch…",
 | 
					  "upload_progress.label": "Lade hoch…",
 | 
				
			||||||
 | 
					  "video.close": "Close video",
 | 
				
			||||||
 | 
					  "video.exit_fullscreen": "Exit full screen",
 | 
				
			||||||
 | 
					  "video.expand": "Expand video",
 | 
				
			||||||
 | 
					  "video.fullscreen": "Full screen",
 | 
				
			||||||
 | 
					  "video.hide": "Hide video",
 | 
				
			||||||
 | 
					  "video.mute": "Mute sound",
 | 
				
			||||||
 | 
					  "video.pause": "Pause",
 | 
				
			||||||
 | 
					  "video.play": "Play",
 | 
				
			||||||
 | 
					  "video.unmute": "Unmute sound",
 | 
				
			||||||
  "video_player.expand": "Videoanzeige vergrößern",
 | 
					  "video_player.expand": "Videoanzeige vergrößern",
 | 
				
			||||||
  "video_player.toggle_sound": "Ton umschalten",
 | 
					  "video_player.toggle_sound": "Ton umschalten",
 | 
				
			||||||
  "video_player.toggle_visible": "Sichtbarkeit umschalten",
 | 
					  "video_player.toggle_visible": "Sichtbarkeit umschalten",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -516,6 +516,22 @@
 | 
				
			||||||
        "defaultMessage": "Search...",
 | 
					        "defaultMessage": "Search...",
 | 
				
			||||||
        "id": "emoji_button.search"
 | 
					        "id": "emoji_button.search"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "No emojos!! (╯°□°)╯︵ ┻━┻",
 | 
				
			||||||
 | 
					        "id": "emoji_button.not_found"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Custom",
 | 
				
			||||||
 | 
					        "id": "emoji_button.custom"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Frequently used",
 | 
				
			||||||
 | 
					        "id": "emoji_button.recent"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Search results",
 | 
				
			||||||
 | 
					        "id": "emoji_button.search_results"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        "defaultMessage": "People",
 | 
					        "defaultMessage": "People",
 | 
				
			||||||
        "id": "emoji_button.people"
 | 
					        "id": "emoji_button.people"
 | 
				
			||||||
| 
						 | 
					@ -682,10 +698,6 @@
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        "defaultMessage": "locked",
 | 
					        "defaultMessage": "locked",
 | 
				
			||||||
        "id": "compose_form.lock_disclaimer.lock"
 | 
					        "id": "compose_form.lock_disclaimer.lock"
 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      {
 | 
					 | 
				
			||||||
        "defaultMessage": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
 | 
					 | 
				
			||||||
        "id": "compose_form.privacy_disclaimer"
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    "path": "app/javascript/mastodon/features/compose/containers/warning_container.json"
 | 
					    "path": "app/javascript/mastodon/features/compose/containers/warning_container.json"
 | 
				
			||||||
| 
						 | 
					@ -812,6 +824,10 @@
 | 
				
			||||||
        "defaultMessage": "Extended information",
 | 
					        "defaultMessage": "Extended information",
 | 
				
			||||||
        "id": "navigation_bar.info"
 | 
					        "id": "navigation_bar.info"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Pinned toots",
 | 
				
			||||||
 | 
					        "id": "navigation_bar.pins"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        "defaultMessage": "FAQ",
 | 
					        "defaultMessage": "FAQ",
 | 
				
			||||||
        "id": "getting_started.faq"
 | 
					        "id": "getting_started.faq"
 | 
				
			||||||
| 
						 | 
					@ -992,6 +1008,15 @@
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    "path": "app/javascript/mastodon/features/notifications/index.json"
 | 
					    "path": "app/javascript/mastodon/features/notifications/index.json"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "descriptors": [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Pinned toot",
 | 
				
			||||||
 | 
					        "id": "column.pins"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    "path": "app/javascript/mastodon/features/pinned_statuses/index.json"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    "descriptors": [
 | 
					    "descriptors": [
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
| 
						 | 
					@ -1321,10 +1346,50 @@
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    "descriptors": [
 | 
					    "descriptors": [
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        "defaultMessage": "Close",
 | 
					        "defaultMessage": "Play",
 | 
				
			||||||
        "id": "lightbox.close"
 | 
					        "id": "video.play"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Pause",
 | 
				
			||||||
 | 
					        "id": "video.pause"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Mute sound",
 | 
				
			||||||
 | 
					        "id": "video.mute"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Unmute sound",
 | 
				
			||||||
 | 
					        "id": "video.unmute"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Hide video",
 | 
				
			||||||
 | 
					        "id": "video.hide"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Expand video",
 | 
				
			||||||
 | 
					        "id": "video.expand"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Close video",
 | 
				
			||||||
 | 
					        "id": "video.close"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Full screen",
 | 
				
			||||||
 | 
					        "id": "video.fullscreen"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Exit full screen",
 | 
				
			||||||
 | 
					        "id": "video.exit_fullscreen"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Sensitive content",
 | 
				
			||||||
 | 
					        "id": "status.sensitive_warning"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "defaultMessage": "Click to view",
 | 
				
			||||||
 | 
					        "id": "status.sensitive_toggle"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    "path": "app/javascript/mastodon/features/ui/components/video_modal.json"
 | 
					    "path": "app/javascript/mastodon/features/video/index.json"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,8 +33,8 @@
 | 
				
			||||||
  "column.home": "Home",
 | 
					  "column.home": "Home",
 | 
				
			||||||
  "column.mutes": "Muted users",
 | 
					  "column.mutes": "Muted users",
 | 
				
			||||||
  "column.notifications": "Notifications",
 | 
					  "column.notifications": "Notifications",
 | 
				
			||||||
  "column.public": "Federated timeline",
 | 
					 | 
				
			||||||
  "column.pins": "Pinned toots",
 | 
					  "column.pins": "Pinned toots",
 | 
				
			||||||
 | 
					  "column.public": "Federated timeline",
 | 
				
			||||||
  "column_back_button.label": "Back",
 | 
					  "column_back_button.label": "Back",
 | 
				
			||||||
  "column_header.hide_settings": "Hide settings",
 | 
					  "column_header.hide_settings": "Hide settings",
 | 
				
			||||||
  "column_header.moveLeft_settings": "Move column to the left",
 | 
					  "column_header.moveLeft_settings": "Move column to the left",
 | 
				
			||||||
| 
						 | 
					@ -47,7 +47,6 @@
 | 
				
			||||||
  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
 | 
					  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
 | 
				
			||||||
  "compose_form.lock_disclaimer.lock": "locked",
 | 
					  "compose_form.lock_disclaimer.lock": "locked",
 | 
				
			||||||
  "compose_form.placeholder": "What is on your mind?",
 | 
					  "compose_form.placeholder": "What is on your mind?",
 | 
				
			||||||
  "compose_form.privacy_disclaimer": "Your post will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is not a public post, and it may be boosted or otherwise made visible to unintended recipients.",
 | 
					 | 
				
			||||||
  "compose_form.publish": "Toot",
 | 
					  "compose_form.publish": "Toot",
 | 
				
			||||||
  "compose_form.publish_loud": "{publish}!",
 | 
					  "compose_form.publish_loud": "{publish}!",
 | 
				
			||||||
  "compose_form.sensitive": "Mark media as sensitive",
 | 
					  "compose_form.sensitive": "Mark media as sensitive",
 | 
				
			||||||
| 
						 | 
					@ -67,13 +66,17 @@
 | 
				
			||||||
  "embed.instructions": "Embed this status on your website by copying the code below.",
 | 
					  "embed.instructions": "Embed this status on your website by copying the code below.",
 | 
				
			||||||
  "embed.preview": "Here is what it will look like:",
 | 
					  "embed.preview": "Here is what it will look like:",
 | 
				
			||||||
  "emoji_button.activity": "Activity",
 | 
					  "emoji_button.activity": "Activity",
 | 
				
			||||||
 | 
					  "emoji_button.custom": "Custom",
 | 
				
			||||||
  "emoji_button.flags": "Flags",
 | 
					  "emoji_button.flags": "Flags",
 | 
				
			||||||
  "emoji_button.food": "Food & Drink",
 | 
					  "emoji_button.food": "Food & Drink",
 | 
				
			||||||
  "emoji_button.label": "Insert emoji",
 | 
					  "emoji_button.label": "Insert emoji",
 | 
				
			||||||
  "emoji_button.nature": "Nature",
 | 
					  "emoji_button.nature": "Nature",
 | 
				
			||||||
 | 
					  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
 | 
				
			||||||
  "emoji_button.objects": "Objects",
 | 
					  "emoji_button.objects": "Objects",
 | 
				
			||||||
  "emoji_button.people": "People",
 | 
					  "emoji_button.people": "People",
 | 
				
			||||||
 | 
					  "emoji_button.recent": "Frequently used",
 | 
				
			||||||
  "emoji_button.search": "Search...",
 | 
					  "emoji_button.search": "Search...",
 | 
				
			||||||
 | 
					  "emoji_button.search_results": "Search results",
 | 
				
			||||||
  "emoji_button.symbols": "Symbols",
 | 
					  "emoji_button.symbols": "Symbols",
 | 
				
			||||||
  "emoji_button.travel": "Travel & Places",
 | 
					  "emoji_button.travel": "Travel & Places",
 | 
				
			||||||
  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
 | 
					  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
 | 
				
			||||||
| 
						 | 
					@ -110,9 +113,9 @@
 | 
				
			||||||
  "navigation_bar.info": "About this instance",
 | 
					  "navigation_bar.info": "About this instance",
 | 
				
			||||||
  "navigation_bar.logout": "Logout",
 | 
					  "navigation_bar.logout": "Logout",
 | 
				
			||||||
  "navigation_bar.mutes": "Muted users",
 | 
					  "navigation_bar.mutes": "Muted users",
 | 
				
			||||||
 | 
					  "navigation_bar.pins": "Pinned toots",
 | 
				
			||||||
  "navigation_bar.preferences": "Preferences",
 | 
					  "navigation_bar.preferences": "Preferences",
 | 
				
			||||||
  "navigation_bar.public_timeline": "Federated timeline",
 | 
					  "navigation_bar.public_timeline": "Federated timeline",
 | 
				
			||||||
  "navigation_bar.pins": "Pinned toots",
 | 
					 | 
				
			||||||
  "notification.favourite": "{name} favourited your status",
 | 
					  "notification.favourite": "{name} favourited your status",
 | 
				
			||||||
  "notification.follow": "{name} followed you",
 | 
					  "notification.follow": "{name} followed you",
 | 
				
			||||||
  "notification.mention": "{name} mentioned you",
 | 
					  "notification.mention": "{name} mentioned you",
 | 
				
			||||||
| 
						 | 
					@ -195,6 +198,15 @@
 | 
				
			||||||
  "upload_button.label": "Add media",
 | 
					  "upload_button.label": "Add media",
 | 
				
			||||||
  "upload_form.undo": "Undo",
 | 
					  "upload_form.undo": "Undo",
 | 
				
			||||||
  "upload_progress.label": "Uploading...",
 | 
					  "upload_progress.label": "Uploading...",
 | 
				
			||||||
 | 
					  "video.close": "Close video",
 | 
				
			||||||
 | 
					  "video.exit_fullscreen": "Exit full screen",
 | 
				
			||||||
 | 
					  "video.expand": "Expand video",
 | 
				
			||||||
 | 
					  "video.fullscreen": "Full screen",
 | 
				
			||||||
 | 
					  "video.hide": "Hide video",
 | 
				
			||||||
 | 
					  "video.mute": "Mute sound",
 | 
				
			||||||
 | 
					  "video.pause": "Pause",
 | 
				
			||||||
 | 
					  "video.play": "Play",
 | 
				
			||||||
 | 
					  "video.unmute": "Unmute sound",
 | 
				
			||||||
  "video_player.expand": "Expand video",
 | 
					  "video_player.expand": "Expand video",
 | 
				
			||||||
  "video_player.toggle_sound": "Toggle sound",
 | 
					  "video_player.toggle_sound": "Toggle sound",
 | 
				
			||||||
  "video_player.toggle_visible": "Toggle visibility",
 | 
					  "video_player.toggle_visible": "Toggle visibility",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,6 +33,7 @@
 | 
				
			||||||
  "column.home": "Hejmo",
 | 
					  "column.home": "Hejmo",
 | 
				
			||||||
  "column.mutes": "Muted users",
 | 
					  "column.mutes": "Muted users",
 | 
				
			||||||
  "column.notifications": "Sciigoj",
 | 
					  "column.notifications": "Sciigoj",
 | 
				
			||||||
 | 
					  "column.pins": "Pinned toot",
 | 
				
			||||||
  "column.public": "Fratara tempolinio",
 | 
					  "column.public": "Fratara tempolinio",
 | 
				
			||||||
  "column_back_button.label": "Reveni",
 | 
					  "column_back_button.label": "Reveni",
 | 
				
			||||||
  "column_header.hide_settings": "Hide settings",
 | 
					  "column_header.hide_settings": "Hide settings",
 | 
				
			||||||
| 
						 | 
					@ -46,7 +47,6 @@
 | 
				
			||||||
  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
 | 
					  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
 | 
				
			||||||
  "compose_form.lock_disclaimer.lock": "locked",
 | 
					  "compose_form.lock_disclaimer.lock": "locked",
 | 
				
			||||||
  "compose_form.placeholder": "Pri kio vi pensas?",
 | 
					  "compose_form.placeholder": "Pri kio vi pensas?",
 | 
				
			||||||
  "compose_form.privacy_disclaimer": "Via privata mesaĝo estos sendita nur al menciitaj uzantoj en {domains}. Ĉu vi fidas {domainsCount, plural, one {tiun servilon} other {tiujn servilojn}}? Mesaĝa privateco funkcias nur en aperaĵoj de Mastodon. Se {domains} {domainsCount, plural, one {ne estas aperaĵo de Mastodon} other {ne estas aperaĵoj de Mastodon}}, estos neniu indiko ke via mesaĝo estas privata, kaj ĝi povus esti diskonigita aŭ videbligita al necelitaj ricevantoj.",
 | 
					 | 
				
			||||||
  "compose_form.publish": "Hup",
 | 
					  "compose_form.publish": "Hup",
 | 
				
			||||||
  "compose_form.publish_loud": "{publish}!",
 | 
					  "compose_form.publish_loud": "{publish}!",
 | 
				
			||||||
  "compose_form.sensitive": "Marki ke la enhavo estas tikla",
 | 
					  "compose_form.sensitive": "Marki ke la enhavo estas tikla",
 | 
				
			||||||
| 
						 | 
					@ -66,13 +66,17 @@
 | 
				
			||||||
  "embed.instructions": "Embed this status on your website by copying the code below.",
 | 
					  "embed.instructions": "Embed this status on your website by copying the code below.",
 | 
				
			||||||
  "embed.preview": "Here is what it will look like:",
 | 
					  "embed.preview": "Here is what it will look like:",
 | 
				
			||||||
  "emoji_button.activity": "Activity",
 | 
					  "emoji_button.activity": "Activity",
 | 
				
			||||||
 | 
					  "emoji_button.custom": "Custom",
 | 
				
			||||||
  "emoji_button.flags": "Flags",
 | 
					  "emoji_button.flags": "Flags",
 | 
				
			||||||
  "emoji_button.food": "Food & Drink",
 | 
					  "emoji_button.food": "Food & Drink",
 | 
				
			||||||
  "emoji_button.label": "Insert emoji",
 | 
					  "emoji_button.label": "Insert emoji",
 | 
				
			||||||
  "emoji_button.nature": "Nature",
 | 
					  "emoji_button.nature": "Nature",
 | 
				
			||||||
 | 
					  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
 | 
				
			||||||
  "emoji_button.objects": "Objects",
 | 
					  "emoji_button.objects": "Objects",
 | 
				
			||||||
  "emoji_button.people": "People",
 | 
					  "emoji_button.people": "People",
 | 
				
			||||||
 | 
					  "emoji_button.recent": "Frequently used",
 | 
				
			||||||
  "emoji_button.search": "Search...",
 | 
					  "emoji_button.search": "Search...",
 | 
				
			||||||
 | 
					  "emoji_button.search_results": "Search results",
 | 
				
			||||||
  "emoji_button.symbols": "Symbols",
 | 
					  "emoji_button.symbols": "Symbols",
 | 
				
			||||||
  "emoji_button.travel": "Travel & Places",
 | 
					  "emoji_button.travel": "Travel & Places",
 | 
				
			||||||
  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
 | 
					  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
 | 
				
			||||||
| 
						 | 
					@ -109,6 +113,7 @@
 | 
				
			||||||
  "navigation_bar.info": "Extended information",
 | 
					  "navigation_bar.info": "Extended information",
 | 
				
			||||||
  "navigation_bar.logout": "Elsaluti",
 | 
					  "navigation_bar.logout": "Elsaluti",
 | 
				
			||||||
  "navigation_bar.mutes": "Muted users",
 | 
					  "navigation_bar.mutes": "Muted users",
 | 
				
			||||||
 | 
					  "navigation_bar.pins": "Pinned toots",
 | 
				
			||||||
  "navigation_bar.preferences": "Preferoj",
 | 
					  "navigation_bar.preferences": "Preferoj",
 | 
				
			||||||
  "navigation_bar.public_timeline": "Fratara tempolinio",
 | 
					  "navigation_bar.public_timeline": "Fratara tempolinio",
 | 
				
			||||||
  "notification.favourite": "{name} favoris vian mesaĝon",
 | 
					  "notification.favourite": "{name} favoris vian mesaĝon",
 | 
				
			||||||
| 
						 | 
					@ -193,6 +198,15 @@
 | 
				
			||||||
  "upload_button.label": "Aldoni enhavaĵon",
 | 
					  "upload_button.label": "Aldoni enhavaĵon",
 | 
				
			||||||
  "upload_form.undo": "Malfari",
 | 
					  "upload_form.undo": "Malfari",
 | 
				
			||||||
  "upload_progress.label": "Uploading...",
 | 
					  "upload_progress.label": "Uploading...",
 | 
				
			||||||
 | 
					  "video.close": "Close video",
 | 
				
			||||||
 | 
					  "video.exit_fullscreen": "Exit full screen",
 | 
				
			||||||
 | 
					  "video.expand": "Expand video",
 | 
				
			||||||
 | 
					  "video.fullscreen": "Full screen",
 | 
				
			||||||
 | 
					  "video.hide": "Hide video",
 | 
				
			||||||
 | 
					  "video.mute": "Mute sound",
 | 
				
			||||||
 | 
					  "video.pause": "Pause",
 | 
				
			||||||
 | 
					  "video.play": "Play",
 | 
				
			||||||
 | 
					  "video.unmute": "Unmute sound",
 | 
				
			||||||
  "video_player.expand": "Expand video",
 | 
					  "video_player.expand": "Expand video",
 | 
				
			||||||
  "video_player.toggle_sound": "Aktivigi sonojn",
 | 
					  "video_player.toggle_sound": "Aktivigi sonojn",
 | 
				
			||||||
  "video_player.toggle_visible": "Toggle visibility",
 | 
					  "video_player.toggle_visible": "Toggle visibility",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||