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,14 +23,23 @@ 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| | ||||||
|         setting = Setting.where(var: key).first_or_initialize(var: key) |         if UPLOAD_SETTINGS.include?(key) | ||||||
|         setting.update(value: value_for_update(key, value)) |           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.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') | ||||||
|  | |||||||
							
								
								
									
										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 | ||||||
|     ActivityPub::CollectionPresenter.new( |     page = ActivityPub::CollectionPresenter.new( | ||||||
|       id: account_followers_url(@account), |       id: account_followers_url(@account, page: params.fetch(:page, 1)), | ||||||
|       type: :ordered, |       type: :ordered, | ||||||
|       size: @account.followers_count, |       size: @account.followers_count, | ||||||
|       items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) } |       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( | ||||||
|  |         id: account_followers_url(@account), | ||||||
|  |         type: :ordered, | ||||||
|  |         size: @account.followers_count, | ||||||
|  |         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 | ||||||
|     ActivityPub::CollectionPresenter.new( |     page = ActivityPub::CollectionPresenter.new( | ||||||
|       id: account_following_index_url(@account), |       id: account_following_index_url(@account, page: params.fetch(:page, 1)), | ||||||
|       type: :ordered, |       type: :ordered, | ||||||
|       size: @account.following_count, |       size: @account.following_count, | ||||||
|       items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) } |       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( | ||||||
|  |         id: account_following_index_url(@account), | ||||||
|  |         type: :ordered, | ||||||
|  |         size: @account.following_count, | ||||||
|  |         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]; |   return (dispatch, getState) => { | ||||||
| 
 |     if (token[0] === ':') { | ||||||
|   if (leading === '@') { |       const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 }); | ||||||
|     // handle search
 |       dispatch(readyComposeSuggestionsEmojis(token, results)); | ||||||
|     return (dispatch, getState) => { |       return; | ||||||
|       api(getState).get('/api/v1/accounts/search', { |  | ||||||
|         params: { |  | ||||||
|           q: token.slice(1), // remove the '@'
 |  | ||||||
|           resolve: false, |  | ||||||
|           limit: 4, |  | ||||||
|         }, |  | ||||||
|       }).then(response => { |  | ||||||
|         dispatch(readyComposeSuggestions(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); |     api(getState).get('/api/v1/accounts/search', { | ||||||
|       if (innertxt.length > 1) { // prevent searching single letter, causes lag
 |       params: { | ||||||
|         dispatch(readyComposeSuggestionsTxt(token, allShortcodes.filter((sc) => { |         q: token.slice(1), | ||||||
|           return sc.indexOf(innertxt) !== -1; |         resolve: false, | ||||||
|         }).sort((a, b) => { |         limit: 4, | ||||||
|           if (a.indexOf(token) === 0 && b.indexOf(token) === 0) return a.localeCompare(b); |       }, | ||||||
|           if (a.indexOf(token) === 0) return -1; |     }).then(response => { | ||||||
|           if (b.indexOf(token) === 0) return 1; |       dispatch(readyComposeSuggestionsAccounts(token, response.data)); | ||||||
|           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] === ':') { |       this.props.onSuggestionsFetchRequested(token); | ||||||
|         // faster debounce for shortcodes.
 |  | ||||||
|         // hashtags have long debounce because they're fetched from server.
 |  | ||||||
|         this.props.onLocalSuggestionsFetchRequested(token); |  | ||||||
|       } else { |  | ||||||
|         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 }) |  | ||||||
| 
 |  | ||||||
|   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; |     const { text, href = '#' } = option; | ||||||
| 
 | 
 | ||||||
|     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,43 +80,130 @@ 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 ( |  | ||||||
|         <div className='icon-button disabled' style={iconStyle} aria-label={ariaLabel}> |  | ||||||
|           <i className={iconClassname} aria-hidden /> |  | ||||||
|         </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 ( |     return ( | ||||||
|       <Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}> |       <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 }) }}> | ||||||
|         <DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-expanded={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}> |         {({ opacity, scaleX, scaleY }) => ( | ||||||
|           <i className={iconClassname} aria-hidden /> |           <div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> | ||||||
|         </DropdownTrigger> |             <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> | ||||||
| 
 | 
 | ||||||
|         {dropdownContent} |             <ul> | ||||||
|       </Dropdown> |               {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> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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; | ||||||
|       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} />); | 
 | ||||||
|  |       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} />); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     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 excluded       = ['®', '©', '™']; | ||||||
|  | const skins          = ['🏻', '🏼', '🏽', '🏾', '🏿']; | ||||||
|  | const shortcodeMap   = {}; | ||||||
| 
 | 
 | ||||||
| const mappedUnicode = emojione.mapUnicodeToShort(); | Object.keys(emojiIndex.emojis).forEach(key => { | ||||||
| const excluded = ['®', '©', '™']; |   shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id; | ||||||
|  | }); | ||||||
| 
 | 
 | ||||||
| module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap) | const stripModifiers = unicode => { | ||||||
|   .filter(c => !excluded.includes(c)) |   skins.forEach(tone => { | ||||||
|   .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]]) |     unicode = unicode.replace(tone, ''); | ||||||
|   .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] })) |   }); | ||||||
|   .reduce((x, y) => Object.assign(x, y), { }); | 
 | ||||||
|  |   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/', |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| let EmojiPicker; // load asynchronously
 | class ModifierPickerMenu extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     active: PropTypes.bool, | ||||||
|  |     onSelect: PropTypes.func.isRequired, | ||||||
|  |     onClose: PropTypes.func.isRequired, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   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 { | ||||||
|   <Column> |  | ||||||
|     <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} /> |  | ||||||
|     <div className='scrollable' /> |  | ||||||
|   </Column> |  | ||||||
| ); |  | ||||||
| 
 | 
 | ||||||
| ColumnLoading.propTypes = { |   static propTypes = { | ||||||
|   title: PropTypes.node, |     title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), | ||||||
|   icon: PropTypes.string, |     icon: PropTypes.string, | ||||||
| }; |   }; | ||||||
| 
 | 
 | ||||||
| export default ColumnLoading; |   static defaultProps = { | ||||||
|  |     title: '', | ||||||
|  |     icon: '', | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   render() { | ||||||
|  |     let { title, icon } = this.props; | ||||||
|  |     return ( | ||||||
|  |       <Column> | ||||||
|  |         <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} /> | ||||||
|  |         <div className='scrollable' /> | ||||||
|  |       </Column> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | |||||||
| @ -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", | ||||||
|  | |||||||