Compare commits
	
		
			211 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 61d3ecc805 | ||
|  | 4bb3e4eeba | ||
|  | 784c7510d7 | ||
|  | 6c54d2e583 | ||
|  | 96c942e8ab | ||
|  | aafe55af81 | ||
|  | fd49d5603a | ||
|  | 1c6fc0e4ce | ||
|  | 92e7815d1d | ||
|  | 9d97054fe6 | ||
|  | cc796298c9 | ||
|  | 7fd66cf2fe | ||
|  | 6e9e0c14e6 | ||
|  | 0aa810f9c8 | ||
|  | 3888a12c79 | ||
|  | cfa68907ae | ||
|  | 488584bfc1 | ||
|  | 0717d9b3e6 | ||
|  | 6e4046fc3f | ||
|  | f0c939c431 | ||
|  | ebadfe0ab7 | ||
|  | 94f15338c3 | ||
|  | db33a53ee8 | ||
|  | 7de6d269d2 | ||
|  | 684001d729 | ||
|  | 292f3cd7e0 | ||
|  | a3d4f1bd93 | ||
|  | 633426b261 | ||
|  | f486ef2666 | ||
|  | d2dee6ea43 | ||
|  | 967e70663f | ||
|  | b7e65a004f | ||
|  | 3a3475450e | ||
|  | 057db0ecd0 | ||
|  | 11436358b4 | ||
|  | 0e0a9e716c | ||
|  | 45682f876d | ||
|  | 4413d81d7f | ||
|  | 5a2c7bd4ce | ||
|  | 5c8ca024ef | ||
|  | d8b2f89d33 | ||
|  | d5f490b1a2 | ||
|  | c75ca0525b | ||
|  | 6f2d88dd28 | ||
|  | daa59dd454 | ||
|  | 72d939b69f | ||
|  | 97b3d0cd56 | ||
|  | fd7f0732fe | ||
|  | eb5ac23434 | ||
|  | a5143df303 | ||
|  | 2aca22b8ea | ||
|  | 7db0f8dcb2 | ||
|  | 49cc0eb3e7 | ||
|  | b9c76e2edb | ||
|  | 2559d9166c | ||
|  | 32e8a87830 | ||
|  | 636acb5712 | ||
|  | b406e3cc4c | ||
|  | 43577e9f59 | ||
|  | ecfa1c3f3b | ||
|  | b3af3f9f8c | ||
|  | d5091387c6 | ||
|  | 178f718a9b | ||
|  | 0e1b0f2747 | ||
|  | 468523f4ad | ||
|  | 2076c557c9 | ||
|  | d40c9140e8 | ||
|  | 632178d754 | ||
|  | 291feba6f1 | ||
|  | 63f0979799 | ||
|  | ec13cfa4f9 | ||
|  | cdd5ef691b | ||
|  | c743b5e1fd | ||
|  | dfaa219f88 | ||
|  | e6543d5fc4 | ||
|  | 813c5f2f52 | ||
|  | 82d9ade7a6 | ||
|  | 875d943c18 | ||
|  | 334a446313 | ||
|  | ecacb15cd5 | ||
|  | eb6ec3d068 | ||
|  | f303a954e6 | ||
|  | 395a57d03d | ||
|  | 0f699a4280 | ||
|  | 5e5f36c216 | ||
|  | a767ef85fa | ||
|  | 0db47196fb | ||
|  | c30e492587 | ||
|  | 97c02c3389 | ||
|  | 4453c9a9f5 | ||
|  | b9c612b561 | ||
|  | d841af4e80 | ||
|  | 01d6aa0397 | ||
|  | c567c87453 | ||
|  | 47ecd652d3 | ||
|  | 04fa4eb7f9 | ||
|  | cdad7977fc | ||
|  | 0b3f1ec62a | ||
|  | b110cc542f | ||
|  | cdacac8c6c | ||
|  | eb605141ff | ||
|  | 1e1d788757 | ||
|  | 1df453aff6 | ||
|  | f7c909e290 | ||
|  | 7481ae1bcb | ||
|  | cb3b0c1a0f | ||
|  | ca0e8be20c | ||
|  | 83ffc4dc07 | ||
|  | d6fe0954e3 | ||
|  | ebb8c89207 | ||
|  | 0060f98847 | ||
|  | 1a72813b53 | ||
|  | c3f9c74719 | ||
|  | 35a8cafa35 | ||
|  | f4ca116ea8 | ||
|  | 5b45c1646a | ||
|  | 887cd94e96 | ||
|  | d2f56d1cbc | ||
|  | d0b4709b2a | ||
|  | 6e0659c838 | ||
|  | 3406e30526 | ||
|  | 76f360c625 | ||
|  | a3202f61af | ||
|  | 4ec1771165 | ||
|  | 3d9b8847d2 | ||
|  | b9f59ebcc6 | ||
|  | e648ef0bfb | ||
|  | db3ed498b0 | ||
|  | 901fc48aae | ||
|  | 3caf0ba923 | ||
|  | 6e83b5e719 | ||
|  | b32a1d5754 | ||
|  | 9d53a38a44 | ||
|  | e528114c53 | ||
|  | cf7fbf2c56 | ||
|  | 91e5b0dfdb | ||
|  | 3b60832214 | ||
|  | 259181c41a | ||
|  | 510df0ac55 | ||
|  | 98936bfcdf | ||
|  | 5c82c2b75f | ||
|  | 0fea700c7b | ||
|  | 2c8e3fbbfb | ||
|  | b982d549f4 | ||
|  | 909a6d4661 | ||
|  | 3f3de38075 | ||
|  | c267acfcf7 | ||
|  | ab625c57ce | ||
|  | e756c4f5ec | ||
|  | a0bbeafb04 | ||
|  | 2f079573ed | ||
|  | b2820c3913 | ||
|  | adc38078dd | ||
|  | dae0af1fd2 | ||
|  | 5b8d0ad71b | ||
|  | 233258c61b | ||
|  | 9c8aad612e | ||
|  | 6dfeb64326 | ||
|  | 427beb4177 | ||
|  | 838f255fc2 | ||
|  | 1e02ba111a | ||
|  | 66126f3021 | ||
|  | 293972f716 | ||
|  | 9c8e602163 | ||
|  | 846cd4e838 | ||
|  | 0de82dd316 | ||
|  | dcfc9b2204 | ||
|  | b01ab55ed8 | ||
|  | dd4ef69839 | ||
|  | d4f80824f7 | ||
|  | 034fab39ab | ||
|  | 0df6442636 | ||
|  | 245816ab27 | ||
|  | 63819c848d | ||
|  | a9abe89093 | ||
|  | 798b0fc5af | ||
|  | 8fcfcddc8f | ||
|  | d68df88d4e | ||
|  | c8580eb806 | ||
|  | f41e70ca38 | ||
|  | 7b8ecde32d | ||
|  | 8cb4561145 | ||
|  | 1607bb445a | ||
|  | 33c39784e4 | ||
|  | 669fe9ee06 | ||
|  | b35406b700 | ||
|  | 8e33fc29d7 | ||
|  | 1de6c52545 | ||
|  | 34fa305a00 | ||
|  | bb4d005a83 | ||
|  | df1ce2350c | ||
|  | e5c65b3067 | ||
|  | 7d16bb379d | ||
|  | 0401a24558 | ||
|  | 4aea3f88a6 | ||
|  | 41e6c8b151 | ||
|  | 813e650729 | ||
|  | 1664e52cbb | ||
|  | dce869dfc7 | ||
|  | 09a94b575e | ||
|  | d43944143a | ||
|  | 81cec35dbf | ||
|  | c155d843f4 | ||
|  | 3d640dc8ac | ||
|  | 6db034a866 | ||
|  | 17bf3363ac | ||
|  | dcf1139ebd | ||
|  | 9619b7f727 | ||
|  | 66be6475b6 | ||
|  | 9e0985d9e4 | ||
|  | cf14f4945a | 
| @@ -1,5 +1,6 @@ | ||||
| # Service dependencies | ||||
| # 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_PORT=6379 | ||||
| # You may set DATABASE_URL instead for more advanced options | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| 2.4.1 | ||||
| 2.4.2 | ||||
|   | ||||
| @@ -26,18 +26,16 @@ addons: | ||||
|   postgresql: 9.4 | ||||
|   apt: | ||||
|     sources: | ||||
|     - ubuntu-toolchain-r-test | ||||
|     - trusty-media | ||||
|     packages: | ||||
|     - ffmpeg | ||||
|     - g++-6 | ||||
|     - libprotobuf-dev | ||||
|     - protobuf-compiler | ||||
|     - libicu-dev | ||||
|  | ||||
| rvm: | ||||
|   - 2.3.4 | ||||
|   - 2.4.1 | ||||
|   - 2.4.2 | ||||
|  | ||||
| services: | ||||
|   - redis-server | ||||
|   | ||||
							
								
								
									
										46
									
								
								.yarnclean
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								.yarnclean
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| # test directories | ||||
| __tests__ | ||||
| test | ||||
| tests | ||||
| powered-test | ||||
|  | ||||
| # asset directories | ||||
| docs | ||||
| doc | ||||
| website | ||||
| images | ||||
| # assets | ||||
|  | ||||
| # examples | ||||
| example | ||||
| examples | ||||
|  | ||||
| # code coverage directories | ||||
| coverage | ||||
| .nyc_output | ||||
|  | ||||
| # build scripts | ||||
| Makefile | ||||
| Gulpfile.js | ||||
| Gruntfile.js | ||||
|  | ||||
| # configs | ||||
| .tern-project | ||||
| .gitattributes | ||||
| .editorconfig | ||||
| .*ignore | ||||
| .eslintrc | ||||
| .jshintrc | ||||
| .flowconfig | ||||
| .documentup.json | ||||
| .yarn-metadata.json | ||||
| .*.yml | ||||
| *.yml | ||||
|  | ||||
| # misc | ||||
| *.gz | ||||
| *.md | ||||
|  | ||||
| # for specific ignore | ||||
| !.svgo.yml | ||||
|  | ||||
							
								
								
									
										21
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								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" \ | ||||
|       description="A GNU Social-compatible microblogging server" | ||||
| @@ -7,6 +7,8 @@ ENV UID=991 GID=991 \ | ||||
|     RAILS_SERVE_STATIC_FILES=true \ | ||||
|     RAILS_ENV=production NODE_ENV=production | ||||
|  | ||||
| ARG YARN_VERSION=1.1.0 | ||||
| ARG YARN_DOWNLOAD_SHA256=171c1f9ee93c488c0d774ac6e9c72649047c3f896277d88d0f805266519430f3 | ||||
| ARG LIBICONV_VERSION=1.15 | ||||
| ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 | ||||
|  | ||||
| @@ -19,6 +21,7 @@ RUN apk -U upgrade \ | ||||
|     build-base \ | ||||
|     icu-dev \ | ||||
|     libidn-dev \ | ||||
|     libressl \ | ||||
|     libtool \ | ||||
|     postgresql-dev \ | ||||
|     protobuf-dev \ | ||||
| @@ -32,16 +35,21 @@ RUN apk -U upgrade \ | ||||
|     imagemagick \ | ||||
|     libidn \ | ||||
|     libpq \ | ||||
|     nodejs-npm \ | ||||
|     nodejs \ | ||||
|     nodejs-npm \ | ||||
|     protobuf \ | ||||
|     su-exec \ | ||||
|     tini \ | ||||
|     yarn \ | ||||
|  && 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" \ | ||||
|  && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \ | ||||
|  && mkdir -p /tmp/src \ | ||||
|  && tar -xzf libiconv.tar.gz -C /tmp/src \ | ||||
|  && rm libiconv.tar.gz \ | ||||
|  && cd /tmp/src/libiconv-$LIBICONV_VERSION \ | ||||
| @@ -52,11 +60,12 @@ RUN apk -U upgrade \ | ||||
|  && cd /mastodon \ | ||||
|  && rm -rf /tmp/* /var/cache/apk/* | ||||
|  | ||||
| COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/ | ||||
| COPY Gemfile Gemfile.lock package.json yarn.lock .yarnclean /mastodon/ | ||||
|  | ||||
| RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \ | ||||
|  && bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \ | ||||
|  && yarn --ignore-optional --pure-lockfile | ||||
|  && yarn --pure-lockfile \ | ||||
|  && yarn cache clean | ||||
|  | ||||
| COPY . /mastodon | ||||
|  | ||||
|   | ||||
							
								
								
									
										9
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										9
									
								
								Gemfile
									
									
									
									
									
								
							| @@ -42,6 +42,7 @@ gem 'kaminari', '~> 1.0' | ||||
| gem 'link_header', '~> 0.0' | ||||
| gem 'mime-types', '~> 3.1' | ||||
| gem 'nokogiri', '~> 1.7' | ||||
| gem 'nsa', '~> 0.2' | ||||
| gem 'oj', '~> 3.0' | ||||
| gem 'ostatus2', '~> 2.0' | ||||
| gem 'ox', '~> 2.5' | ||||
| @@ -64,10 +65,10 @@ gem 'sidekiq-bulk', '~>0.1.1' | ||||
| gem 'simple-navigation', '~> 4.0' | ||||
| gem 'simple_form', '~> 3.4' | ||||
| gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' | ||||
| gem 'statsd-instrument', '~> 2.1' | ||||
| gem 'strong_migrations' | ||||
| gem 'twitter-text', '~> 1.14' | ||||
| gem 'tzinfo-data', '~> 1.2017' | ||||
| gem 'webpacker', '~> 2.0' | ||||
| gem 'webpacker', '~> 3.0' | ||||
| gem 'webpush' | ||||
|  | ||||
| gem 'json-ld-preloaded', '~> 2.2.1' | ||||
| @@ -102,8 +103,8 @@ group :development do | ||||
|   gem 'letter_opener', '~> 1.4' | ||||
|   gem 'letter_opener_web', '~> 1.3' | ||||
|   gem 'rubocop', require: false | ||||
|   gem 'brakeman', '~> 3.6', require: false | ||||
|   gem 'bundler-audit', '~> 0.5', require: false | ||||
|   gem 'brakeman', '~> 4.0', require: false | ||||
|   gem 'bundler-audit', '~> 0.6', require: false | ||||
|   gem 'scss_lint', '~> 0.53', require: false | ||||
|  | ||||
|   gem 'capistrano', '~> 3.8' | ||||
|   | ||||
							
								
								
									
										118
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										118
									
								
								Gemfile.lock
									
									
									
									
									
								
							| @@ -57,33 +57,33 @@ GEM | ||||
|       encryptor (~> 3.0.0) | ||||
|     av (0.9.0) | ||||
|       cocaine (~> 0.5.3) | ||||
|     aws-sdk (2.10.21) | ||||
|       aws-sdk-resources (= 2.10.21) | ||||
|     aws-sdk-core (2.10.21) | ||||
|     aws-sdk (2.10.46) | ||||
|       aws-sdk-resources (= 2.10.46) | ||||
|     aws-sdk-core (2.10.46) | ||||
|       aws-sigv4 (~> 1.0) | ||||
|       jmespath (~> 1.0) | ||||
|     aws-sdk-resources (2.10.21) | ||||
|       aws-sdk-core (= 2.10.21) | ||||
|     aws-sigv4 (1.0.1) | ||||
|     aws-sdk-resources (2.10.46) | ||||
|       aws-sdk-core (= 2.10.46) | ||||
|     aws-sigv4 (1.0.2) | ||||
|     bcrypt (3.1.11) | ||||
|     better_errors (2.1.1) | ||||
|     better_errors (2.3.0) | ||||
|       coderay (>= 1.0.0) | ||||
|       erubis (>= 2.6.6) | ||||
|       erubi (>= 1.0.0) | ||||
|       rack (>= 0.9.0) | ||||
|     binding_of_caller (0.7.2) | ||||
|       debug_inspector (>= 0.0.1) | ||||
|     bootsnap (1.1.2) | ||||
|     bootsnap (1.1.3) | ||||
|       msgpack (~> 1.0) | ||||
|     brakeman (3.7.2) | ||||
|     browser (2.4.0) | ||||
|     brakeman (4.0.1) | ||||
|     browser (2.5.1) | ||||
|     builder (3.2.3) | ||||
|     bullet (5.5.1) | ||||
|     bullet (5.6.1) | ||||
|       activesupport (>= 3.0.0) | ||||
|       uniform_notifier (~> 1.10.0) | ||||
|     bundler-audit (0.6.0) | ||||
|       bundler (~> 1.2) | ||||
|       thor (~> 0.18) | ||||
|     capistrano (3.8.2) | ||||
|     capistrano (3.9.1) | ||||
|       airbrussh (>= 1.0.0) | ||||
|       i18n | ||||
|       rake (>= 10.0.0) | ||||
| @@ -99,9 +99,9 @@ GEM | ||||
|       sshkit (~> 1.3) | ||||
|     capistrano-yarn (2.0.2) | ||||
|       capistrano (~> 3.0) | ||||
|     capybara (2.14.4) | ||||
|     capybara (2.15.1) | ||||
|       addressable | ||||
|       mime-types (>= 1.16) | ||||
|       mini_mime (>= 0.1.3) | ||||
|       nokogiri (>= 1.3.3) | ||||
|       rack (>= 1.0.0) | ||||
|       rack-test (>= 0.5.4) | ||||
| @@ -115,7 +115,7 @@ GEM | ||||
|     climate_control (0.2.0) | ||||
|     cocaine (0.5.8) | ||||
|       climate_control (>= 0.0.3, < 1.0) | ||||
|     coderay (1.1.1) | ||||
|     coderay (1.1.2) | ||||
|     colorize (0.8.1) | ||||
|     concurrent-ruby (1.0.5) | ||||
|     connection_pool (2.2.1) | ||||
| @@ -151,13 +151,12 @@ GEM | ||||
|       thread_safe | ||||
|     encryptor (3.0.0) | ||||
|     erubi (1.6.1) | ||||
|     erubis (2.7.0) | ||||
|     et-orbi (1.0.5) | ||||
|       tzinfo | ||||
|     excon (0.58.0) | ||||
|     excon (0.59.0) | ||||
|     execjs (2.7.0) | ||||
|     fabrication (2.16.2) | ||||
|     faker (1.7.3) | ||||
|     fabrication (2.16.3) | ||||
|     faker (1.8.4) | ||||
|       i18n (~> 0.5) | ||||
|     fast_blank (1.0.0) | ||||
|     ffi (1.9.18) | ||||
| @@ -194,7 +193,7 @@ GEM | ||||
|       railties (>= 4.0.1) | ||||
|     hamster (3.0.0) | ||||
|       concurrent-ruby (~> 1.0) | ||||
|     hashdiff (0.3.5) | ||||
|     hashdiff (0.3.6) | ||||
|     highline (1.7.8) | ||||
|     hiredis (0.6.1) | ||||
|     hkdf (0.3.0) | ||||
| @@ -213,11 +212,11 @@ GEM | ||||
|       colorize | ||||
|       rack | ||||
|     i18n (0.8.6) | ||||
|     i18n-tasks (0.9.16) | ||||
|     i18n-tasks (0.9.18) | ||||
|       activesupport (>= 4.0.2) | ||||
|       ast (>= 2.1.0) | ||||
|       easy_translate (>= 0.5.0) | ||||
|       erubis | ||||
|       erubi | ||||
|       highline (>= 1.7.3) | ||||
|       i18n | ||||
|       parser (>= 2.2.3.0) | ||||
| @@ -231,7 +230,7 @@ GEM | ||||
|     json-ld (2.1.5) | ||||
|       multi_json (~> 1.12) | ||||
|       rdf (~> 2.2) | ||||
|     json-ld-preloaded (2.2.1) | ||||
|     json-ld-preloaded (2.2.2) | ||||
|       json-ld (~> 2.1, >= 2.1.5) | ||||
|       multi_json (~> 1.11) | ||||
|       rdf (~> 2.2) | ||||
| @@ -258,10 +257,11 @@ GEM | ||||
|       letter_opener (~> 1.0) | ||||
|       railties (>= 3.2) | ||||
|     link_header (0.0.8) | ||||
|     lograge (0.5.1) | ||||
|     lograge (0.6.0) | ||||
|       actionpack (>= 4, < 5.2) | ||||
|       activesupport (>= 4, < 5.2) | ||||
|       railties (>= 4, < 5.2) | ||||
|       request_store (~> 1.0) | ||||
|     loofah (2.0.3) | ||||
|       nokogiri (>= 1.5.9) | ||||
|     mail (2.6.6) | ||||
| @@ -276,27 +276,33 @@ GEM | ||||
|       mime-types-data (~> 3.2015) | ||||
|     mime-types-data (3.2016.0521) | ||||
|     mimemagic (0.3.2) | ||||
|     mini_mime (0.1.4) | ||||
|     mini_portile2 (2.2.0) | ||||
|     minitest (5.10.3) | ||||
|     msgpack (1.1.0) | ||||
|     multi_json (1.12.1) | ||||
|     multi_json (1.12.2) | ||||
|     net-scp (1.2.1) | ||||
|       net-ssh (>= 2.6.5) | ||||
|     net-ssh (4.1.0) | ||||
|     net-ssh (4.2.0) | ||||
|     nio4r (2.1.0) | ||||
|     nokogiri (1.8.0) | ||||
|       mini_portile2 (~> 2.2.0) | ||||
|     nokogumbo (1.4.13) | ||||
|       nokogiri | ||||
|     oj (3.3.4) | ||||
|     openssl (2.0.4) | ||||
|     nsa (0.2.4) | ||||
|       activesupport (>= 4.2, < 6) | ||||
|       concurrent-ruby (~> 1.0.0) | ||||
|       sidekiq (>= 3.5.0) | ||||
|       statsd-ruby (~> 1.2.0) | ||||
|     oj (3.3.5) | ||||
|     openssl (2.0.5) | ||||
|     orm_adapter (0.5.0) | ||||
|     ostatus2 (2.0.1) | ||||
|       addressable (~> 2.4) | ||||
|       http (~> 2.0) | ||||
|       nokogiri (~> 1.6) | ||||
|       openssl (~> 2.0) | ||||
|     ox (2.5.0) | ||||
|     ox (2.6.0) | ||||
|     paperclip (5.1.0) | ||||
|       activemodel (>= 4.2.0) | ||||
|       activesupport (>= 4.2.0) | ||||
| @@ -306,15 +312,15 @@ GEM | ||||
|     paperclip-av-transcoder (0.6.4) | ||||
|       av (~> 0.9.0) | ||||
|       paperclip (>= 2.5.2) | ||||
|     parallel (1.11.2) | ||||
|     parallel_tests (2.14.2) | ||||
|     parallel (1.12.0) | ||||
|     parallel_tests (2.15.0) | ||||
|       parallel | ||||
|     parser (2.4.0.0) | ||||
|       ast (~> 2.2) | ||||
|     pg (0.21.0) | ||||
|     pghero (1.7.0) | ||||
|       activerecord | ||||
|     pkg-config (1.2.4) | ||||
|     pkg-config (1.2.7) | ||||
|     powerpack (0.1.1) | ||||
|     pry (0.10.4) | ||||
|       coderay (~> 1.1.0) | ||||
| @@ -334,6 +340,8 @@ GEM | ||||
|     rack-cors (0.4.1) | ||||
|     rack-protection (2.0.0) | ||||
|       rack | ||||
|     rack-proxy (0.6.2) | ||||
|       rack | ||||
|     rack-test (0.7.0) | ||||
|       rack (>= 1.0, < 3) | ||||
|     rack-timeout (0.4.2) | ||||
| @@ -371,8 +379,8 @@ GEM | ||||
|       thor (>= 0.18.1, < 2.0) | ||||
|     rainbow (2.2.2) | ||||
|       rake | ||||
|     rake (12.0.0) | ||||
|     rdf (2.2.8) | ||||
|     rake (12.1.0) | ||||
|     rdf (2.2.9) | ||||
|       hamster (~> 3.0) | ||||
|       link_header (~> 0.0, >= 0.0.8) | ||||
|     rdf-normalize (0.3.2) | ||||
| @@ -396,6 +404,7 @@ GEM | ||||
|       redis-store (>= 1.2, < 2) | ||||
|     redis-store (1.3.0) | ||||
|       redis (>= 2.2) | ||||
|     request_store (1.3.2) | ||||
|     responders (2.4.0) | ||||
|       actionpack (>= 4.2.0, < 5.3) | ||||
|       railties (>= 4.2.0, < 5.3) | ||||
| @@ -410,7 +419,7 @@ GEM | ||||
|     rspec-mocks (3.6.0) | ||||
|       diff-lcs (>= 1.2.0, < 2.0) | ||||
|       rspec-support (~> 3.6.0) | ||||
|     rspec-rails (3.6.0) | ||||
|     rspec-rails (3.6.1) | ||||
|       actionpack (>= 3.0) | ||||
|       activesupport (>= 3.0) | ||||
|       railties (>= 3.0) | ||||
| @@ -422,15 +431,15 @@ GEM | ||||
|       rspec-core (~> 3.0, >= 3.0.0) | ||||
|       sidekiq (>= 2.4.0) | ||||
|     rspec-support (3.6.0) | ||||
|     rubocop (0.49.1) | ||||
|     rubocop (0.50.0) | ||||
|       parallel (~> 1.10) | ||||
|       parser (>= 2.3.3.1, < 3.0) | ||||
|       powerpack (~> 0.1) | ||||
|       rainbow (>= 1.99.1, < 3.0) | ||||
|       rainbow (>= 2.2.2, < 3.0) | ||||
|       ruby-progressbar (~> 1.7) | ||||
|       unicode-display_width (~> 1.0, >= 1.0.1) | ||||
|     ruby-oembed (0.12.0) | ||||
|     ruby-progressbar (1.8.1) | ||||
|     ruby-progressbar (1.8.3) | ||||
|     rufus-scheduler (3.4.2) | ||||
|       et-orbi (~> 1.0) | ||||
|     safe_yaml (1.0.4) | ||||
| @@ -438,7 +447,7 @@ GEM | ||||
|       crass (~> 1.0.2) | ||||
|       nokogiri (>= 1.4.4) | ||||
|       nokogumbo (~> 1.4.1) | ||||
|     sass (3.4.24) | ||||
|     sass (3.4.25) | ||||
|     scss_lint (0.54.0) | ||||
|       rake (>= 0.9, < 13) | ||||
|       sass (~> 3.4.20) | ||||
| @@ -450,12 +459,12 @@ GEM | ||||
|     sidekiq-bulk (0.1.1) | ||||
|       activesupport | ||||
|       sidekiq | ||||
|     sidekiq-scheduler (2.1.8) | ||||
|     sidekiq-scheduler (2.1.9) | ||||
|       redis (~> 3) | ||||
|       rufus-scheduler (~> 3.2) | ||||
|       sidekiq (>= 3) | ||||
|       tilt (>= 1.4.0) | ||||
|     sidekiq-unique-jobs (5.0.9) | ||||
|     sidekiq-unique-jobs (5.0.10) | ||||
|       sidekiq (>= 4.0, <= 6.0) | ||||
|       thor (~> 0) | ||||
|     simple-navigation (4.0.5) | ||||
| @@ -463,11 +472,11 @@ GEM | ||||
|     simple_form (3.5.0) | ||||
|       actionpack (> 4, < 5.2) | ||||
|       activemodel (> 4, < 5.2) | ||||
|     simplecov (0.14.1) | ||||
|     simplecov (0.15.1) | ||||
|       docile (~> 1.1.0) | ||||
|       json (>= 1.8, < 3) | ||||
|       simplecov-html (~> 0.10.0) | ||||
|     simplecov-html (0.10.1) | ||||
|     simplecov-html (0.10.2) | ||||
|     slop (3.6.0) | ||||
|     sprockets (3.7.1) | ||||
|       concurrent-ruby (~> 1.0) | ||||
| @@ -476,10 +485,12 @@ GEM | ||||
|       actionpack (>= 4.0) | ||||
|       activesupport (>= 4.0) | ||||
|       sprockets (>= 3.0.0) | ||||
|     sshkit (1.13.1) | ||||
|     sshkit (1.14.0) | ||||
|       net-scp (>= 1.1.2) | ||||
|       net-ssh (>= 2.8.0) | ||||
|     statsd-instrument (2.1.4) | ||||
|     statsd-ruby (1.2.1) | ||||
|     strong_migrations (0.1.9) | ||||
|       activerecord (>= 3.2.0) | ||||
|     temple (0.8.0) | ||||
|     terminal-table (1.8.0) | ||||
|       unicode-display_width (~> 1.1, >= 1.1.1) | ||||
| @@ -506,9 +517,9 @@ GEM | ||||
|       addressable (>= 2.3.6) | ||||
|       crack (>= 0.3.2) | ||||
|       hashdiff | ||||
|     webpacker (2.0) | ||||
|     webpacker (3.0.1) | ||||
|       activesupport (>= 4.2) | ||||
|       multi_json (~> 1.2) | ||||
|       rack-proxy (>= 0.6.1) | ||||
|       railties (>= 4.2) | ||||
|     webpush (0.3.2) | ||||
|       hkdf (~> 0.2) | ||||
| @@ -531,10 +542,10 @@ DEPENDENCIES | ||||
|   better_errors (~> 2.1) | ||||
|   binding_of_caller (~> 0.7) | ||||
|   bootsnap | ||||
|   brakeman (~> 3.6) | ||||
|   brakeman (~> 4.0) | ||||
|   browser | ||||
|   bullet (~> 5.5) | ||||
|   bundler-audit (~> 0.5) | ||||
|   bundler-audit (~> 0.6) | ||||
|   capistrano (~> 3.8) | ||||
|   capistrano-rails (~> 1.2) | ||||
|   capistrano-rbenv (~> 2.1) | ||||
| @@ -572,6 +583,7 @@ DEPENDENCIES | ||||
|   microformats (~> 4.0) | ||||
|   mime-types (~> 3.1) | ||||
|   nokogiri (~> 1.7) | ||||
|   nsa (~> 0.2) | ||||
|   oj (~> 3.0) | ||||
|   ostatus2 (~> 2.0) | ||||
|   ox (~> 2.5) | ||||
| @@ -611,16 +623,16 @@ DEPENDENCIES | ||||
|   simple_form (~> 3.4) | ||||
|   simplecov (~> 0.14) | ||||
|   sprockets-rails (~> 3.2) | ||||
|   statsd-instrument (~> 2.1) | ||||
|   strong_migrations | ||||
|   twitter-text (~> 1.14) | ||||
|   tzinfo-data (~> 1.2017) | ||||
|   uglifier (~> 3.2) | ||||
|   webmock (~> 3.0) | ||||
|   webpacker (~> 2.0) | ||||
|   webpacker (~> 3.0) | ||||
|   webpush | ||||
|  | ||||
| RUBY VERSION | ||||
|    ruby 2.4.1p111 | ||||
|    ruby 2.4.2p198 | ||||
|  | ||||
| BUNDLED WITH | ||||
|    1.15.4 | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| web: PORT=3000 bundle exec puma -C config/puma.rb | ||||
| sidekiq: PORT=3000 bundle exec sidekiq | ||||
| 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 | ||||
|   | ||||
							
								
								
									
										67
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								README.md
									
									
									
									
									
								
							| @@ -7,46 +7,62 @@ | ||||
| [travis]: https://travis-ci.org/tootsuite/mastodon | ||||
| [code_climate]: https://codeclimate.com/github/tootsuite/mastodon | ||||
|  | ||||
| Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. | ||||
| Mastodon is a **free, open-source social network server** based on **open web protocols** like ActivityPub and OStatus. The social focus of the project is a viable decentralized alternative to commercial social media silos that returns the control of the content distribution channels to the people. The technical focus of the project is a good user interface, a clean REST API for 3rd party apps and robust anti-abuse tools. | ||||
|  | ||||
| An alternative implementation of the GNU social project. Based on [ActivityStreams](https://en.wikipedia.org/wiki/Activity_Streams_(format)), [Webfinger](https://en.wikipedia.org/wiki/WebFinger), [WebSub](https://en.wikipedia.org/wiki/WebSub) and [Salmon](https://en.wikipedia.org/wiki/Salmon_(protocol)). | ||||
|  | ||||
| Click on the screenshot to watch a demo of the UI: | ||||
| Click on the screenshot below to watch a demo of the UI: | ||||
|  | ||||
| [][youtube_demo] | ||||
|  | ||||
| [youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU | ||||
|  | ||||
| The project focus is a clean REST API and a good user interface. Ruby on Rails is used for the back-end, while React.js and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided. | ||||
| **Ruby on Rails** is used for the back-end, while **React.js** and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided. | ||||
|  | ||||
| If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd` | ||||
|  | ||||
| [patreon]: https://www.patreon.com/user?u=619786 | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Resources | ||||
|  | ||||
| - [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md) | ||||
| - [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com) | ||||
| - [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) | ||||
| - [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md) | ||||
| - [Use this tool to find Twitter friends on Mastodon](https://bridge.joinmastodon.org) | ||||
| - [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) | ||||
| - [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md) | ||||
| - [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md) | ||||
| - [List of sponsors](https://joinmastodon.org/sponsors) | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| - **Fully interoperable with GNU social and any OStatus platform** | ||||
|   Whatever implements Atom feeds, ActivityStreams, Salmon, WebSub and Webfinger is part of the network | ||||
| - **Real-time timeline updates** | ||||
|   See the updates of people you're following appear in real-time in the UI via WebSockets | ||||
| - **Federated thread resolving** | ||||
|   If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI | ||||
| - **Media attachments like images and WebM** | ||||
|   Upload and view images and WebM videos attached to the updates | ||||
| - **OAuth2 and a straightforward REST API** | ||||
|   Mastodon acts as an OAuth2 provider so 3rd party apps can use the API, which is RESTful and simple | ||||
| - **Background processing for long-running tasks** | ||||
|   Mastodon tries to be as fast and responsive as possible, so all long-running tasks that can be delegated to background processing, are | ||||
| - **Deployable via Docker** | ||||
|   You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy | ||||
| **No vendor lock-in: Fully interoperable with any conforming platform** | ||||
|  | ||||
| It doesn't have to be Mastodon, whatever implements ActivityPub or OStatus is part of the social network! | ||||
|  | ||||
| **Real-time timeline updates** | ||||
|  | ||||
| See the updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well! | ||||
|  | ||||
| **Federated thread resolving** | ||||
|  | ||||
| If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI | ||||
|  | ||||
| **Media attachments like images and short videos** | ||||
|  | ||||
| Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos are looped - like vines! | ||||
|  | ||||
| **OAuth2 and a straightforward REST API** | ||||
|  | ||||
| Mastodon acts as an OAuth2 provider so 3rd party apps can use the API | ||||
|  | ||||
| **Fast response times** | ||||
|  | ||||
| Mastodon tries to be as fast and responsive as possible, so all long-running tasks are delegated to background processing | ||||
|  | ||||
| **Deployable via Docker** | ||||
|  | ||||
| You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Development | ||||
|  | ||||
| @@ -62,9 +78,8 @@ You can open issues for bugs you've found or features you think are missing. You | ||||
|  | ||||
| **IRC channel**: #mastodon on irc.freenode.net | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Extra credits | ||||
|  | ||||
| - The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis | ||||
| - The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo) | ||||
|  | ||||
|  | ||||
| The elephant friend illustrations are created by [Dopatwo](https://mastodon.social/@dopatwo) | ||||
|   | ||||
| @@ -26,7 +26,10 @@ class AccountsController < ApplicationController | ||||
|       end | ||||
|  | ||||
|       format.json do | ||||
|         render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||
|         render json: @account, | ||||
|                serializer: ActivityPub::ActorSerializer, | ||||
|                adapter: ActivityPub::Adapter, | ||||
|                content_type: 'application/activity+json' | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|   | ||||
| @@ -9,9 +9,9 @@ class ActivityPub::InboxesController < Api::BaseController | ||||
|     if signed_request_account | ||||
|       upgrade_account | ||||
|       process_payload | ||||
|       head 201 | ||||
|     else | ||||
|       head 202 | ||||
|     else | ||||
|       [signature_verification_failure_reason, 401] | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -32,6 +32,7 @@ class ActivityPub::InboxesController < Api::BaseController | ||||
|     end | ||||
|  | ||||
|     Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? | ||||
|     DeliveryFailureTracker.track_inverse_success!(signed_request_account) | ||||
|   end | ||||
|  | ||||
|   def process_payload | ||||
|   | ||||
							
								
								
									
										31
									
								
								app/controllers/admin/account_moderation_notes_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								app/controllers/admin/account_moderation_notes_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::AccountModerationNotesController < Admin::BaseController | ||||
|   def create | ||||
|     @account_moderation_note = current_account.account_moderation_notes.new(resource_params) | ||||
|     if @account_moderation_note.save | ||||
|       @target_account = @account_moderation_note.target_account | ||||
|       redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.created_msg') | ||||
|     else | ||||
|       @account = @account_moderation_note.target_account | ||||
|       @moderation_notes = @account.targeted_moderation_notes.latest | ||||
|       render template: 'admin/accounts/show' | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def destroy | ||||
|     @account_moderation_note = AccountModerationNote.find(params[:id]) | ||||
|     @target_account = @account_moderation_note.target_account | ||||
|     @account_moderation_note.destroy | ||||
|     redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg') | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def resource_params | ||||
|     params.require(:account_moderation_note).permit( | ||||
|       :content, | ||||
|       :target_account_id | ||||
|     ) | ||||
|   end | ||||
| end | ||||
| @@ -9,7 +9,10 @@ module Admin | ||||
|       @accounts = filtered_accounts.page(params[:page]) | ||||
|     end | ||||
|  | ||||
|     def show; end | ||||
|     def show | ||||
|       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) | ||||
|       @moderation_notes = @account.targeted_moderation_notes.latest | ||||
|     end | ||||
|  | ||||
|     def subscribe | ||||
|       Pubsubhubbub::SubscribeWorker.perform_async(@account.id) | ||||
|   | ||||
							
								
								
									
										71
									
								
								app/controllers/admin/custom_emojis_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								app/controllers/admin/custom_emojis_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| module Admin | ||||
|   class CustomEmojisController < BaseController | ||||
|     before_action :set_custom_emoji, except: [:index, :new, :create] | ||||
|  | ||||
|     def index | ||||
|       @custom_emojis = filtered_custom_emojis.page(params[:page]) | ||||
|     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 | ||||
|       @custom_emoji.destroy | ||||
|       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg') | ||||
|     end | ||||
|  | ||||
|     def copy | ||||
|       emoji = CustomEmoji.new(domain: nil, shortcode: @custom_emoji.shortcode, image: @custom_emoji.image) | ||||
|  | ||||
|       if emoji.save | ||||
|         redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.copied_msg') | ||||
|       else | ||||
|         redirect_to admin_custom_emojis_path, alert: I18n.t('admin.custom_emojis.copy_failed_msg') | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     def enable | ||||
|       @custom_emoji.update!(disabled: false) | ||||
|       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg') | ||||
|     end | ||||
|  | ||||
|     def disable | ||||
|       @custom_emoji.update!(disabled: true) | ||||
|       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg') | ||||
|     end | ||||
|  | ||||
|     private | ||||
|  | ||||
|     def set_custom_emoji | ||||
|       @custom_emoji = CustomEmoji.find(params[:id]) | ||||
|     end | ||||
|  | ||||
|     def resource_params | ||||
|       params.require(:custom_emoji).permit(:shortcode, :image) | ||||
|     end | ||||
|  | ||||
|     def filtered_custom_emojis | ||||
|       CustomEmojiFilter.new(filter_params).results | ||||
|     end | ||||
|  | ||||
|     def filter_params | ||||
|       params.permit( | ||||
|         :local, | ||||
|         :remote | ||||
|       ) | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										40
									
								
								app/controllers/admin/email_domain_blocks_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								app/controllers/admin/email_domain_blocks_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| module Admin | ||||
|   class EmailDomainBlocksController < BaseController | ||||
|     before_action :set_email_domain_block, only: [:show, :destroy] | ||||
|  | ||||
|     def index | ||||
|       @email_domain_blocks = EmailDomainBlock.page(params[:page]) | ||||
|     end | ||||
|  | ||||
|     def new | ||||
|       @email_domain_block = EmailDomainBlock.new | ||||
|     end | ||||
|  | ||||
|     def create | ||||
|       @email_domain_block = EmailDomainBlock.new(resource_params) | ||||
|  | ||||
|       if @email_domain_block.save | ||||
|         redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg') | ||||
|       else | ||||
|         render :new | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     def destroy | ||||
|       @email_domain_block.destroy | ||||
|       redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg') | ||||
|     end | ||||
|  | ||||
|     private | ||||
|  | ||||
|     def set_email_domain_block | ||||
|       @email_domain_block = EmailDomainBlock.find(params[:id]) | ||||
|     end | ||||
|  | ||||
|     def resource_params | ||||
|       params.require(:email_domain_block).permit(:domain) | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -7,9 +7,11 @@ class Api::SalmonController < Api::BaseController | ||||
|   def update | ||||
|     if verify_payload? | ||||
|       process_salmon | ||||
|       head 201 | ||||
|     else | ||||
|       head 202 | ||||
|     elsif payload.present? | ||||
|       [signature_verification_failure_reason, 401] | ||||
|     else | ||||
|       head 400 | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController | ||||
|   respond_to :json | ||||
|  | ||||
|   def index | ||||
|     @accounts = Account.where(id: account_ids).select('id') | ||||
|     accounts = Account.where(id: account_ids).select('id') | ||||
|     # .where doesn't guarantee that our results are in the same order | ||||
|     # we requested them, so return the "right" order to the requestor. | ||||
|     @accounts = accounts.index_by(&:id).values_at(*account_ids) | ||||
|     render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships | ||||
|   end | ||||
|  | ||||
|   | ||||
							
								
								
									
										11
									
								
								app/controllers/api/v1/apps/credentials_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/controllers/api/v1/apps/credentials_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Api::V1::Apps::CredentialsController < Api::BaseController | ||||
|   before_action -> { doorkeeper_authorize! :read } | ||||
|  | ||||
|   respond_to :json | ||||
|  | ||||
|   def show | ||||
|     render json: doorkeeper_token.application, serializer: REST::StatusSerializer::ApplicationSerializer | ||||
|   end | ||||
| end | ||||
| @@ -1,8 +1,6 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Api::V1::AppsController < Api::BaseController | ||||
|   respond_to :json | ||||
|  | ||||
|   def create | ||||
|     @app = Doorkeeper::Application.create!(application_options) | ||||
|     render json: @app, serializer: REST::ApplicationSerializer | ||||
|   | ||||
| @@ -15,19 +15,17 @@ class Api::V1::BlocksController < Api::BaseController | ||||
|   private | ||||
|  | ||||
|   def load_accounts | ||||
|     default_accounts.merge(paginated_blocks).to_a | ||||
|   end | ||||
|  | ||||
|   def default_accounts | ||||
|     Account.includes(:blocked_by).references(:blocked_by) | ||||
|     paginated_blocks.map(&:target_account) | ||||
|   end | ||||
|  | ||||
|   def paginated_blocks | ||||
|     Block.where(account: current_account).paginate_by_max_id( | ||||
|       limit_param(DEFAULT_ACCOUNTS_LIMIT), | ||||
|       params[:max_id], | ||||
|       params[:since_id] | ||||
|     ) | ||||
|     @paginated_blocks ||= Block.eager_load(:target_account) | ||||
|                                .where(account: current_account) | ||||
|                                .paginate_by_max_id( | ||||
|                                  limit_param(DEFAULT_ACCOUNTS_LIMIT), | ||||
|                                  params[:max_id], | ||||
|                                  params[:since_id] | ||||
|                                ) | ||||
|   end | ||||
|  | ||||
|   def insert_pagination_headers | ||||
| @@ -41,21 +39,21 @@ class Api::V1::BlocksController < Api::BaseController | ||||
|   end | ||||
|  | ||||
|   def prev_path | ||||
|     unless @accounts.empty? | ||||
|     unless paginated_blocks.empty? | ||||
|       api_v1_blocks_url pagination_params(since_id: pagination_since_id) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def pagination_max_id | ||||
|     @accounts.last.blocked_by_ids.last | ||||
|     paginated_blocks.last.id | ||||
|   end | ||||
|  | ||||
|   def pagination_since_id | ||||
|     @accounts.first.blocked_by_ids.first | ||||
|     paginated_blocks.first.id | ||||
|   end | ||||
|  | ||||
|   def records_continue? | ||||
|     @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) | ||||
|     paginated_blocks.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) | ||||
|   end | ||||
|  | ||||
|   def pagination_params(core_params) | ||||
|   | ||||
							
								
								
									
										9
									
								
								app/controllers/api/v1/custom_emojis_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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.where(disabled: false), each_serializer: REST::CustomEmojiSerializer | ||||
|   end | ||||
| end | ||||
| @@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController | ||||
|   respond_to :json | ||||
|  | ||||
|   def create | ||||
|     @media = current_account.media_attachments.create!(file: media_params[:file]) | ||||
|     @media = current_account.media_attachments.create!(media_params) | ||||
|     render json: @media, serializer: REST::MediaAttachmentSerializer | ||||
|   rescue Paperclip::Errors::NotIdentifiedByImageMagickError | ||||
|     render json: file_type_error, status: 422 | ||||
| @@ -18,10 +18,16 @@ class Api::V1::MediaController < Api::BaseController | ||||
|     render json: processing_error, status: 500 | ||||
|   end | ||||
|  | ||||
|   def update | ||||
|     @media = current_account.media_attachments.where(status_id: nil).find(params[:id]) | ||||
|     @media.update!(media_params) | ||||
|     render json: @media, serializer: REST::MediaAttachmentSerializer | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def media_params | ||||
|     params.permit(:file) | ||||
|     params.permit(:file, :description) | ||||
|   end | ||||
|  | ||||
|   def file_type_error | ||||
|   | ||||
| @@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base | ||||
|  | ||||
|   helper_method :current_account | ||||
|   helper_method :current_session | ||||
|   helper_method :current_theme | ||||
|   helper_method :single_user_mode? | ||||
|  | ||||
|   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']) | ||||
|   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) | ||||
|     return raw unless klass.respond_to?(:with_includes) | ||||
|  | ||||
|   | ||||
| @@ -9,10 +9,15 @@ module SignatureVerification | ||||
|     request.headers['Signature'].present? | ||||
|   end | ||||
|  | ||||
|   def signature_verification_failure_reason | ||||
|     return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason) | ||||
|   end | ||||
|  | ||||
|   def signed_request_account | ||||
|     return @signed_request_account if defined?(@signed_request_account) | ||||
|  | ||||
|     unless signed_request? | ||||
|       @signature_verification_failure_reason = 'Request not signed' | ||||
|       @signed_request_account = nil | ||||
|       return | ||||
|     end | ||||
| @@ -27,6 +32,7 @@ module SignatureVerification | ||||
|     end | ||||
|  | ||||
|     if incompatible_signature?(signature_params) | ||||
|       @signature_verification_failure_reason = 'Incompatible request signature' | ||||
|       @signed_request_account = nil | ||||
|       return | ||||
|     end | ||||
| @@ -34,6 +40,7 @@ module SignatureVerification | ||||
|     account = account_from_key_id(signature_params['keyId']) | ||||
|  | ||||
|     if account.nil? | ||||
|       @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" | ||||
|       @signed_request_account = nil | ||||
|       return | ||||
|     end | ||||
| @@ -44,7 +51,18 @@ module SignatureVerification | ||||
|     if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) | ||||
|       @signed_request_account = account | ||||
|       @signed_request_account | ||||
|     elsif account.possibly_stale? | ||||
|       account = account.refresh! | ||||
|  | ||||
|       if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) | ||||
|         @signed_request_account = account | ||||
|         @signed_request_account | ||||
|       else | ||||
|         @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" | ||||
|         @signed_request_account = nil | ||||
|       end | ||||
|     else | ||||
|       @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" | ||||
|       @signed_request_account = nil | ||||
|     end | ||||
|   end | ||||
| @@ -99,7 +117,7 @@ module SignatureVerification | ||||
|       ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) | ||||
|     elsif !ActivityPub::TagManager.instance.local_uri?(key_id) | ||||
|       account   = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) | ||||
|       account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) | ||||
|       account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) | ||||
|       account | ||||
|     end | ||||
|   end | ||||
|   | ||||
							
								
								
									
										22
									
								
								app/controllers/emojis_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/controllers/emojis_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class EmojisController < ApplicationController | ||||
|   before_action :set_emoji | ||||
|  | ||||
|   def show | ||||
|     respond_to do |format| | ||||
|       format.json do | ||||
|         render json: @emoji, | ||||
|                serializer: ActivityPub::EmojiSerializer, | ||||
|                adapter: ActivityPub::Adapter, | ||||
|                content_type: 'application/activity+json' | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_emoji | ||||
|     @emoji = CustomEmoji.local.find(params[:id]) | ||||
|   end | ||||
| end | ||||
| @@ -10,19 +10,39 @@ class FollowerAccountsController < ApplicationController | ||||
|       format.html | ||||
|  | ||||
|       format.json do | ||||
|         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||
|         render json: collection_presenter, | ||||
|                serializer: ActivityPub::CollectionSerializer, | ||||
|                adapter: ActivityPub::Adapter, | ||||
|                content_type: 'application/activity+json' | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def page_url(page) | ||||
|     account_followers_url(@account, page: page) unless page.nil? | ||||
|   end | ||||
|  | ||||
|   def collection_presenter | ||||
|     ActivityPub::CollectionPresenter.new( | ||||
|       id: account_followers_url(@account), | ||||
|     page = ActivityPub::CollectionPresenter.new( | ||||
|       id: account_followers_url(@account, page: params.fetch(:page, 1)), | ||||
|       type: :ordered, | ||||
|       size: @account.followers_count, | ||||
|       items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) } | ||||
|       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 | ||||
|   | ||||
| @@ -10,19 +10,39 @@ class FollowingAccountsController < ApplicationController | ||||
|       format.html | ||||
|  | ||||
|       format.json do | ||||
|         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||
|         render json: collection_presenter, | ||||
|                serializer: ActivityPub::CollectionSerializer, | ||||
|                adapter: ActivityPub::Adapter, | ||||
|                content_type: 'application/activity+json' | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def page_url(page) | ||||
|     account_following_index_url(@account, page: page) unless page.nil? | ||||
|   end | ||||
|  | ||||
|   def collection_presenter | ||||
|     ActivityPub::CollectionPresenter.new( | ||||
|       id: account_following_index_url(@account), | ||||
|     page = ActivityPub::CollectionPresenter.new( | ||||
|       id: account_following_index_url(@account, page: params.fetch(:page, 1)), | ||||
|       type: :ordered, | ||||
|       size: @account.following_count, | ||||
|       items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) } | ||||
|       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 | ||||
|   | ||||
| @@ -1,11 +1,7 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class ManifestsController < ApplicationController | ||||
|   before_action :set_instance_presenter | ||||
|  | ||||
|   def show; end | ||||
|  | ||||
|   def set_instance_presenter | ||||
|     @instance_presenter = InstancePresenter.new | ||||
|   def show | ||||
|     render json: InstancePresenter.new, serializer: ManifestSerializer | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -18,7 +18,7 @@ class MediaProxyController < ApplicationController | ||||
|  | ||||
|   def redownload! | ||||
|     @media_attachment.file_remote_url = @media_attachment.remote_url | ||||
|     @media_attachment.touch(:created_at) | ||||
|     @media_attachment.created_at      = Time.now.utc | ||||
|     @media_attachment.save! | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ class Settings::FollowerDomainsController < ApplicationController | ||||
|  | ||||
|   def show | ||||
|     @account = current_account | ||||
|     @domains = current_account.followers.reorder(nil).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) | ||||
|     @domains = current_account.followers.reorder('MIN(follows.id) DESC').group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) | ||||
|   end | ||||
|  | ||||
|   def update | ||||
|   | ||||
							
								
								
									
										32
									
								
								app/controllers/settings/notifications_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								app/controllers/settings/notifications_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Settings::NotificationsController < ApplicationController | ||||
|   layout 'admin' | ||||
|  | ||||
|   before_action :authenticate_user! | ||||
|  | ||||
|   def show; end | ||||
|  | ||||
|   def update | ||||
|     user_settings.update(user_settings_params.to_h) | ||||
|  | ||||
|     if current_user.save | ||||
|       redirect_to settings_notifications_path, notice: I18n.t('generic.changes_saved_msg') | ||||
|     else | ||||
|       render :show | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def user_settings | ||||
|     UserSettingsDecorator.new(current_user) | ||||
|   end | ||||
|  | ||||
|   def user_settings_params | ||||
|     params.require(:user).permit( | ||||
|       notification_emails: %i(follow follow_request reblog favourite mention digest), | ||||
|       interactions: %i(must_be_follower must_be_following) | ||||
|     ) | ||||
|   end | ||||
| end | ||||
| @@ -41,6 +41,7 @@ class Settings::PreferencesController < ApplicationController | ||||
|       :setting_auto_play_gif, | ||||
|       :setting_system_font_ui, | ||||
|       :setting_noindex, | ||||
|       :setting_theme, | ||||
|       notification_emails: %i(follow follow_request reblog favourite mention digest), | ||||
|       interactions: %i(must_be_follower must_be_following) | ||||
|     ) | ||||
|   | ||||
| @@ -21,13 +21,19 @@ class StatusesController < ApplicationController | ||||
|       end | ||||
|  | ||||
|       format.json do | ||||
|         render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||
|         render json: @status, | ||||
|                serializer: ActivityPub::NoteSerializer, | ||||
|                adapter: ActivityPub::Adapter, | ||||
|                content_type: 'application/activity+json' | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def activity | ||||
|     render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||
|     render json: @status, | ||||
|            serializer: ActivityPub::ActivitySerializer, | ||||
|            adapter: ActivityPub::Adapter, | ||||
|            content_type: 'application/activity+json' | ||||
|   end | ||||
|  | ||||
|   def embed | ||||
|   | ||||
| @@ -1,24 +1,40 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class TagsController < ApplicationController | ||||
|   layout 'public' | ||||
|   before_action :set_body_classes | ||||
|   before_action :set_instance_presenter | ||||
|  | ||||
|   def show | ||||
|     @tag      = Tag.find_by!(name: params[:id].downcase) | ||||
|     @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) | ||||
|     @statuses = cache_collection(@statuses, Status) | ||||
|     @tag = Tag.find_by!(name: params[:id].downcase) | ||||
|  | ||||
|     respond_to do |format| | ||||
|       format.html | ||||
|       format.html do | ||||
|         serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) | ||||
|         @initial_state_json   = serializable_resource.to_json | ||||
|       end | ||||
|  | ||||
|       format.json do | ||||
|         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||
|         @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) | ||||
|         @statuses = cache_collection(@statuses, Status) | ||||
|  | ||||
|         render json: collection_presenter, | ||||
|                serializer: ActivityPub::CollectionSerializer, | ||||
|                adapter: ActivityPub::Adapter, | ||||
|                content_type: 'application/activity+json' | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_body_classes | ||||
|     @body_classes = 'tag-body' | ||||
|   end | ||||
|  | ||||
|   def set_instance_presenter | ||||
|     @instance_presenter = InstancePresenter.new | ||||
|   end | ||||
|  | ||||
|   def collection_presenter | ||||
|     ActivityPub::CollectionPresenter.new( | ||||
|       id: tag_url(@tag), | ||||
| @@ -27,4 +43,11 @@ class TagsController < ApplicationController | ||||
|       items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def initial_state_params | ||||
|     { | ||||
|       settings: {}, | ||||
|       token: current_session&.token, | ||||
|     } | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										4
									
								
								app/helpers/admin/account_moderation_notes_helper.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								app/helpers/admin/account_moderation_notes_helper.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| module Admin::AccountModerationNotesHelper | ||||
| 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 | ||||
| @@ -22,7 +22,18 @@ module JsonLdHelper | ||||
|     graph.dump(:normalize) | ||||
|   end | ||||
|  | ||||
|   def fetch_resource(uri) | ||||
|   def fetch_resource(uri, id) | ||||
|     unless id | ||||
|       json = fetch_resource_without_id_validation(uri) | ||||
|       return unless json | ||||
|       uri = json['id'] | ||||
|     end | ||||
|  | ||||
|     json = fetch_resource_without_id_validation(uri) | ||||
|     json.present? && json['id'] == uri ? json : nil | ||||
|   end | ||||
|  | ||||
|   def fetch_resource_without_id_validation(uri) | ||||
|     response = build_request(uri).perform | ||||
|     return if response.code != 200 | ||||
|     body_to_json(response.to_s) | ||||
|   | ||||
| @@ -1,4 +1,7 @@ | ||||
| import api from '../api'; | ||||
| import { throttle } from 'lodash'; | ||||
| import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; | ||||
| import { useEmoji } from './emojis'; | ||||
|  | ||||
| import { | ||||
|   updateTimeline, | ||||
| @@ -14,6 +17,7 @@ export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL'; | ||||
| export const COMPOSE_REPLY           = 'COMPOSE_REPLY'; | ||||
| export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL'; | ||||
| export const COMPOSE_MENTION         = 'COMPOSE_MENTION'; | ||||
| export const COMPOSE_RESET           = 'COMPOSE_RESET'; | ||||
| export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST'; | ||||
| export const COMPOSE_UPLOAD_SUCCESS  = 'COMPOSE_UPLOAD_SUCCESS'; | ||||
| export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL'; | ||||
| @@ -36,6 +40,10 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; | ||||
|  | ||||
| export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; | ||||
|  | ||||
| export const COMPOSE_UPLOAD_CHANGE_REQUEST     = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; | ||||
| export const COMPOSE_UPLOAD_CHANGE_SUCCESS     = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; | ||||
| export const COMPOSE_UPLOAD_CHANGE_FAIL        = 'COMPOSE_UPLOAD_UPDATE_FAIL'; | ||||
|  | ||||
| export function changeCompose(text) { | ||||
|   return { | ||||
|     type: COMPOSE_CHANGE, | ||||
| @@ -62,6 +70,12 @@ export function cancelReplyCompose() { | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function resetCompose() { | ||||
|   return { | ||||
|     type: COMPOSE_RESET, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function mentionCompose(account, router) { | ||||
|   return (dispatch, getState) => { | ||||
|     dispatch({ | ||||
| @@ -164,6 +178,40 @@ export function uploadCompose(files) { | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function changeUploadCompose(id, description) { | ||||
|   return (dispatch, getState) => { | ||||
|     dispatch(changeUploadComposeRequest()); | ||||
|  | ||||
|     api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { | ||||
|       dispatch(changeUploadComposeSuccess(response.data)); | ||||
|     }).catch(error => { | ||||
|       dispatch(changeUploadComposeFail(id, error)); | ||||
|     }); | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function changeUploadComposeRequest() { | ||||
|   return { | ||||
|     type: COMPOSE_UPLOAD_CHANGE_REQUEST, | ||||
|     skipLoading: true, | ||||
|   }; | ||||
| }; | ||||
| export function changeUploadComposeSuccess(media) { | ||||
|   return { | ||||
|     type: COMPOSE_UPLOAD_CHANGE_SUCCESS, | ||||
|     media: media, | ||||
|     skipLoading: true, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function changeUploadComposeFail(error) { | ||||
|   return { | ||||
|     type: COMPOSE_UPLOAD_CHANGE_FAIL, | ||||
|     error: error, | ||||
|     skipLoading: true, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function uploadComposeRequest() { | ||||
|   return { | ||||
|     type: COMPOSE_UPLOAD_REQUEST, | ||||
| @@ -208,21 +256,42 @@ export function clearComposeSuggestions() { | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { | ||||
|   api(getState).get('/api/v1/accounts/search', { | ||||
|     params: { | ||||
|       q: token.slice(1), | ||||
|       resolve: false, | ||||
|       limit: 4, | ||||
|     }, | ||||
|   }).then(response => { | ||||
|     dispatch(readyComposeSuggestionsAccounts(token, response.data)); | ||||
|   }); | ||||
| }, 200, { leading: true, trailing: true }); | ||||
|  | ||||
| const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { | ||||
|   const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); | ||||
|   dispatch(readyComposeSuggestionsEmojis(token, results)); | ||||
| }; | ||||
|  | ||||
| export function fetchComposeSuggestions(token) { | ||||
|   return (dispatch, getState) => { | ||||
|     api(getState).get('/api/v1/accounts/search', { | ||||
|       params: { | ||||
|         q: token, | ||||
|         resolve: false, | ||||
|         limit: 4, | ||||
|       }, | ||||
|     }).then(response => { | ||||
|       dispatch(readyComposeSuggestions(token, response.data)); | ||||
|     }); | ||||
|     if (token[0] === ':') { | ||||
|       fetchComposeSuggestionsEmojis(dispatch, getState, token); | ||||
|     } else { | ||||
|       fetchComposeSuggestionsAccounts(dispatch, getState, token); | ||||
|     } | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function readyComposeSuggestions(token, accounts) { | ||||
| export function readyComposeSuggestionsEmojis(token, emojis) { | ||||
|   return { | ||||
|     type: COMPOSE_SUGGESTIONS_READY, | ||||
|     token, | ||||
|     emojis, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function readyComposeSuggestionsAccounts(token, accounts) { | ||||
|   return { | ||||
|     type: COMPOSE_SUGGESTIONS_READY, | ||||
|     token, | ||||
| @@ -230,13 +299,23 @@ export function readyComposeSuggestions(token, accounts) { | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function selectComposeSuggestion(position, token, accountId) { | ||||
| export function selectComposeSuggestion(position, token, suggestion) { | ||||
|   return (dispatch, getState) => { | ||||
|     const completion = getState().getIn(['accounts', accountId, 'acct']); | ||||
|     let completion, startPosition; | ||||
|  | ||||
|     if (typeof suggestion === 'object' && suggestion.id) { | ||||
|       completion    = suggestion.native || suggestion.colons; | ||||
|       startPosition = position - 1; | ||||
|  | ||||
|       dispatch(useEmoji(suggestion)); | ||||
|     } else { | ||||
|       completion    = getState().getIn(['accounts', suggestion, 'acct']); | ||||
|       startPosition = position; | ||||
|     } | ||||
|  | ||||
|     dispatch({ | ||||
|       type: COMPOSE_SUGGESTION_SELECT, | ||||
|       position, | ||||
|       position: startPosition, | ||||
|       token, | ||||
|       completion, | ||||
|     }); | ||||
|   | ||||
							
								
								
									
										14
									
								
								app/javascript/mastodon/actions/emojis.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/javascript/mastodon/actions/emojis.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import { saveSettings } from './settings'; | ||||
|  | ||||
| export const EMOJI_USE = 'EMOJI_USE'; | ||||
|  | ||||
| export function useEmoji(emoji) { | ||||
|   return dispatch => { | ||||
|     dispatch({ | ||||
|       type: EMOJI_USE, | ||||
|       emoji, | ||||
|     }); | ||||
|  | ||||
|     dispatch(saveSettings()); | ||||
|   }; | ||||
| }; | ||||
| @@ -1,6 +1,8 @@ | ||||
| import axios from 'axios'; | ||||
| import { debounce } from 'lodash'; | ||||
|  | ||||
| export const SETTING_CHANGE = 'SETTING_CHANGE'; | ||||
| export const SETTING_SAVE   = 'SETTING_SAVE'; | ||||
|  | ||||
| export function changeSetting(key, value) { | ||||
|   return dispatch => { | ||||
| @@ -14,10 +16,16 @@ export function changeSetting(key, value) { | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const debouncedSave = debounce((dispatch, getState) => { | ||||
|   if (getState().getIn(['settings', 'saved'])) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS(); | ||||
|  | ||||
|   axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE })); | ||||
| }, 5000, { trailing: true }); | ||||
|  | ||||
| export function saveSettings() { | ||||
|   return (_, getState) => { | ||||
|     axios.put('/api/web/settings', { | ||||
|       data: getState().get('settings').toJS(), | ||||
|     }); | ||||
|   }; | ||||
|   return (dispatch, getState) => debouncedSave(dispatch, getState); | ||||
| }; | ||||
|   | ||||
| @@ -5,8 +5,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; | ||||
|  | ||||
| const convertState = rawState => | ||||
|   fromJS(rawState, (k, v) => | ||||
|     Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x => | ||||
|       Number.isNaN(x * 1) ? x : x * 1)); | ||||
|     Iterable.isIndexed(v) ? v.toList() : v.toMap()); | ||||
|  | ||||
| export function hydrateStore(rawState) { | ||||
|   const state = convertState(rawState); | ||||
|   | ||||
| @@ -17,6 +17,8 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; | ||||
| export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT'; | ||||
| export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; | ||||
|  | ||||
| export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; | ||||
|  | ||||
| export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { | ||||
|   return { | ||||
|     type: TIMELINE_REFRESH_SUCCESS, | ||||
| @@ -30,6 +32,16 @@ export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { | ||||
| export function updateTimeline(timeline, status) { | ||||
|   return (dispatch, getState) => { | ||||
|     const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; | ||||
|     const parents = []; | ||||
|  | ||||
|     if (status.in_reply_to_id) { | ||||
|       let parent = getState().getIn(['statuses', status.in_reply_to_id]); | ||||
|  | ||||
|       while (parent && parent.get('in_reply_to_id')) { | ||||
|         parents.push(parent.get('id')); | ||||
|         parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     dispatch({ | ||||
|       type: TIMELINE_UPDATE, | ||||
| @@ -37,6 +49,14 @@ export function updateTimeline(timeline, status) { | ||||
|       status, | ||||
|       references, | ||||
|     }); | ||||
|  | ||||
|     if (parents.length > 0) { | ||||
|       dispatch({ | ||||
|         type: TIMELINE_CONTEXT_UPDATE, | ||||
|         status, | ||||
|         references: parents, | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -21,7 +21,7 @@ export default class Account extends ImmutablePureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     account: ImmutablePropTypes.map.isRequired, | ||||
|     me: PropTypes.number.isRequired, | ||||
|     me: PropTypes.string.isRequired, | ||||
|     onFollow: PropTypes.func.isRequired, | ||||
|     onBlock: PropTypes.func.isRequired, | ||||
|     onMute: PropTypes.func.isRequired, | ||||
|   | ||||
							
								
								
									
										42
									
								
								app/javascript/mastodon/components/autosuggest_emoji.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								app/javascript/mastodon/components/autosuggest_emoji.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import unicodeMapping from '../features/emoji/emoji_unicode_mapping_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 mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; | ||||
|  | ||||
|       if (!mapping) { | ||||
|         return null; | ||||
|       } | ||||
|  | ||||
|       url = `${assetHost}/emoji/${mapping.filename}.svg`; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       <div className='autosuggest-emoji'> | ||||
|         <img | ||||
|           className='emojione' | ||||
|           src={url} | ||||
|           alt={emoji.native || emoji.colons} | ||||
|         /> | ||||
|  | ||||
|         {emoji.colons} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -1,10 +1,12 @@ | ||||
| import React from 'react'; | ||||
| import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; | ||||
| import AutosuggestEmoji from './autosuggest_emoji'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { isRtl } from '../rtl'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import Textarea from 'react-textarea-autosize'; | ||||
| import classNames from 'classnames'; | ||||
|  | ||||
| const textAtCursorMatchesToken = (str, caretPosition) => { | ||||
|   let word; | ||||
| @@ -18,11 +20,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => { | ||||
|     word = str.slice(left, right + caretPosition); | ||||
|   } | ||||
|  | ||||
|   if (!word || word.trim().length < 2 || word[0] !== '@') { | ||||
|   if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) { | ||||
|     return [null, null]; | ||||
|   } | ||||
|  | ||||
|   word = word.trim().toLowerCase().slice(1); | ||||
|   word = word.trim().toLowerCase(); | ||||
|  | ||||
|   if (word.length > 0) { | ||||
|     return [left + 1, word]; | ||||
| @@ -123,12 +125,22 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | ||||
|     this.props.onKeyDown(e); | ||||
|   } | ||||
|  | ||||
|   onKeyUp = e => { | ||||
|     if (e.key === 'Escape' && this.state.suggestionsHidden) { | ||||
|       document.querySelector('.ui').parentElement.focus(); | ||||
|     } | ||||
|  | ||||
|     if (this.props.onKeyUp) { | ||||
|       this.props.onKeyUp(e); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onBlur = () => { | ||||
|     this.setState({ suggestionsHidden: true }); | ||||
|   } | ||||
|  | ||||
|   onSuggestionClick = (e) => { | ||||
|     const suggestion = Number(e.currentTarget.getAttribute('data-index')); | ||||
|     const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); | ||||
|     e.preventDefault(); | ||||
|     this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); | ||||
|     this.textarea.focus(); | ||||
| @@ -151,9 +163,28 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   renderSuggestion = (suggestion, i) => { | ||||
|     const { selectedSuggestion } = this.state; | ||||
|     let inner, key; | ||||
|  | ||||
|     if (typeof suggestion === 'object') { | ||||
|       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 () { | ||||
|     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; | ||||
|     const { suggestionsHidden, selectedSuggestion } = this.state; | ||||
|     const { value, suggestions, disabled, placeholder, autoFocus } = this.props; | ||||
|     const { suggestionsHidden } = this.state; | ||||
|     const style = { direction: 'ltr' }; | ||||
|  | ||||
|     if (isRtl(value)) { | ||||
| @@ -164,6 +195,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | ||||
|       <div className='autosuggest-textarea'> | ||||
|         <label> | ||||
|           <span style={{ display: 'none' }}>{placeholder}</span> | ||||
|  | ||||
|           <Textarea | ||||
|             inputRef={this.setTextarea} | ||||
|             className='autosuggest-textarea__textarea' | ||||
| @@ -173,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | ||||
|             value={value} | ||||
|             onChange={this.onChange} | ||||
|             onKeyDown={this.onKeyDown} | ||||
|             onKeyUp={onKeyUp} | ||||
|             onKeyUp={this.onKeyUp} | ||||
|             onBlur={this.onBlur} | ||||
|             onPaste={this.onPaste} | ||||
|             style={style} | ||||
| @@ -181,18 +213,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | ||||
|         </label> | ||||
|  | ||||
|         <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> | ||||
|           {suggestions.map((suggestion, i) => ( | ||||
|             <div | ||||
|               role='button' | ||||
|               tabIndex='0' | ||||
|               key={suggestion} | ||||
|               data-index={suggestion} | ||||
|               className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} | ||||
|               onMouseDown={this.onSuggestionClick} | ||||
|             > | ||||
|               <AutosuggestAccountContainer id={suggestion} /> | ||||
|             </div> | ||||
|           ))} | ||||
|           {suggestions.map(this.renderSuggestion)} | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   | ||||
| @@ -135,7 +135,7 @@ export default class ColumnHeader extends React.PureComponent { | ||||
|  | ||||
|     return ( | ||||
|       <div className={wrapperClassName}> | ||||
|         <h1 tabIndex={focusable && '0'} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> | ||||
|         <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> | ||||
|           <i className={`fa fa-fw fa-${icon} column-header__icon`} /> | ||||
|           {title} | ||||
|  | ||||
| @@ -145,7 +145,7 @@ export default class ColumnHeader extends React.PureComponent { | ||||
|           </div> | ||||
|         </h1> | ||||
|  | ||||
|         <div className={collapsibleClassName} tabIndex={collapsed && -1} onTransitionEnd={this.handleTransitionEnd}> | ||||
|         <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> | ||||
|           <div className='column-header__collapsible-inner'> | ||||
|             {(!collapsed || animating) && collapsedContent} | ||||
|           </div> | ||||
|   | ||||
| @@ -1,53 +1,59 @@ | ||||
| import React from 'react'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import IconButton from './icon_button'; | ||||
| import Overlay from 'react-overlays/lib/Overlay'; | ||||
| import Motion from 'react-motion/lib/Motion'; | ||||
| import spring from 'react-motion/lib/spring'; | ||||
| import detectPassiveEvents from 'detect-passive-events'; | ||||
|  | ||||
| export default class DropdownMenu extends React.PureComponent { | ||||
| const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; | ||||
|  | ||||
| class DropdownMenu extends React.PureComponent { | ||||
|  | ||||
|   static contextTypes = { | ||||
|     router: PropTypes.object, | ||||
|   }; | ||||
|  | ||||
|   static propTypes = { | ||||
|     isUserTouching: PropTypes.func, | ||||
|     isModalOpen: PropTypes.bool.isRequired, | ||||
|     onModalOpen: PropTypes.func, | ||||
|     onModalClose: PropTypes.func, | ||||
|     icon: PropTypes.string.isRequired, | ||||
|     items: PropTypes.array.isRequired, | ||||
|     size: PropTypes.number.isRequired, | ||||
|     direction: PropTypes.string, | ||||
|     status: ImmutablePropTypes.map, | ||||
|     ariaLabel: PropTypes.string, | ||||
|     disabled: PropTypes.bool, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     style: PropTypes.object, | ||||
|     placement: PropTypes.string, | ||||
|     arrowOffsetLeft: PropTypes.string, | ||||
|     arrowOffsetTop: PropTypes.string, | ||||
|   }; | ||||
|  | ||||
|   static defaultProps = { | ||||
|     ariaLabel: 'Menu', | ||||
|     isModalOpen: false, | ||||
|     isUserTouching: () => false, | ||||
|     style: {}, | ||||
|     placement: 'bottom', | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     direction: 'left', | ||||
|     expanded: false, | ||||
|   }; | ||||
|  | ||||
|   setRef = (c) => { | ||||
|     this.dropdown = c; | ||||
|   handleDocumentClick = e => { | ||||
|     if (this.node && !this.node.contains(e.target)) { | ||||
|       this.props.onClose(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   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 { action, to } = this.props.items[i]; | ||||
|  | ||||
|     if (this.props.isModalOpen) { | ||||
|       this.props.onModalClose(); | ||||
|     } | ||||
|  | ||||
|     // Don't call e.preventDefault() when the item uses 'href' property. | ||||
|     // ex. "Edit profile" on the account action bar | ||||
|     this.props.onClose(); | ||||
|  | ||||
|     if (typeof action === 'function') { | ||||
|       e.preventDefault(); | ||||
| @@ -56,46 +62,18 @@ export default class DropdownMenu extends React.PureComponent { | ||||
|       e.preventDefault(); | ||||
|       this.context.router.history.push(to); | ||||
|     } | ||||
|  | ||||
|     this.dropdown.hide(); | ||||
|   } | ||||
|  | ||||
|   handleShow = () => { | ||||
|     if (this.props.isUserTouching()) { | ||||
|       this.props.onModalOpen({ | ||||
|         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' />; | ||||
|   renderItem (option, i) { | ||||
|     if (option === null) { | ||||
|       return <li key={`sep-${i}`} className='dropdown-menu__separator' />; | ||||
|     } | ||||
|  | ||||
|     const { text, href = '#' } = item; | ||||
|     const { text, href = '#' } = option; | ||||
|  | ||||
|     return ( | ||||
|       <li className='dropdown__content-list-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'> | ||||
|       <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}> | ||||
|           {text} | ||||
|         </a> | ||||
|       </li> | ||||
| @@ -103,43 +81,130 @@ export default class DropdownMenu extends React.PureComponent { | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { icon, items, size, direction, ariaLabel, disabled } = 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 />; | ||||
|     const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}> | ||||
|         <DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-expanded={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}> | ||||
|           <i className={iconClassname} aria-hidden /> | ||||
|         </DropdownTrigger> | ||||
|       <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> | ||||
|         {({ opacity, scaleX, scaleY }) => ( | ||||
|           <div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> | ||||
|             <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> | ||||
|  | ||||
|         {dropdownContent} | ||||
|       </Dropdown> | ||||
|             <ul> | ||||
|               {items.map((option, i) => this.renderItem(option, i))} | ||||
|             </ul> | ||||
|           </div> | ||||
|         )} | ||||
|       </Motion> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default class Dropdown extends React.PureComponent { | ||||
|  | ||||
|   static contextTypes = { | ||||
|     router: PropTypes.object, | ||||
|   }; | ||||
|  | ||||
|   static propTypes = { | ||||
|     icon: PropTypes.string.isRequired, | ||||
|     items: PropTypes.array.isRequired, | ||||
|     size: PropTypes.number.isRequired, | ||||
|     ariaLabel: PropTypes.string, | ||||
|     disabled: PropTypes.bool, | ||||
|     status: ImmutablePropTypes.map, | ||||
|     isUserTouching: PropTypes.func, | ||||
|     isModalOpen: PropTypes.bool.isRequired, | ||||
|     onModalOpen: PropTypes.func, | ||||
|     onModalClose: PropTypes.func, | ||||
|   }; | ||||
|  | ||||
|   static defaultProps = { | ||||
|     ariaLabel: 'Menu', | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     expanded: false, | ||||
|   }; | ||||
|  | ||||
|   handleClick = () => { | ||||
|     if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) { | ||||
|       const { status, items } = this.props; | ||||
|  | ||||
|       this.props.onModalOpen({ | ||||
|         status, | ||||
|         actions: items, | ||||
|         onClick: this.handleItemClick, | ||||
|       }); | ||||
|  | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this.setState({ expanded: !this.state.expanded }); | ||||
|   } | ||||
|  | ||||
|   handleClose = () => { | ||||
|     if (this.props.onModalClose) { | ||||
|       this.props.onModalClose(); | ||||
|     } | ||||
|  | ||||
|     this.setState({ expanded: false }); | ||||
|   } | ||||
|  | ||||
|   handleKeyDown = e => { | ||||
|     switch(e.key) { | ||||
|     case 'Enter': | ||||
|       this.handleClick(); | ||||
|       break; | ||||
|     case 'Escape': | ||||
|       this.handleClose(); | ||||
|       break; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleItemClick = e => { | ||||
|     const i = Number(e.currentTarget.getAttribute('data-index')); | ||||
|     const { action, to } = this.props.items[i]; | ||||
|  | ||||
|     this.handleClose(); | ||||
|  | ||||
|     if (typeof action === 'function') { | ||||
|       e.preventDefault(); | ||||
|       action(); | ||||
|     } else if (to) { | ||||
|       e.preventDefault(); | ||||
|       this.context.router.history.push(to); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   setTargetRef = c => { | ||||
|     this.target = c; | ||||
|   } | ||||
|  | ||||
|   findTarget = () => { | ||||
|     return this.target; | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { icon, items, size, ariaLabel, disabled } = this.props; | ||||
|     const { expanded } = this.state; | ||||
|  | ||||
|     return ( | ||||
|       <div onKeyDown={this.handleKeyDown}> | ||||
|         <IconButton | ||||
|           icon={icon} | ||||
|           title={ariaLabel} | ||||
|           active={expanded} | ||||
|           disabled={disabled} | ||||
|           size={size} | ||||
|           ref={this.setTargetRef} | ||||
|           onClick={this.handleClick} | ||||
|         /> | ||||
|  | ||||
|         <Overlay show={expanded} placement='bottom' target={this.findTarget}> | ||||
|           <DropdownMenu items={items} onClose={this.handleClose} /> | ||||
|         </Overlay> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     src: PropTypes.string.isRequired, | ||||
|     alt: PropTypes.string, | ||||
|     width: PropTypes.number, | ||||
|     height: PropTypes.number, | ||||
|     time: PropTypes.number, | ||||
| @@ -31,15 +32,20 @@ export default class ExtendedVideoPlayer extends React.PureComponent { | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { src, muted, controls, alt } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <div className='extended-video-player'> | ||||
|         <video | ||||
|           ref={this.setRef} | ||||
|           src={this.props.src} | ||||
|           src={src} | ||||
|           autoPlay | ||||
|           muted={this.props.muted} | ||||
|           controls={this.props.controls} | ||||
|           loop={!this.props.controls} | ||||
|           role='button' | ||||
|           tabIndex='0' | ||||
|           aria-label={alt} | ||||
|           muted={muted} | ||||
|           controls={controls} | ||||
|           loop={!controls} | ||||
|         /> | ||||
|       </div> | ||||
|     ); | ||||
|   | ||||
| @@ -1,10 +1,15 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; | ||||
| 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 = { | ||||
|     intersectionObserverWrapper: PropTypes.object.isRequired, | ||||
| @@ -22,18 +27,15 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent | ||||
|   } | ||||
|  | ||||
|   shouldComponentUpdate (nextProps, nextState) { | ||||
|     if (!nextState.isIntersecting && nextState.isHidden) { | ||||
|       // It's only if we're not intersecting (i.e. offscreen) and isHidden is true | ||||
|       // that either "isIntersecting" or "isHidden" matter, and then they're | ||||
|       // the only things that matter (and updated ARIA attributes). | ||||
|       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 | ||||
|     const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight); | ||||
|     const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight); | ||||
|     if (!!isUnrendered !== !!willBeUnrendered) { | ||||
|       // If we're going from rendered to unrendered (or vice versa) then update | ||||
|       return true; | ||||
|     } | ||||
|     // Otherwise, diff based on "updateOnProps" and "updateOnStates" | ||||
|     return super.shouldComponentUpdate(nextProps, nextState); | ||||
|     // Otherwise, diff based on props | ||||
|     const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered; | ||||
|     return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop])); | ||||
|   } | ||||
|  | ||||
|   componentDidMount () { | ||||
| @@ -56,26 +58,31 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent | ||||
|   } | ||||
|  | ||||
|   handleIntersection = (entry) => { | ||||
|     const { onHeightChange, saveHeightKey, id } = this.props; | ||||
|     this.entry = entry; | ||||
|  | ||||
|     if (this.node && this.node.children.length !== 0) { | ||||
|       // save the height of the fully-rendered element | ||||
|       this.height = getRectFromEntry(entry).height; | ||||
|     scheduleIdleTask(this.calculateHeight); | ||||
|     this.setState(this.updateStateAfterIntersection); | ||||
|   } | ||||
|  | ||||
|       if (onHeightChange && saveHeightKey) { | ||||
|         onHeightChange(saveHeightKey, id, this.height); | ||||
|       } | ||||
|   updateStateAfterIntersection = (prevState) => { | ||||
|     if (prevState.isIntersecting && !this.entry.isIntersecting) { | ||||
|       scheduleIdleTask(this.hideIfNotIntersecting); | ||||
|     } | ||||
|     return { | ||||
|       isIntersecting: this.entry.isIntersecting, | ||||
|       isHidden: false, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|     this.setState((prevState) => { | ||||
|       if (prevState.isIntersecting && !entry.isIntersecting) { | ||||
|         scheduleIdleTask(this.hideIfNotIntersecting); | ||||
|       } | ||||
|       return { | ||||
|         isIntersecting: entry.isIntersecting, | ||||
|         isHidden: false, | ||||
|       }; | ||||
|     }); | ||||
|   calculateHeight = () => { | ||||
|     const { onHeightChange, saveHeightKey, id } = this.props; | ||||
|     // save the height of the fully-rendered element (this is expensive | ||||
|     // on Chrome, where we need to fall back to getBoundingClientRect) | ||||
|     this.height = getRectFromEntry(this.entry).height; | ||||
|  | ||||
|     if (onHeightChange && saveHeightKey) { | ||||
|       onHeightChange(saveHeightKey, id, this.height); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   hideIfNotIntersecting = () => { | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import React from 'react'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { is } from 'immutable'; | ||||
| import IconButton from './icon_button'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import { isIOS } from '../is_mobile'; | ||||
| import classNames from 'classnames'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, | ||||
| @@ -17,6 +19,7 @@ class Item extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     attachment: ImmutablePropTypes.map.isRequired, | ||||
|     standalone: PropTypes.bool, | ||||
|     index: PropTypes.number.isRequired, | ||||
|     size: PropTypes.number.isRequired, | ||||
|     onClick: PropTypes.func.isRequired, | ||||
| @@ -25,6 +28,9 @@ class Item extends React.PureComponent { | ||||
|  | ||||
|   static defaultProps = { | ||||
|     autoPlayGif: false, | ||||
|     standalone: false, | ||||
|     index: 0, | ||||
|     size: 1, | ||||
|   }; | ||||
|  | ||||
|   handleMouseEnter = (e) => { | ||||
| @@ -57,7 +63,7 @@ class Item extends React.PureComponent { | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { attachment, index, size } = this.props; | ||||
|     const { attachment, index, size, standalone } = this.props; | ||||
|  | ||||
|     let width  = 50; | ||||
|     let height = 100; | ||||
| @@ -129,16 +135,17 @@ class Item extends React.PureComponent { | ||||
|           onClick={this.handleClick} | ||||
|           target='_blank' | ||||
|         > | ||||
|           <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' /> | ||||
|           <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} /> | ||||
|         </a> | ||||
|       ); | ||||
|     } else if (attachment.get('type') === 'gifv') { | ||||
|       const autoPlay = !isIOS() && this.props.autoPlayGif; | ||||
|  | ||||
|       thumbnail = ( | ||||
|         <div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}> | ||||
|         <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> | ||||
|           <video | ||||
|             className='media-gallery__item-gifv-thumbnail' | ||||
|             aria-label={attachment.get('description')} | ||||
|             role='application' | ||||
|             src={attachment.get('url')} | ||||
|             onClick={this.handleClick} | ||||
| @@ -155,7 +162,7 @@ class Item extends React.PureComponent { | ||||
|     } | ||||
|  | ||||
|     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} | ||||
|       </div> | ||||
|     ); | ||||
| @@ -168,7 +175,9 @@ export default class MediaGallery extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     sensitive: PropTypes.bool, | ||||
|     standalone: PropTypes.bool, | ||||
|     media: ImmutablePropTypes.list.isRequired, | ||||
|     size: PropTypes.object, | ||||
|     height: PropTypes.number.isRequired, | ||||
|     onOpenMedia: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
| @@ -177,6 +186,7 @@ export default class MediaGallery extends React.PureComponent { | ||||
|  | ||||
|   static defaultProps = { | ||||
|     autoPlayGif: false, | ||||
|     standalone: false, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
| @@ -184,7 +194,7 @@ export default class MediaGallery extends React.PureComponent { | ||||
|   }; | ||||
|  | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|     if (nextProps.sensitive !== this.props.sensitive) { | ||||
|     if (!is(nextProps.media, this.props.media)) { | ||||
|       this.setState({ visible: !nextProps.sensitive }); | ||||
|     } | ||||
|   } | ||||
| @@ -197,12 +207,42 @@ export default class MediaGallery extends React.PureComponent { | ||||
|     this.props.onOpenMedia(this.props.media, index); | ||||
|   } | ||||
|  | ||||
|   handleRef = (node) => { | ||||
|     if (node && this.isStandaloneEligible()) { | ||||
|       // offsetWidth triggers a layout, so only calculate when we need to | ||||
|       this.setState({ | ||||
|         width: node.offsetWidth, | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   isStandaloneEligible() { | ||||
|     const { media, standalone } = this.props; | ||||
|     return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { media, intl, sensitive } = this.props; | ||||
|     const { media, intl, sensitive, height } = this.props; | ||||
|     const { width, visible } = this.state; | ||||
|  | ||||
|     let children; | ||||
|  | ||||
|     if (!this.state.visible) { | ||||
|     const style = {}; | ||||
|  | ||||
|     if (this.isStandaloneEligible()) { | ||||
|       if (!visible && width) { | ||||
|         // only need to forcibly set the height in "sensitive" mode | ||||
|         style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); | ||||
|       } else { | ||||
|         // layout automatically, using image's natural aspect ratio | ||||
|         style.height = ''; | ||||
|       } | ||||
|     } else { | ||||
|       // crop the image | ||||
|       style.height = height; | ||||
|     } | ||||
|  | ||||
|     if (!visible) { | ||||
|       let warning; | ||||
|  | ||||
|       if (sensitive) { | ||||
| @@ -212,20 +252,25 @@ export default class MediaGallery extends React.PureComponent { | ||||
|       } | ||||
|  | ||||
|       children = ( | ||||
|         <button className='media-spoiler' onClick={this.handleOpen}> | ||||
|         <button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}> | ||||
|           <span className='media-spoiler__warning'>{warning}</span> | ||||
|           <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> | ||||
|         </button> | ||||
|       ); | ||||
|     } else { | ||||
|       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 (this.isStandaloneEligible()) { | ||||
|         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 ( | ||||
|       <div className='media-gallery' style={{ height: `${this.props.height}px` }}> | ||||
|         <div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}> | ||||
|           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> | ||||
|       <div className='media-gallery' style={style}> | ||||
|         <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}> | ||||
|           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> | ||||
|         </div> | ||||
|  | ||||
|         {children} | ||||
|   | ||||
| @@ -1,7 +1,15 @@ | ||||
| import React from 'react'; | ||||
| import { injectIntl, FormattedRelative } from 'react-intl'; | ||||
| import { injectIntl, defineMessages } from 'react-intl'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, | ||||
|   seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, | ||||
|   minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, | ||||
|   hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, | ||||
|   days: { id: 'relative_time.days', defaultMessage: '{number}d' }, | ||||
| }); | ||||
|  | ||||
| const dateFormatOptions = { | ||||
|   hour12: false, | ||||
|   year: 'numeric', | ||||
| @@ -11,6 +19,47 @@ const dateFormatOptions = { | ||||
|   minute: '2-digit', | ||||
| }; | ||||
|  | ||||
| const shortDateFormatOptions = { | ||||
|   month: 'numeric', | ||||
|   day: 'numeric', | ||||
| }; | ||||
|  | ||||
| const SECOND = 1000; | ||||
| const MINUTE = 1000 * 60; | ||||
| const HOUR   = 1000 * 60 * 60; | ||||
| const DAY    = 1000 * 60 * 60 * 24; | ||||
|  | ||||
| const MAX_DELAY = 2147483647; | ||||
|  | ||||
| const selectUnits = delta => { | ||||
|   const absDelta = Math.abs(delta); | ||||
|  | ||||
|   if (absDelta < MINUTE) { | ||||
|     return 'second'; | ||||
|   } else if (absDelta < HOUR) { | ||||
|     return 'minute'; | ||||
|   } else if (absDelta < DAY) { | ||||
|     return 'hour'; | ||||
|   } | ||||
|  | ||||
|   return 'day'; | ||||
| }; | ||||
|  | ||||
| const getUnitDelay = units => { | ||||
|   switch (units) { | ||||
|   case 'second': | ||||
|     return SECOND; | ||||
|   case 'minute': | ||||
|     return MINUTE; | ||||
|   case 'hour': | ||||
|     return HOUR; | ||||
|   case 'day': | ||||
|     return DAY; | ||||
|   default: | ||||
|     return MAX_DELAY; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @injectIntl | ||||
| export default class RelativeTimestamp extends React.Component { | ||||
|  | ||||
| @@ -19,20 +68,74 @@ export default class RelativeTimestamp extends React.Component { | ||||
|     timestamp: PropTypes.string.isRequired, | ||||
|   }; | ||||
|  | ||||
|   shouldComponentUpdate (nextProps) { | ||||
|   state = { | ||||
|     now: this.props.intl.now(), | ||||
|   }; | ||||
|  | ||||
|   shouldComponentUpdate (nextProps, nextState) { | ||||
|     // As of right now the locale doesn't change without a new page load, | ||||
|     // but we might as well check in case that ever changes. | ||||
|     return this.props.timestamp !== nextProps.timestamp || | ||||
|       this.props.intl.locale !== nextProps.intl.locale; | ||||
|       this.props.intl.locale !== nextProps.intl.locale || | ||||
|       this.state.now !== nextState.now; | ||||
|   } | ||||
|  | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|     if (this.props.timestamp !== nextProps.timestamp) { | ||||
|       this.setState({ now: this.props.intl.now() }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   componentDidMount () { | ||||
|     this._scheduleNextUpdate(this.props, this.state); | ||||
|   } | ||||
|  | ||||
|   componentWillUpdate (nextProps, nextState) { | ||||
|     this._scheduleNextUpdate(nextProps, nextState); | ||||
|   } | ||||
|  | ||||
|   _scheduleNextUpdate (props, state) { | ||||
|     clearTimeout(this._timer); | ||||
|  | ||||
|     const { timestamp }  = props; | ||||
|     const delta          = (new Date(timestamp)).getTime() - state.now; | ||||
|     const unitDelay      = getUnitDelay(selectUnits(delta)); | ||||
|     const unitRemainder  = Math.abs(delta % unitDelay); | ||||
|     const updateInterval = 1000 * 10; | ||||
|     const delay          = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder); | ||||
|  | ||||
|     this._timer = setTimeout(() => { | ||||
|       this.setState({ now: this.props.intl.now() }); | ||||
|     }, delay); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { timestamp, intl } = this.props; | ||||
|     const date = new Date(timestamp); | ||||
|  | ||||
|     const date  = new Date(timestamp); | ||||
|     const delta = this.state.now - date.getTime(); | ||||
|  | ||||
|     let relativeTime; | ||||
|  | ||||
|     if (delta < 10 * SECOND) { | ||||
|       relativeTime = intl.formatMessage(messages.just_now); | ||||
|     } else if (delta < 3 * DAY) { | ||||
|       if (delta < MINUTE) { | ||||
|         relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); | ||||
|       } else if (delta < HOUR) { | ||||
|         relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); | ||||
|       } else if (delta < DAY) { | ||||
|         relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); | ||||
|       } else { | ||||
|         relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); | ||||
|       } | ||||
|     } else { | ||||
|       relativeTime = intl.formatDate(date, shortDateFormatOptions); | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> | ||||
|         <FormattedRelative value={date} /> | ||||
|         {relativeTime} | ||||
|       </time> | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -6,6 +6,8 @@ import LoadMore from './load_more'; | ||||
| import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; | ||||
| import { throttle } from 'lodash'; | ||||
| import { List as ImmutableList } from 'immutable'; | ||||
| import classNames from 'classnames'; | ||||
| import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; | ||||
|  | ||||
| export default class ScrollableList extends PureComponent { | ||||
|  | ||||
| @@ -66,6 +68,7 @@ export default class ScrollableList extends PureComponent { | ||||
|   componentDidMount () { | ||||
|     this.attachScrollListener(); | ||||
|     this.attachIntersectionObserver(); | ||||
|     attachFullscreenListener(this.onFullScreenChange); | ||||
|  | ||||
|     // Handle initial scroll posiiton | ||||
|     this.handleScroll(); | ||||
| @@ -92,6 +95,11 @@ export default class ScrollableList extends PureComponent { | ||||
|   componentWillUnmount () { | ||||
|     this.detachScrollListener(); | ||||
|     this.detachIntersectionObserver(); | ||||
|     detachFullscreenListener(this.onFullScreenChange); | ||||
|   } | ||||
|  | ||||
|   onFullScreenChange = () => { | ||||
|     this.setState({ fullscreen: isFullscreen() }); | ||||
|   } | ||||
|  | ||||
|   attachIntersectionObserver () { | ||||
| @@ -137,34 +145,9 @@ export default class ScrollableList extends PureComponent { | ||||
|     return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); | ||||
|   } | ||||
|  | ||||
|   handleKeyDown = (e) => { | ||||
|     if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { | ||||
|       const article = (() => { | ||||
|         switch (e.key) { | ||||
|         case 'PageDown': | ||||
|           return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; | ||||
|         case 'PageUp': | ||||
|           return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; | ||||
|         case 'End': | ||||
|           return this.node.querySelector('[role="feed"] > article:last-of-type'); | ||||
|         case 'Home': | ||||
|           return this.node.querySelector('[role="feed"] > article:first-of-type'); | ||||
|         default: | ||||
|           return null; | ||||
|         } | ||||
|       })(); | ||||
|  | ||||
|  | ||||
|       if (article) { | ||||
|         e.preventDefault(); | ||||
|         article.focus(); | ||||
|         article.scrollIntoView(); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; | ||||
|     const { fullscreen } = this.state; | ||||
|     const childrenCount = React.Children.count(children); | ||||
|  | ||||
|     const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; | ||||
| @@ -172,8 +155,8 @@ export default class ScrollableList extends PureComponent { | ||||
|  | ||||
|     if (isLoading || childrenCount > 0 || !emptyMessage) { | ||||
|       scrollableArea = ( | ||||
|         <div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> | ||||
|           <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}> | ||||
|         <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> | ||||
|           <div role='feed' className='item-list'> | ||||
|             {prepend} | ||||
|  | ||||
|             {React.Children.map(this.props.children, (child, index) => ( | ||||
|   | ||||
| @@ -10,6 +10,8 @@ import StatusActionBar from './status_action_bar'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { MediaGallery, Video } from '../features/ui/util/async-components'; | ||||
| import { HotKeys } from 'react-hotkeys'; | ||||
| import classNames from 'classnames'; | ||||
|  | ||||
| // We use the component (and not the container) since we do not want | ||||
| // to use the progress bar to show download progress | ||||
| @@ -34,11 +36,13 @@ export default class Status extends ImmutablePureComponent { | ||||
|     onBlock: PropTypes.func, | ||||
|     onEmbed: PropTypes.func, | ||||
|     onHeightChange: PropTypes.func, | ||||
|     me: PropTypes.number, | ||||
|     me: PropTypes.string, | ||||
|     boostModal: PropTypes.bool, | ||||
|     autoPlayGif: PropTypes.bool, | ||||
|     muted: PropTypes.bool, | ||||
|     hidden: PropTypes.bool, | ||||
|     onMoveUp: PropTypes.func, | ||||
|     onMoveDown: PropTypes.func, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
| @@ -70,7 +74,7 @@ export default class Status extends ImmutablePureComponent { | ||||
|  | ||||
|   handleAccountClick = (e) => { | ||||
|     if (this.context.router && e.button === 0) { | ||||
|       const id = Number(e.currentTarget.getAttribute('data-id')); | ||||
|       const id = e.currentTarget.getAttribute('data-id'); | ||||
|       e.preventDefault(); | ||||
|       this.context.router.history.push(`/accounts/${id}`); | ||||
|     } | ||||
| @@ -89,16 +93,62 @@ export default class Status extends ImmutablePureComponent { | ||||
|   } | ||||
|  | ||||
|   handleOpenVideo = startTime => { | ||||
|     this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); | ||||
|     this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime); | ||||
|   } | ||||
|  | ||||
|   handleHotkeyReply = e => { | ||||
|     e.preventDefault(); | ||||
|     this.props.onReply(this._properStatus(), this.context.router.history); | ||||
|   } | ||||
|  | ||||
|   handleHotkeyFavourite = () => { | ||||
|     this.props.onFavourite(this._properStatus()); | ||||
|   } | ||||
|  | ||||
|   handleHotkeyBoost = e => { | ||||
|     this.props.onReblog(this._properStatus(), e); | ||||
|   } | ||||
|  | ||||
|   handleHotkeyMention = e => { | ||||
|     e.preventDefault(); | ||||
|     this.props.onMention(this._properStatus().get('account'), this.context.router.history); | ||||
|   } | ||||
|  | ||||
|   handleHotkeyOpen = () => { | ||||
|     this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`); | ||||
|   } | ||||
|  | ||||
|   handleHotkeyOpenProfile = () => { | ||||
|     this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`); | ||||
|   } | ||||
|  | ||||
|   handleHotkeyMoveUp = () => { | ||||
|     this.props.onMoveUp(this.props.status.get('id')); | ||||
|   } | ||||
|  | ||||
|   handleHotkeyMoveDown = () => { | ||||
|     this.props.onMoveDown(this.props.status.get('id')); | ||||
|   } | ||||
|  | ||||
|   _properStatus () { | ||||
|     const { status } = this.props; | ||||
|  | ||||
|     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | ||||
|       return status.get('reblog'); | ||||
|     } else { | ||||
|       return status; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     let media = null; | ||||
|     let statusAvatar; | ||||
|     let statusAvatar, prepend; | ||||
|  | ||||
|     const { status, account, hidden, ...other } = this.props; | ||||
|     const { hidden }     = this.props; | ||||
|     const { isExpanded } = this.state; | ||||
|  | ||||
|     let { status, account, ...other } = this.props; | ||||
|  | ||||
|     if (status === null) { | ||||
|       return null; | ||||
|     } | ||||
| @@ -115,16 +165,15 @@ export default class Status extends ImmutablePureComponent { | ||||
|     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | ||||
|       const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; | ||||
|  | ||||
|       return ( | ||||
|         <div className='status__wrapper' data-id={status.get('id')} > | ||||
|           <div className='status__prepend'> | ||||
|             <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> | ||||
|             <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} /> | ||||
|           </div> | ||||
|  | ||||
|           <Status {...other} status={status.get('reblog')} account={status.get('account')} /> | ||||
|       prepend = ( | ||||
|         <div className='status__prepend'> | ||||
|           <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> | ||||
|           <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} /> | ||||
|         </div> | ||||
|       ); | ||||
|  | ||||
|       account = status.get('account'); | ||||
|       status  = status.get('reblog'); | ||||
|     } | ||||
|  | ||||
|     if (status.get('media_attachments').size > 0 && !this.props.muted) { | ||||
| @@ -160,26 +209,43 @@ export default class Status extends ImmutablePureComponent { | ||||
|       statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}> | ||||
|         <div className='status__info'> | ||||
|           <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> | ||||
|     const handlers = this.props.muted ? {} : { | ||||
|       reply: this.handleHotkeyReply, | ||||
|       favourite: this.handleHotkeyFavourite, | ||||
|       boost: this.handleHotkeyBoost, | ||||
|       mention: this.handleHotkeyMention, | ||||
|       open: this.handleHotkeyOpen, | ||||
|       openProfile: this.handleHotkeyOpenProfile, | ||||
|       moveUp: this.handleHotkeyMoveUp, | ||||
|       moveDown: this.handleHotkeyMoveDown, | ||||
|     }; | ||||
|  | ||||
|           <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'> | ||||
|             <div className='status__avatar'> | ||||
|               {statusAvatar} | ||||
|     return ( | ||||
|       <HotKeys handlers={handlers}> | ||||
|         <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}> | ||||
|           {prepend} | ||||
|  | ||||
|           <div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}> | ||||
|             <div className='status__info'> | ||||
|               <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> | ||||
|  | ||||
|               <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'> | ||||
|                 <div className='status__avatar'> | ||||
|                   {statusAvatar} | ||||
|                 </div> | ||||
|  | ||||
|                 <DisplayName account={status.get('account')} /> | ||||
|               </a> | ||||
|             </div> | ||||
|  | ||||
|             <DisplayName account={status.get('account')} /> | ||||
|           </a> | ||||
|             <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> | ||||
|  | ||||
|             {media} | ||||
|  | ||||
|             <StatusActionBar status={status} account={account} {...other} /> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> | ||||
|  | ||||
|         {media} | ||||
|  | ||||
|         <StatusActionBar {...this.props} /> | ||||
|       </div> | ||||
|       </HotKeys> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -46,7 +46,7 @@ export default class StatusActionBar extends ImmutablePureComponent { | ||||
|     onEmbed: PropTypes.func, | ||||
|     onMuteConversation: PropTypes.func, | ||||
|     onPin: PropTypes.func, | ||||
|     me: PropTypes.number, | ||||
|     me: PropTypes.string, | ||||
|     withDismiss: PropTypes.bool, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
|   | ||||
| @@ -144,7 +144,7 @@ export default class StatusContent extends React.PureComponent { | ||||
|       } | ||||
|  | ||||
|       return ( | ||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' aria-label={status.get('search_index')} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> | ||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> | ||||
|           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> | ||||
|             <span dangerouslySetInnerHTML={spoilerContent} /> | ||||
|             {' '} | ||||
| @@ -161,7 +161,6 @@ export default class StatusContent extends React.PureComponent { | ||||
|         <div | ||||
|           ref={this.setRef} | ||||
|           tabIndex='0' | ||||
|           aria-label={status.get('search_index')} | ||||
|           className={classNames} | ||||
|           style={directionStyle} | ||||
|           onMouseDown={this.handleMouseDown} | ||||
| @@ -173,7 +172,6 @@ export default class StatusContent extends React.PureComponent { | ||||
|       return ( | ||||
|         <div | ||||
|           tabIndex='0' | ||||
|           aria-label={status.get('search_index')} | ||||
|           ref={this.setRef} | ||||
|           className='status__content' | ||||
|           style={directionStyle} | ||||
|   | ||||
| @@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent { | ||||
|     trackScroll: true, | ||||
|   }; | ||||
|  | ||||
|   handleMoveUp = id => { | ||||
|     const elementIndex = this.props.statusIds.indexOf(id) - 1; | ||||
|     this._selectChild(elementIndex); | ||||
|   } | ||||
|  | ||||
|   handleMoveDown = id => { | ||||
|     const elementIndex = this.props.statusIds.indexOf(id) + 1; | ||||
|     this._selectChild(elementIndex); | ||||
|   } | ||||
|  | ||||
|   _selectChild (index) { | ||||
|     const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); | ||||
|  | ||||
|     if (element) { | ||||
|       element.focus(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   setRef = c => { | ||||
|     this.node = c; | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { statusIds, ...other } = this.props; | ||||
|     const { isLoading } = other; | ||||
|  | ||||
|     const scrollableContent = (isLoading || statusIds.size > 0) ? ( | ||||
|       statusIds.map((statusId) => ( | ||||
|         <StatusContainer key={statusId} id={statusId} /> | ||||
|         <StatusContainer | ||||
|           key={statusId} | ||||
|           id={statusId} | ||||
|           onMoveUp={this.handleMoveUp} | ||||
|           onMoveDown={this.handleMoveDown} | ||||
|         /> | ||||
|       )) | ||||
|     ) : null; | ||||
|  | ||||
|     return ( | ||||
|       <ScrollableList {...other}> | ||||
|       <ScrollableList {...other} ref={this.setRef}> | ||||
|         {scrollableContent} | ||||
|       </ScrollableList> | ||||
|     ); | ||||
|   | ||||
| @@ -1,204 +0,0 @@ | ||||
| import React from 'react'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import IconButton from './icon_button'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import { isIOS } from '../is_mobile'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, | ||||
|   toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }, | ||||
|   expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' }, | ||||
| }); | ||||
|  | ||||
| @injectIntl | ||||
| export default class VideoPlayer extends React.PureComponent { | ||||
|  | ||||
|   static contextTypes = { | ||||
|     router: PropTypes.object, | ||||
|   }; | ||||
|  | ||||
|   static propTypes = { | ||||
|     media: ImmutablePropTypes.map.isRequired, | ||||
|     width: PropTypes.number, | ||||
|     height: PropTypes.number, | ||||
|     sensitive: PropTypes.bool, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     autoplay: PropTypes.bool, | ||||
|     onOpenVideo: PropTypes.func.isRequired, | ||||
|   }; | ||||
|  | ||||
|   static defaultProps = { | ||||
|     width: 239, | ||||
|     height: 110, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     visible: !this.props.sensitive, | ||||
|     preview: true, | ||||
|     muted: true, | ||||
|     hasAudio: true, | ||||
|     videoError: false, | ||||
|   }; | ||||
|  | ||||
|   handleClick = () => { | ||||
|     this.setState({ muted: !this.state.muted }); | ||||
|   } | ||||
|  | ||||
|   handleVideoClick = (e) => { | ||||
|     e.stopPropagation(); | ||||
|  | ||||
|     const node = this.video; | ||||
|  | ||||
|     if (node.paused) { | ||||
|       node.play(); | ||||
|     } else { | ||||
|       node.pause(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleOpen = () => { | ||||
|     this.setState({ preview: !this.state.preview }); | ||||
|   } | ||||
|  | ||||
|   handleVisibility = () => { | ||||
|     this.setState({ | ||||
|       visible: !this.state.visible, | ||||
|       preview: true, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   handleExpand = () => { | ||||
|     this.video.pause(); | ||||
|     this.props.onOpenVideo(this.props.media, this.video.currentTime); | ||||
|   } | ||||
|  | ||||
|   setRef = (c) => { | ||||
|     this.video = c; | ||||
|   } | ||||
|  | ||||
|   handleLoadedData = () => { | ||||
|     if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) { | ||||
|       this.setState({ hasAudio: false }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleVideoError = () => { | ||||
|     this.setState({ videoError: true }); | ||||
|   } | ||||
|  | ||||
|   componentDidMount () { | ||||
|     if (!this.video) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this.video.addEventListener('loadeddata', this.handleLoadedData); | ||||
|     this.video.addEventListener('error', this.handleVideoError); | ||||
|   } | ||||
|  | ||||
|   componentDidUpdate () { | ||||
|     if (!this.video) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this.video.addEventListener('loadeddata', this.handleLoadedData); | ||||
|     this.video.addEventListener('error', this.handleVideoError); | ||||
|   } | ||||
|  | ||||
|   componentWillUnmount () { | ||||
|     if (!this.video) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this.video.removeEventListener('loadeddata', this.handleLoadedData); | ||||
|     this.video.removeEventListener('error', this.handleVideoError); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { media, intl, width, height, sensitive, autoplay } = this.props; | ||||
|  | ||||
|     let spoilerButton = ( | ||||
|       <div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}> | ||||
|         <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} /> | ||||
|       </div> | ||||
|     ); | ||||
|  | ||||
|     let expandButton = ''; | ||||
|  | ||||
|     if (this.context.router) { | ||||
|       expandButton = ( | ||||
|         <div className='status__video-player-expand'> | ||||
|           <IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} /> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     let muteButton = ''; | ||||
|  | ||||
|     if (this.state.hasAudio) { | ||||
|       muteButton = ( | ||||
|         <div className='status__video-player-mute'> | ||||
|           <IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     if (!this.state.visible) { | ||||
|       if (sensitive) { | ||||
|         return ( | ||||
|           <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> | ||||
|             {spoilerButton} | ||||
|             <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> | ||||
|             <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> | ||||
|           </button> | ||||
|         ); | ||||
|       } else { | ||||
|         return ( | ||||
|           <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> | ||||
|             {spoilerButton} | ||||
|             <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> | ||||
|             <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> | ||||
|           </button> | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (this.state.preview && !autoplay) { | ||||
|       return ( | ||||
|         <button className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}> | ||||
|           {spoilerButton} | ||||
|           <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div> | ||||
|         </button> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     if (this.state.videoError) { | ||||
|       return ( | ||||
|         <div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' > | ||||
|           <span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       <div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}> | ||||
|         {spoilerButton} | ||||
|         {muteButton} | ||||
|         {expandButton} | ||||
|  | ||||
|         <video | ||||
|           className='status__video-player-video' | ||||
|           role='button' | ||||
|           tabIndex='0' | ||||
|           ref={this.setRef} | ||||
|           src={media.get('url')} | ||||
|           autoPlay={!isIOS()} | ||||
|           loop | ||||
|           muted={this.state.muted} | ||||
|           onClick={this.handleVideoClick} | ||||
|         /> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -3,9 +3,8 @@ import { Provider } from 'react-redux'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import configureStore from '../store/configureStore'; | ||||
| import { showOnboardingOnce } from '../actions/onboarding'; | ||||
| import BrowserRouter from 'react-router-dom/BrowserRouter'; | ||||
| import Route from 'react-router-dom/Route'; | ||||
| import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext'; | ||||
| import { BrowserRouter, Route } from 'react-router-dom'; | ||||
| import { ScrollContext } from 'react-router-scroll'; | ||||
| import UI from '../features/ui'; | ||||
| import { hydrateStore } from '../actions/store'; | ||||
| import { connectUserStream } from '../actions/streaming'; | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store'; | ||||
| import { IntlProvider, addLocaleData } from 'react-intl'; | ||||
| import { getLocale } from '../locales'; | ||||
| import PublicTimeline from '../features/standalone/public_timeline'; | ||||
| import HashtagTimeline from '../features/standalone/hashtag_timeline'; | ||||
|  | ||||
| const { localeData, messages } = getLocale(); | ||||
| addLocaleData(localeData); | ||||
| @@ -22,15 +23,24 @@ export default class TimelineContainer extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     locale: PropTypes.string.isRequired, | ||||
|     hashtag: PropTypes.string, | ||||
|   }; | ||||
|  | ||||
|   render () { | ||||
|     const { locale } = this.props; | ||||
|     const { locale, hashtag } = this.props; | ||||
|  | ||||
|     let timeline; | ||||
|  | ||||
|     if (hashtag) { | ||||
|       timeline = <HashtagTimeline hashtag={hashtag} />; | ||||
|     } else { | ||||
|       timeline = <PublicTimeline />; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       <IntlProvider locale={locale} messages={messages}> | ||||
|         <Provider store={store}> | ||||
|           <PublicTimeline /> | ||||
|           {timeline} | ||||
|         </Provider> | ||||
|       </IntlProvider> | ||||
|     ); | ||||
|   | ||||
| @@ -1,30 +0,0 @@ | ||||
| import { unicodeMapping } from './emojione_light'; | ||||
| import Trie from 'substring-trie'; | ||||
|  | ||||
| const trie = new Trie(Object.keys(unicodeMapping)); | ||||
|  | ||||
| const emojify = str => { | ||||
|   let rtn = ''; | ||||
|   for (;;) { | ||||
|     let match, i = 0; | ||||
|     while (i < str.length && str[i] !== '<' && !(match = trie.search(str.slice(i)))) { | ||||
|       i += str.codePointAt(i) < 65536 ? 1 : 2; | ||||
|     } | ||||
|     if (i === str.length) | ||||
|       break; | ||||
|     else if (str[i] === '<') { | ||||
|       let tagend = str.indexOf('>', i + 1) + 1; | ||||
|       if (!tagend) | ||||
|         break; | ||||
|       rtn += str.slice(0, tagend); | ||||
|       str = str.slice(tagend); | ||||
|     } else { | ||||
|       const [filename, shortCode] = unicodeMapping[match]; | ||||
|       rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`; | ||||
|       str = str.slice(i + match.length); | ||||
|     } | ||||
|   } | ||||
|   return rtn + str; | ||||
| }; | ||||
|  | ||||
| export default emojify; | ||||
| @@ -1,13 +0,0 @@ | ||||
| // @preval | ||||
| // Force tree shaking on emojione by exposing just a subset of its functionality | ||||
|  | ||||
| const emojione = require('emojione'); | ||||
|  | ||||
| const mappedUnicode = emojione.mapUnicodeToShort(); | ||||
| const excluded = ['®', '©', '™']; | ||||
|  | ||||
| module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap) | ||||
|   .filter(c => !excluded.includes(c)) | ||||
|   .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]]) | ||||
|   .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] })) | ||||
|   .reduce((x, y) => Object.assign(x, y), { }); | ||||
| @@ -2,7 +2,7 @@ import React from 'react'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; | ||||
| import Link from 'react-router-dom/Link'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
| @@ -26,7 +26,7 @@ export default class ActionBar extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     account: ImmutablePropTypes.map.isRequired, | ||||
|     me: PropTypes.number.isRequired, | ||||
|     me: PropTypes.string.isRequired, | ||||
|     onFollow: PropTypes.func, | ||||
|     onBlock: PropTypes.func.isRequired, | ||||
|     onMention: PropTypes.func.isRequired, | ||||
|   | ||||
| @@ -77,7 +77,7 @@ export default class Header extends ImmutablePureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     account: ImmutablePropTypes.map, | ||||
|     me: PropTypes.number.isRequired, | ||||
|     me: PropTypes.string.isRequired, | ||||
|     onFollow: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     autoPlayGif: PropTypes.bool.isRequired, | ||||
|   | ||||
| @@ -16,9 +16,9 @@ import { ScrollContainer } from 'react-router-scroll'; | ||||
| import LoadMore from '../../components/load_more'; | ||||
|  | ||||
| const mapStateToProps = (state, props) => ({ | ||||
|   medias: getAccountGallery(state, Number(props.params.accountId)), | ||||
|   isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'isLoading']), | ||||
|   hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'next']), | ||||
|   medias: getAccountGallery(state, props.params.accountId), | ||||
|   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), | ||||
|   hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']), | ||||
|   autoPlayGif: state.getIn(['meta', 'auto_play_gif']), | ||||
| }); | ||||
|  | ||||
| @@ -35,20 +35,20 @@ export default class AccountGallery extends ImmutablePureComponent { | ||||
|   }; | ||||
|  | ||||
|   componentDidMount () { | ||||
|     this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); | ||||
|     this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId))); | ||||
|     this.props.dispatch(fetchAccount(this.props.params.accountId)); | ||||
|     this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); | ||||
|   } | ||||
|  | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { | ||||
|       this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); | ||||
|       this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId))); | ||||
|       this.props.dispatch(fetchAccount(nextProps.params.accountId)); | ||||
|       this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleScrollToBottom = () => { | ||||
|     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 = { | ||||
|     account: ImmutablePropTypes.map, | ||||
|     me: PropTypes.number.isRequired, | ||||
|     me: PropTypes.string.isRequired, | ||||
|     onFollow: PropTypes.func.isRequired, | ||||
|     onBlock: PropTypes.func.isRequired, | ||||
|     onMention: PropTypes.func.isRequired, | ||||
|   | ||||
| @@ -27,7 +27,7 @@ const makeMapStateToProps = () => { | ||||
|   const getAccount = makeGetAccount(); | ||||
|  | ||||
|   const mapStateToProps = (state, { accountId }) => ({ | ||||
|     account: getAccount(state, Number(accountId)), | ||||
|     account: getAccount(state, accountId), | ||||
|     me: state.getIn(['meta', 'me']), | ||||
|     unfollowModal: state.getIn(['meta', 'unfollow_modal']), | ||||
|   }); | ||||
|   | ||||
| @@ -13,9 +13,9 @@ import { List as ImmutableList } from 'immutable'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
|  | ||||
| const mapStateToProps = (state, props) => ({ | ||||
|   statusIds: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'items'], ImmutableList()), | ||||
|   isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'isLoading']), | ||||
|   hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'next']), | ||||
|   statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()), | ||||
|   isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']), | ||||
|   hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']), | ||||
|   me: state.getIn(['meta', 'me']), | ||||
| }); | ||||
|  | ||||
| @@ -28,24 +28,24 @@ export default class AccountTimeline extends ImmutablePureComponent { | ||||
|     statusIds: ImmutablePropTypes.list, | ||||
|     isLoading: PropTypes.bool, | ||||
|     hasMore: PropTypes.bool, | ||||
|     me: PropTypes.number.isRequired, | ||||
|     me: PropTypes.string.isRequired, | ||||
|   }; | ||||
|  | ||||
|   componentWillMount () { | ||||
|     this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); | ||||
|     this.props.dispatch(refreshAccountTimeline(Number(this.props.params.accountId))); | ||||
|     this.props.dispatch(fetchAccount(this.props.params.accountId)); | ||||
|     this.props.dispatch(refreshAccountTimeline(this.props.params.accountId)); | ||||
|   } | ||||
|  | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { | ||||
|       this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); | ||||
|       this.props.dispatch(refreshAccountTimeline(Number(nextProps.params.accountId))); | ||||
|       this.props.dispatch(fetchAccount(nextProps.params.accountId)); | ||||
|       this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleScrollToBottom = () => { | ||||
|     if (!this.props.isLoading && this.props.hasMore) { | ||||
|       this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId))); | ||||
|       this.props.dispatch(expandAccountTimeline(this.props.params.accountId)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -5,14 +5,13 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import ReplyIndicatorContainer from '../containers/reply_indicator_container'; | ||||
| import AutosuggestTextarea from '../../../components/autosuggest_textarea'; | ||||
| import { debounce } from 'lodash'; | ||||
| import UploadButtonContainer from '../containers/upload_button_container'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import Collapsable from '../../../components/collapsable'; | ||||
| import SpoilerButtonContainer from '../containers/spoiler_button_container'; | ||||
| import PrivacyDropdownContainer from '../containers/privacy_dropdown_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 WarningContainer from '../containers/warning_container'; | ||||
| import { isMobile } from '../../../is_mobile'; | ||||
| @@ -42,7 +41,7 @@ export default class ComposeForm extends ImmutablePureComponent { | ||||
|     preselectDate: PropTypes.instanceOf(Date), | ||||
|     is_submitting: PropTypes.bool, | ||||
|     is_uploading: PropTypes.bool, | ||||
|     me: PropTypes.number, | ||||
|     me: PropTypes.string, | ||||
|     onChange: PropTypes.func.isRequired, | ||||
|     onSubmit: PropTypes.func.isRequired, | ||||
|     onClearSuggestions: PropTypes.func.isRequired, | ||||
| @@ -82,9 +81,9 @@ export default class ComposeForm extends ImmutablePureComponent { | ||||
|     this.props.onClearSuggestions(); | ||||
|   } | ||||
|  | ||||
|   onSuggestionsFetchRequested = debounce((token) => { | ||||
|   onSuggestionsFetchRequested = (token) => { | ||||
|     this.props.onFetchSuggestions(token); | ||||
|   }, 500, { trailing: true }) | ||||
|   } | ||||
|  | ||||
|   onSuggestionSelected = (tokenStart, token, value) => { | ||||
|     this._restoreCaret = null; | ||||
| @@ -138,7 +137,7 @@ export default class ComposeForm extends ImmutablePureComponent { | ||||
|  | ||||
|   handleEmojiPick = (data) => { | ||||
|     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.props.onPickEmoji(position, data); | ||||
|   } | ||||
|   | ||||
| @@ -1,12 +1,20 @@ | ||||
| import React from 'react'; | ||||
| import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; | ||||
| import Overlay from 'react-overlays/lib/Overlay'; | ||||
| import classNames from 'classnames'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import detectPassiveEvents from 'detect-passive-events'; | ||||
| import { buildCustomEmojis } from '../../emoji/emoji'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, | ||||
|   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' }, | ||||
|   nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, | ||||
|   food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, | ||||
| @@ -17,20 +25,270 @@ const messages = defineMessages({ | ||||
|   flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, | ||||
| }); | ||||
|  | ||||
| const settings = { | ||||
|   imageType: 'png', | ||||
|   sprites: false, | ||||
|   imagePathPNG: '/emoji/', | ||||
| }; | ||||
| const assetHost = process.env.CDN_HOST || ''; | ||||
| let EmojiPicker, Emoji; // load asynchronously | ||||
|  | ||||
| let EmojiPicker; // load asynchronously | ||||
| const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`; | ||||
| const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; | ||||
|  | ||||
| const categoriesSort = [ | ||||
|   'recent', | ||||
|   'custom', | ||||
|   'people', | ||||
|   'nature', | ||||
|   'foods', | ||||
|   'activity', | ||||
|   'places', | ||||
|   'objects', | ||||
|   'symbols', | ||||
|   'flags', | ||||
| ]; | ||||
|  | ||||
| class ModifierPickerMenu extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     active: PropTypes.bool, | ||||
|     onSelect: PropTypes.func.isRequired, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|   }; | ||||
|  | ||||
|   handleClick = e => { | ||||
|     this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); | ||||
|   } | ||||
|  | ||||
|   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} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button> | ||||
|         <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button> | ||||
|         <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button> | ||||
|         <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button> | ||||
|         <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button> | ||||
|         <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| 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, | ||||
|     frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), | ||||
|     loading: PropTypes.bool, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     onPick: PropTypes.func.isRequired, | ||||
|     style: PropTypes.object, | ||||
|     placement: PropTypes.string, | ||||
|     arrowOffsetLeft: PropTypes.string, | ||||
|     arrowOffsetTop: PropTypes.string, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     skinTone: PropTypes.number.isRequired, | ||||
|     onSkinTone: PropTypes.func.isRequired, | ||||
|     autoPlay: PropTypes.bool, | ||||
|   }; | ||||
|  | ||||
|   static defaultProps = { | ||||
|     style: {}, | ||||
|     loading: true, | ||||
|     placement: 'bottom', | ||||
|     frequentlyUsedEmojis: [], | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     modifierOpen: false, | ||||
|   }; | ||||
|  | ||||
|   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 => { | ||||
|     this.props.onSkinTone(modifier); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props; | ||||
|  | ||||
|     if (loading) { | ||||
|       return <div style={{ width: 299 }} />; | ||||
|     } | ||||
|  | ||||
|     const title = intl.formatMessage(messages.emoji); | ||||
|     const { modifierOpen } = this.state; | ||||
|  | ||||
|     return ( | ||||
|       <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> | ||||
|         <EmojiPicker | ||||
|           perLine={8} | ||||
|           emojiSize={22} | ||||
|           sheetSize={32} | ||||
|           custom={buildCustomEmojis(custom_emojis, autoPlay)} | ||||
|           color='' | ||||
|           emoji='' | ||||
|           set='twitter' | ||||
|           title={title} | ||||
|           i18n={this.getI18n()} | ||||
|           onClick={this.handleClick} | ||||
|           include={categoriesSort} | ||||
|           recent={frequentlyUsedEmojis} | ||||
|           skin={skinTone} | ||||
|           showPreview={false} | ||||
|           backgroundImageFn={backgroundImageFn} | ||||
|           emojiTooltip | ||||
|         /> | ||||
|  | ||||
|         <ModifierPicker | ||||
|           active={modifierOpen} | ||||
|           modifier={skinTone} | ||||
|           onOpen={this.handleModifierOpen} | ||||
|           onClose={this.handleModifierClose} | ||||
|           onChange={this.handleModifierChange} | ||||
|         /> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| @injectIntl | ||||
| export default class EmojiPickerDropdown extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     custom_emojis: ImmutablePropTypes.list, | ||||
|     frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), | ||||
|     autoPlay: PropTypes.bool, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     onPickEmoji: PropTypes.func.isRequired, | ||||
|     onSkinTone: PropTypes.func.isRequired, | ||||
|     skinTone: PropTypes.number.isRequired, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
| @@ -42,20 +300,18 @@ export default class EmojiPickerDropdown extends React.PureComponent { | ||||
|     this.dropdown = c; | ||||
|   } | ||||
|  | ||||
|   handleChange = (data) => { | ||||
|     this.dropdown.hide(); | ||||
|     this.props.onPickEmoji(data); | ||||
|   } | ||||
|  | ||||
|   onShowDropdown = () => { | ||||
|     this.setState({ active: true }); | ||||
|  | ||||
|     if (!EmojiPicker) { | ||||
|       this.setState({ loading: true }); | ||||
|       EmojiPickerAsync().then(TheEmojiPicker => { | ||||
|         EmojiPicker = TheEmojiPicker.default; | ||||
|  | ||||
|       EmojiPickerAsync().then(EmojiMart => { | ||||
|         EmojiPicker = EmojiMart.Picker; | ||||
|         Emoji       = EmojiMart.Emoji; | ||||
|  | ||||
|         this.setState({ loading: false }); | ||||
|       }).catch(() => { | ||||
|         // TODO: show the user an error? | ||||
|         this.setState({ loading: false }); | ||||
|       }); | ||||
|     } | ||||
| @@ -75,70 +331,48 @@ export default class EmojiPickerDropdown extends React.PureComponent { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onEmojiPickerKeyDown = (e) => { | ||||
|   handleKeyDown = e => { | ||||
|     if (e.key === 'Escape') { | ||||
|       this.onHideDropdown(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   setTargetRef = c => { | ||||
|     this.target = c; | ||||
|   } | ||||
|  | ||||
|   findTarget = () => { | ||||
|     return this.target; | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { intl } = 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 { intl, onPickEmoji, autoPlay, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; | ||||
|     const title = intl.formatMessage(messages.emoji); | ||||
|     const { active, loading } = this.state; | ||||
|  | ||||
|     return ( | ||||
|       <Dropdown ref={this.setRef} className='emoji-picker__dropdown' active={active && !loading} onShow={this.onShowDropdown} onHide={this.onHideDropdown}> | ||||
|         <DropdownTrigger className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onKeyDown={this.onToggle} tabIndex={0} > | ||||
|       <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> | ||||
|         <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> | ||||
|           <img | ||||
|             className={`emojione ${active && loading ? 'pulse-loading' : ''}`} | ||||
|             className={classNames('emojione', { 'pulse-loading': active && loading })} | ||||
|             alt='🙂' | ||||
|             src='/emoji/1f602.svg' | ||||
|             src={`${assetHost}/emoji/1f602.svg`} | ||||
|           /> | ||||
|         </DropdownTrigger> | ||||
|         </div> | ||||
|  | ||||
|         <DropdownContent className='dropdown__left'> | ||||
|           { | ||||
|             this.state.active && !this.state.loading && | ||||
|             (<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} onKeyDown={this.onEmojiPickerKeyDown} categories={categories} search />) | ||||
|           } | ||||
|         </DropdownContent> | ||||
|       </Dropdown> | ||||
|         <Overlay show={active} placement='bottom' target={this.findTarget}> | ||||
|           <EmojiPickerMenu | ||||
|             custom_emojis={this.props.custom_emojis} | ||||
|             loading={loading} | ||||
|             onClose={this.onHideDropdown} | ||||
|             onPick={onPickEmoji} | ||||
|             autoPlay={autoPlay} | ||||
|             onSkinTone={onSkinTone} | ||||
|             skinTone={skinTone} | ||||
|             frequentlyUsedEmojis={frequentlyUsedEmojis} | ||||
|           /> | ||||
|         </Overlay> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,11 @@ import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { injectIntl, defineMessages } from 'react-intl'; | ||||
| import IconButton from '../../../components/icon_button'; | ||||
| import Overlay from 'react-overlays/lib/Overlay'; | ||||
| import Motion from 'react-motion/lib/Motion'; | ||||
| import spring from 'react-motion/lib/spring'; | ||||
| import detectPassiveEvents from 'detect-passive-events'; | ||||
| import classNames from 'classnames'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, | ||||
| @@ -15,10 +20,77 @@ const messages = defineMessages({ | ||||
|   change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, | ||||
| }); | ||||
|  | ||||
| const iconStyle = { | ||||
|   height: null, | ||||
|   lineHeight: '27px', | ||||
| }; | ||||
| const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; | ||||
|  | ||||
| class PrivacyDropdownMenu extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     style: PropTypes.object, | ||||
|     items: PropTypes.array.isRequired, | ||||
|     value: PropTypes.string.isRequired, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     onChange: PropTypes.func.isRequired, | ||||
|   }; | ||||
|  | ||||
|   handleDocumentClick = e => { | ||||
|     if (this.node && !this.node.contains(e.target)) { | ||||
|       this.props.onClose(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleClick = e => { | ||||
|     if (e.key === 'Escape') { | ||||
|       this.props.onClose(); | ||||
|     } else if (!e.key || e.key === 'Enter') { | ||||
|       const value = e.currentTarget.getAttribute('data-index'); | ||||
|  | ||||
|       e.preventDefault(); | ||||
|  | ||||
|       this.props.onClose(); | ||||
|       this.props.onChange(value); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   componentDidMount () { | ||||
|     document.addEventListener('click', this.handleDocumentClick, false); | ||||
|     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); | ||||
|   } | ||||
|  | ||||
|   componentWillUnmount () { | ||||
|     document.removeEventListener('click', this.handleDocumentClick, false); | ||||
|     document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); | ||||
|   } | ||||
|  | ||||
|   setRef = c => { | ||||
|     this.node = c; | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { style, items, value } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> | ||||
|         {({ opacity, scaleX, scaleY }) => ( | ||||
|           <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> | ||||
|             {items.map(item => | ||||
|               <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}> | ||||
|                 <div className='privacy-dropdown__option__icon'> | ||||
|                   <i className={`fa fa-fw fa-${item.icon}`} /> | ||||
|                 </div> | ||||
|  | ||||
|                 <div className='privacy-dropdown__option__content'> | ||||
|                   <strong>{item.text}</strong> | ||||
|                   {item.meta} | ||||
|                 </div> | ||||
|               </div> | ||||
|             )} | ||||
|           </div> | ||||
|         )} | ||||
|       </Motion> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| @injectIntl | ||||
| export default class PrivacyDropdown extends React.PureComponent { | ||||
| @@ -54,26 +126,30 @@ export default class PrivacyDropdown extends React.PureComponent { | ||||
|  | ||||
|   handleModalActionClick = (e) => { | ||||
|     e.preventDefault(); | ||||
|  | ||||
|     const { value } = this.options[e.currentTarget.getAttribute('data-index')]; | ||||
|  | ||||
|     this.props.onModalClose(); | ||||
|     this.props.onChange(value); | ||||
|   } | ||||
|  | ||||
|   handleClick = (e) => { | ||||
|     if (e.key === 'Escape') { | ||||
|       this.setState({ open: false }); | ||||
|     } else if (!e.key || e.key === 'Enter') { | ||||
|       const value = e.currentTarget.getAttribute('data-index'); | ||||
|       e.preventDefault(); | ||||
|       this.setState({ open: false }); | ||||
|       this.props.onChange(value); | ||||
|   handleKeyDown = e => { | ||||
|     switch(e.key) { | ||||
|     case 'Enter': | ||||
|       this.handleToggle(); | ||||
|       break; | ||||
|     case 'Escape': | ||||
|       this.handleClose(); | ||||
|       break; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onGlobalClick = (e) => { | ||||
|     if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { | ||||
|       this.setState({ open: false }); | ||||
|     } | ||||
|   handleClose = () => { | ||||
|     this.setState({ open: false }); | ||||
|   } | ||||
|  | ||||
|   handleChange = value => { | ||||
|     this.props.onChange(value); | ||||
|   } | ||||
|  | ||||
|   componentWillMount () { | ||||
| @@ -87,20 +163,6 @@ export default class PrivacyDropdown extends React.PureComponent { | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   componentDidMount () { | ||||
|     window.addEventListener('click', this.onGlobalClick); | ||||
|     window.addEventListener('touchstart', this.onGlobalClick); | ||||
|   } | ||||
|  | ||||
|   componentWillUnmount () { | ||||
|     window.removeEventListener('click', this.onGlobalClick); | ||||
|     window.removeEventListener('touchstart', this.onGlobalClick); | ||||
|   } | ||||
|  | ||||
|   setRef = (c) => { | ||||
|     this.node = c; | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { value, intl } = this.props; | ||||
|     const { open } = this.state; | ||||
| @@ -108,19 +170,29 @@ export default class PrivacyDropdown extends React.PureComponent { | ||||
|     const valueOption = this.options.find(item => item.value === value); | ||||
|  | ||||
|     return ( | ||||
|       <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> | ||||
|         <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} expanded={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> | ||||
|         <div className='privacy-dropdown__dropdown'> | ||||
|           {open && this.options.map(item => | ||||
|             <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> | ||||
|               <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> | ||||
|               <div className='privacy-dropdown__option__content'> | ||||
|                 <strong>{item.text}</strong> | ||||
|                 {item.meta} | ||||
|               </div> | ||||
|             </div> | ||||
|           )} | ||||
|       <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}> | ||||
|         <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}> | ||||
|           <IconButton | ||||
|             className='privacy-dropdown__value-icon' | ||||
|             icon={valueOption.icon} | ||||
|             title={intl.formatMessage(messages.change_privacy)} | ||||
|             size={18} | ||||
|             expanded={open} | ||||
|             active={open} | ||||
|             inverted | ||||
|             onClick={this.handleToggle} | ||||
|             style={{ height: null, lineHeight: '27px' }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <Overlay show={open} placement='bottom' target={this}> | ||||
|           <PrivacyDropdownMenu | ||||
|             items={this.options} | ||||
|             value={value} | ||||
|             onClose={this.handleClose} | ||||
|             onChange={this.handleChange} | ||||
|           /> | ||||
|         </Overlay> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -1,11 +1,47 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import Overlay from 'react-overlays/lib/Overlay'; | ||||
| import Motion from 'react-motion/lib/Motion'; | ||||
| import spring from 'react-motion/lib/spring'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, | ||||
| }); | ||||
|  | ||||
| class SearchPopout extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     style: PropTypes.object, | ||||
|   }; | ||||
|  | ||||
|   render () { | ||||
|     const { style } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <div style={{ ...style, position: 'absolute', width: 285 }}> | ||||
|         <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> | ||||
|           {({ opacity, scaleX, scaleY }) => ( | ||||
|             <div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> | ||||
|               <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4> | ||||
|  | ||||
|               <ul> | ||||
|                 <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li> | ||||
|                 <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> | ||||
|                 <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> | ||||
|                 <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li> | ||||
|               </ul> | ||||
|  | ||||
|               <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' /> | ||||
|             </div> | ||||
|           )} | ||||
|         </Motion> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| @injectIntl | ||||
| export default class Search extends React.PureComponent { | ||||
|  | ||||
| @@ -19,6 +55,10 @@ export default class Search extends React.PureComponent { | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     expanded: false, | ||||
|   }; | ||||
|  | ||||
|   handleChange = (e) => { | ||||
|     this.props.onChange(e.target.value); | ||||
|   } | ||||
| @@ -35,6 +75,8 @@ export default class Search extends React.PureComponent { | ||||
|     if (e.key === 'Enter') { | ||||
|       e.preventDefault(); | ||||
|       this.props.onSubmit(); | ||||
|     } else if (e.key === 'Escape') { | ||||
|       document.querySelector('.ui').parentElement.focus(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -43,11 +85,17 @@ export default class Search extends React.PureComponent { | ||||
|   } | ||||
|  | ||||
|   handleFocus = () => { | ||||
|     this.setState({ expanded: true }); | ||||
|     this.props.onShow(); | ||||
|   } | ||||
|  | ||||
|   handleBlur = () => { | ||||
|     this.setState({ expanded: false }); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { intl, value, submitted } = this.props; | ||||
|     const { expanded } = this.state; | ||||
|     const hasValue = value.length > 0 || submitted; | ||||
|  | ||||
|     return ( | ||||
| @@ -62,6 +110,7 @@ export default class Search extends React.PureComponent { | ||||
|             onChange={this.handleChange} | ||||
|             onKeyUp={this.handleKeyDown} | ||||
|             onFocus={this.handleFocus} | ||||
|             onBlur={this.handleBlur} | ||||
|           /> | ||||
|         </label> | ||||
|  | ||||
| @@ -69,6 +118,10 @@ export default class Search extends React.PureComponent { | ||||
|           <i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> | ||||
|           <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} /> | ||||
|         </div> | ||||
|  | ||||
|         <Overlay show={expanded && !hasValue} placement='bottom' target={this}> | ||||
|           <SearchPopout /> | ||||
|         </Overlay> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import AccountContainer from '../../../containers/account_container'; | ||||
| import StatusContainer from '../../../containers/status_container'; | ||||
| import Link from 'react-router-dom/Link'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
|  | ||||
| export default class SearchResults extends ImmutablePureComponent { | ||||
|   | ||||
| @@ -0,0 +1,96 @@ | ||||
| import React from 'react'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import IconButton from '../../../components/icon_button'; | ||||
| import Motion from 'react-motion/lib/Motion'; | ||||
| import spring from 'react-motion/lib/spring'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import classNames from 'classnames'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, | ||||
|   description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, | ||||
| }); | ||||
|  | ||||
| @injectIntl | ||||
| export default class Upload extends ImmutablePureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     media: ImmutablePropTypes.map.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     onUndo: PropTypes.func.isRequired, | ||||
|     onDescriptionChange: PropTypes.func.isRequired, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     hovered: false, | ||||
|     focused: false, | ||||
|     dirtyDescription: null, | ||||
|   }; | ||||
|  | ||||
|   handleUndoClick = () => { | ||||
|     this.props.onUndo(this.props.media.get('id')); | ||||
|   } | ||||
|  | ||||
|   handleInputChange = e => { | ||||
|     this.setState({ dirtyDescription: e.target.value }); | ||||
|   } | ||||
|  | ||||
|   handleMouseEnter = () => { | ||||
|     this.setState({ hovered: true }); | ||||
|   } | ||||
|  | ||||
|   handleMouseLeave = () => { | ||||
|     this.setState({ hovered: false }); | ||||
|   } | ||||
|  | ||||
|   handleInputFocus = () => { | ||||
|     this.setState({ focused: true }); | ||||
|   } | ||||
|  | ||||
|   handleInputBlur = () => { | ||||
|     const { dirtyDescription } = this.state; | ||||
|  | ||||
|     this.setState({ focused: false, dirtyDescription: null }); | ||||
|  | ||||
|     if (dirtyDescription !== null) { | ||||
|       this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { intl, media } = this.props; | ||||
|     const active          = this.state.hovered || this.state.focused; | ||||
|     const description     = this.state.dirtyDescription || media.get('description') || ''; | ||||
|  | ||||
|     return ( | ||||
|       <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> | ||||
|         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> | ||||
|           {({ scale }) => ( | ||||
|             <div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}> | ||||
|               <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} /> | ||||
|  | ||||
|               <div className={classNames('compose-form__upload-description', { active })}> | ||||
|                 <label> | ||||
|                   <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> | ||||
|  | ||||
|                   <input | ||||
|                     placeholder={intl.formatMessage(messages.description)} | ||||
|                     type='text' | ||||
|                     value={description} | ||||
|                     maxLength={420} | ||||
|                     onFocus={this.handleInputFocus} | ||||
|                     onChange={this.handleInputChange} | ||||
|                     onBlur={this.handleInputBlur} | ||||
|                   /> | ||||
|                 </label> | ||||
|               </div> | ||||
|             </div> | ||||
|           )} | ||||
|         </Motion> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -1,49 +1,27 @@ | ||||
| import React from 'react'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import IconButton from '../../../components/icon_button'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import UploadProgressContainer from '../containers/upload_progress_container'; | ||||
| import Motion from 'react-motion/lib/Motion'; | ||||
| import spring from 'react-motion/lib/spring'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import UploadContainer from '../containers/upload_container'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, | ||||
| }); | ||||
|  | ||||
| @injectIntl | ||||
| export default class UploadForm extends React.PureComponent { | ||||
| export default class UploadForm extends ImmutablePureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     media: ImmutablePropTypes.list.isRequired, | ||||
|     onRemoveFile: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     mediaIds: ImmutablePropTypes.list.isRequired, | ||||
|   }; | ||||
|  | ||||
|   onRemoveFile = (e) => { | ||||
|     const id = Number(e.currentTarget.parentElement.getAttribute('data-id')); | ||||
|     this.props.onRemoveFile(id); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { intl, media } = this.props; | ||||
|  | ||||
|     const uploads = media.map(attachment => | ||||
|       <div className='compose-form__upload' key={attachment.get('id')}> | ||||
|         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> | ||||
|           {({ scale }) => | ||||
|             <div className='compose-form__upload-thumbnail' data-id={attachment.get('id')} style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}> | ||||
|               <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.onRemoveFile} /> | ||||
|             </div> | ||||
|           } | ||||
|         </Motion> | ||||
|       </div> | ||||
|     ); | ||||
|     const { mediaIds } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <div className='compose-form__upload-wrapper'> | ||||
|         <UploadProgressContainer /> | ||||
|         <div className='compose-form__uploads-wrapper'>{uploads}</div> | ||||
|  | ||||
|         <div className='compose-form__uploads-wrapper'> | ||||
|           {mediaIds.map(id => ( | ||||
|             <UploadContainer id={id} key={id} /> | ||||
|           ))} | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import Motion from 'react-motion/lib/Motion'; | ||||
| import spring from 'react-motion/lib/spring'; | ||||
|  | ||||
| export default class Warning extends React.PureComponent { | ||||
|  | ||||
| @@ -11,9 +13,13 @@ export default class Warning extends React.PureComponent { | ||||
|     const { message } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <div className='compose-form__warning'> | ||||
|         {message} | ||||
|       </div> | ||||
|       <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> | ||||
|         {({ opacity, scaleX, scaleY }) => ( | ||||
|           <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> | ||||
|             {message} | ||||
|           </div> | ||||
|         )} | ||||
|       </Motion> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,57 @@ | ||||
| import { connect } from 'react-redux'; | ||||
| import EmojiPickerDropdown from '../components/emoji_picker_dropdown'; | ||||
| import { changeSetting } from '../../../actions/settings'; | ||||
| import { createSelector } from 'reselect'; | ||||
| import { Map as ImmutableMap } from 'immutable'; | ||||
| import { useEmoji } from '../../../actions/emojis'; | ||||
|  | ||||
| const perLine = 8; | ||||
| const lines   = 2; | ||||
|  | ||||
| const getFrequentlyUsedEmojis = createSelector([ | ||||
|   state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()), | ||||
| ], emojiCounters => emojiCounters | ||||
|     .keySeq() | ||||
|     .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b)) | ||||
|     .reverse() | ||||
|     .slice(0, perLine * lines) | ||||
|     .toArray() | ||||
| ); | ||||
|  | ||||
| const getCustomEmojis = createSelector([ | ||||
|   state => state.get('custom_emojis'), | ||||
| ], emojis => emojis.sort((a, b) => { | ||||
|   const aShort = a.get('shortcode').toLowerCase(); | ||||
|   const bShort = b.get('shortcode').toLowerCase(); | ||||
|  | ||||
|   if (aShort < bShort) { | ||||
|     return -1; | ||||
|   } else if (aShort > bShort ) { | ||||
|     return 1; | ||||
|   } else { | ||||
|     return 0; | ||||
|   } | ||||
| })); | ||||
|  | ||||
| const mapStateToProps = state => ({ | ||||
|   custom_emojis: getCustomEmojis(state), | ||||
|   autoPlay: state.getIn(['meta', 'auto_play_gif']), | ||||
|   skinTone: state.getIn(['settings', 'skinTone']), | ||||
|   frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), | ||||
| }); | ||||
|  | ||||
| const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ | ||||
|   onSkinTone: skinTone => { | ||||
|     dispatch(changeSetting(['skinTone'], skinTone)); | ||||
|   }, | ||||
|  | ||||
|   onPickEmoji: emoji => { | ||||
|     dispatch(useEmoji(emoji)); | ||||
|  | ||||
|     if (onPickEmoji) { | ||||
|       onPickEmoji(emoji); | ||||
|     } | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown); | ||||
| @@ -0,0 +1,21 @@ | ||||
| import { connect } from 'react-redux'; | ||||
| import Upload from '../components/upload'; | ||||
| import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose'; | ||||
|  | ||||
| const mapStateToProps = (state, { id }) => ({ | ||||
|   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), | ||||
| }); | ||||
|  | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|  | ||||
|   onUndo: id => { | ||||
|     dispatch(undoUploadCompose(id)); | ||||
|   }, | ||||
|  | ||||
|   onDescriptionChange: (id, description) => { | ||||
|     dispatch(changeUploadCompose(id, description)); | ||||
|   }, | ||||
|  | ||||
| }); | ||||
|  | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(Upload); | ||||
| @@ -1,17 +1,8 @@ | ||||
| import { connect } from 'react-redux'; | ||||
| import UploadForm from '../components/upload_form'; | ||||
| import { undoUploadCompose } from '../../../actions/compose'; | ||||
|  | ||||
| const mapStateToProps = state => ({ | ||||
|   media: state.getIn(['compose', 'media_attachments']), | ||||
|   mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')), | ||||
| }); | ||||
|  | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|  | ||||
|   onRemoveFile (media_id) { | ||||
|     dispatch(undoUploadCompose(media_id)); | ||||
|   }, | ||||
|  | ||||
| }); | ||||
|  | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(UploadForm); | ||||
| export default connect(mapStateToProps)(UploadForm); | ||||
|   | ||||
| @@ -1,51 +1,23 @@ | ||||
| import React from 'react'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { connect } from 'react-redux'; | ||||
| import Warning from '../components/warning'; | ||||
| import { createSelector } from 'reselect'; | ||||
| import PropTypes from 'prop-types'; | ||||
| 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 getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { | ||||
|   return OrderedSet(mentionedUsernamesWithDomains !== null ? mentionedUsernamesWithDomains.map(item => item.split('@')[2]) : []); | ||||
| const mapStateToProps = state => ({ | ||||
|   needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']), | ||||
| }); | ||||
|  | ||||
| const mapStateToProps = state => { | ||||
|   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 }) => { | ||||
| const WarningWrapper = ({ 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> }} />} />; | ||||
|   } 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; | ||||
| }; | ||||
|  | ||||
| WarningWrapper.propTypes = { | ||||
|   needsLeakWarning: PropTypes.bool, | ||||
|   needsLockWarning: PropTypes.bool, | ||||
|   mentionedDomains: ImmutablePropTypes.orderedSet.isRequired, | ||||
| }; | ||||
|  | ||||
| export default connect(mapStateToProps)(WarningWrapper); | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { connect } from 'react-redux'; | ||||
| import { mountCompose, unmountCompose } from '../../actions/compose'; | ||||
| import Link from 'react-router-dom/Link'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { injectIntl, defineMessages } from 'react-intl'; | ||||
| import SearchContainer from './containers/search_container'; | ||||
| import Motion from 'react-motion/lib/Motion'; | ||||
|   | ||||
| @@ -172,7 +172,7 @@ export const urlRegex = (function() { | ||||
|   , '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); | ||||
|   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('(?:' + | ||||
|     '(?:' + | ||||
|   | ||||
							
								
								
									
										77
									
								
								app/javascript/mastodon/features/emoji/emoji.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								app/javascript/mastodon/features/emoji/emoji.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| import unicodeMapping from './emoji_unicode_mapping_light'; | ||||
| import Trie from 'substring-trie'; | ||||
|  | ||||
| const trie = new Trie(Object.keys(unicodeMapping)); | ||||
|  | ||||
| const assetHost = process.env.CDN_HOST || ''; | ||||
|  | ||||
| let allowAnimations = false; | ||||
|  | ||||
| const emojify = (str, customEmojis = {}) => { | ||||
|   let rtn = ''; | ||||
|   for (;;) { | ||||
|     let match, i = 0, tag; | ||||
|     while (i < str.length && (tag = '<&:'.indexOf(str[i])) === -1 && !(match = trie.search(str.slice(i)))) { | ||||
|       i += str.codePointAt(i) < 65536 ? 1 : 2; | ||||
|     } | ||||
|     let rend, replacement = ''; | ||||
|     if (i === str.length) { | ||||
|       break; | ||||
|     } else if (str[i] === ':') { | ||||
|       if (!(() => { | ||||
|         rend = str.indexOf(':', i + 1) + 1; | ||||
|         if (!rend) return false; // no pair of ':' | ||||
|         const lt = str.indexOf('<', i + 1); | ||||
|         if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':' | ||||
|         const shortname = str.slice(i, rend); | ||||
|         // now got a replacee as ':shortname:' | ||||
|         // if you want additional emoji handler, add statements below which set replacement and return true. | ||||
|         if (shortname in customEmojis) { | ||||
|           const filename = allowAnimations ? customEmojis[shortname].url : customEmojis[shortname].static_url; | ||||
|           replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`; | ||||
|           return true; | ||||
|         } | ||||
|         return false; | ||||
|       })()) rend = ++i; | ||||
|     } else if (tag >= 0) { // <, & | ||||
|       rend = str.indexOf('>;'[tag], i + 1) + 1; | ||||
|       if (!rend) break; | ||||
|       i = rend; | ||||
|     } else { // matched to unicode emoji | ||||
|       const { filename, shortCode } = unicodeMapping[match]; | ||||
|       const title = shortCode ? `:${shortCode}:` : ''; | ||||
|       replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`; | ||||
|       rend = i + match.length; | ||||
|     } | ||||
|     rtn += str.slice(0, i) + replacement; | ||||
|     str = str.slice(rend); | ||||
|   } | ||||
|   return rtn + str; | ||||
| }; | ||||
|  | ||||
| export default emojify; | ||||
|  | ||||
| export const buildCustomEmojis = (customEmojis, overrideAllowAnimations = false) => { | ||||
|   const emojis = []; | ||||
|  | ||||
|   allowAnimations = overrideAllowAnimations; | ||||
|  | ||||
|   customEmojis.forEach(emoji => { | ||||
|     const shortcode = emoji.get('shortcode'); | ||||
|     const url       = allowAnimations ? emoji.get('url') : emoji.get('static_url'); | ||||
|     const name      = shortcode.replace(':', ''); | ||||
|  | ||||
|     emojis.push({ | ||||
|       id: name, | ||||
|       name, | ||||
|       short_names: [name], | ||||
|       text: '', | ||||
|       emoticons: [], | ||||
|       keywords: [name], | ||||
|       imageUrl: url, | ||||
|       custom: true, | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   return emojis; | ||||
| }; | ||||
							
								
								
									
										92
									
								
								app/javascript/mastodon/features/emoji/emoji_compressed.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								app/javascript/mastodon/features/emoji/emoji_compressed.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| // @preval | ||||
| // http://www.unicode.org/Public/emoji/5.0/emoji-test.txt | ||||
| // This file contains the compressed version of the emoji data from | ||||
| // both emoji_map.json and from emoji-mart's emojiIndex and data objects. | ||||
| // It's designed to be emitted in an array format to take up less space | ||||
| // over the wire. | ||||
|  | ||||
| const { unicodeToFilename } = require('./unicode_to_filename'); | ||||
| const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); | ||||
| const emojiMap         = require('./emoji_map.json'); | ||||
| const { emojiIndex } = require('emoji-mart'); | ||||
| const emojiMartData = require('emoji-mart/dist/data').default; | ||||
| const excluded       = ['®', '©', '™']; | ||||
| const skins          = ['🏻', '🏼', '🏽', '🏾', '🏿']; | ||||
| const shortcodeMap   = {}; | ||||
|  | ||||
| const shortCodesToEmojiData = {}; | ||||
| const emojisWithoutShortCodes = []; | ||||
|  | ||||
| Object.keys(emojiIndex.emojis).forEach(key => { | ||||
|   shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id; | ||||
| }); | ||||
|  | ||||
| const stripModifiers = unicode => { | ||||
|   skins.forEach(tone => { | ||||
|     unicode = unicode.replace(tone, ''); | ||||
|   }); | ||||
|  | ||||
|   return unicode; | ||||
| }; | ||||
|  | ||||
| Object.keys(emojiMap).forEach(key => { | ||||
|   if (excluded.includes(key)) { | ||||
|     delete emojiMap[key]; | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   const normalizedKey = stripModifiers(key); | ||||
|   let shortcode       = shortcodeMap[normalizedKey]; | ||||
|  | ||||
|   if (!shortcode) { | ||||
|     shortcode = shortcodeMap[normalizedKey + '\uFE0F']; | ||||
|   } | ||||
|  | ||||
|   const filename = emojiMap[key]; | ||||
|  | ||||
|   const filenameData = [key]; | ||||
|  | ||||
|   if (unicodeToFilename(key) !== filename) { | ||||
|     // filename can't be derived using unicodeToFilename | ||||
|     filenameData.push(filename); | ||||
|   } | ||||
|  | ||||
|   if (typeof shortcode === 'undefined') { | ||||
|     emojisWithoutShortCodes.push(filenameData); | ||||
|   } else { | ||||
|     if (!Array.isArray(shortCodesToEmojiData[shortcode])) { | ||||
|       shortCodesToEmojiData[shortcode] = [[]]; | ||||
|     } | ||||
|     shortCodesToEmojiData[shortcode][0].push(filenameData); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| Object.keys(emojiIndex.emojis).forEach(key => { | ||||
|   const { native } = emojiIndex.emojis[key]; | ||||
|   const { short_names, search, unified } = emojiMartData.emojis[key]; | ||||
|   if (short_names[0] !== key) { | ||||
|     throw new Error('The compresser expects the first short_code to be the ' + | ||||
|       'key. It may need to be rewritten if the emoji change such that this ' + | ||||
|       'is no longer the case.'); | ||||
|   } | ||||
|  | ||||
|   short_names.splice(0, 1); // first short name can be inferred from the key | ||||
|  | ||||
|   const searchData = [native, short_names, search]; | ||||
|   if (unicodeToUnifiedName(native) !== unified) { | ||||
|     // unified name can't be derived from unicodeToUnifiedName | ||||
|     searchData.push(unified); | ||||
|   } | ||||
|  | ||||
|   shortCodesToEmojiData[key].push(searchData); | ||||
| }); | ||||
|  | ||||
| // JSON.parse/stringify is to emulate what @preval is doing and avoid any | ||||
| // inconsistent behavior in dev mode | ||||
| module.exports = JSON.parse(JSON.stringify([ | ||||
|   shortCodesToEmojiData, | ||||
|   emojiMartData.skins, | ||||
|   emojiMartData.categories, | ||||
|   emojiMartData.short_names, | ||||
|   emojisWithoutShortCodes, | ||||
| ])); | ||||
							
								
								
									
										1
									
								
								app/javascript/mastodon/features/emoji/emoji_map.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/javascript/mastodon/features/emoji/emoji_map.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -0,0 +1,41 @@ | ||||
| // The output of this module is designed to mimic emoji-mart's | ||||
| // "data" object, such that we can use it for a light version of emoji-mart's | ||||
| // emojiIndex.search functionality. | ||||
| const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); | ||||
| const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed'); | ||||
|  | ||||
| const emojis = {}; | ||||
|  | ||||
| // decompress | ||||
| Object.keys(shortCodesToEmojiData).forEach((shortCode) => { | ||||
|   let [ | ||||
|     filenameData, // eslint-disable-line no-unused-vars | ||||
|     searchData, | ||||
|   ] = shortCodesToEmojiData[shortCode]; | ||||
|   let [ | ||||
|     native, | ||||
|     short_names, | ||||
|     search, | ||||
|     unified, | ||||
|   ] = searchData; | ||||
|  | ||||
|   if (!unified) { | ||||
|     // unified name can be derived from unicodeToUnifiedName | ||||
|     unified = unicodeToUnifiedName(native); | ||||
|   } | ||||
|  | ||||
|   short_names = [shortCode].concat(short_names); | ||||
|   emojis[shortCode] = { | ||||
|     native, | ||||
|     search, | ||||
|     short_names, | ||||
|     unified, | ||||
|   }; | ||||
| }); | ||||
|  | ||||
| module.exports = { | ||||
|   emojis, | ||||
|   skins, | ||||
|   categories, | ||||
|   short_names, | ||||
| }; | ||||
| @@ -0,0 +1,157 @@ | ||||
| // This code is largely borrowed from: | ||||
| // https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js | ||||
|  | ||||
| import data from './emoji_mart_data_light'; | ||||
| import { getData, getSanitizedData, intersect } from './emoji_utils'; | ||||
|  | ||||
| let originalPool = {}; | ||||
| let index = {}; | ||||
| let emojisList = {}; | ||||
| let emoticonsList = {}; | ||||
|  | ||||
| for (let emoji in data.emojis) { | ||||
|   let emojiData = data.emojis[emoji]; | ||||
|   let { short_names, emoticons } = emojiData; | ||||
|   let id = short_names[0]; | ||||
|  | ||||
|   if (emoticons) { | ||||
|     emoticons.forEach(emoticon => { | ||||
|       if (emoticonsList[emoticon]) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       emoticonsList[emoticon] = id; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   emojisList[id] = getSanitizedData(id); | ||||
|   originalPool[id] = emojiData; | ||||
| } | ||||
|  | ||||
| function addCustomToPool(custom, pool) { | ||||
|   custom.forEach((emoji) => { | ||||
|     let emojiId = emoji.id || emoji.short_names[0]; | ||||
|  | ||||
|     if (emojiId && !pool[emojiId]) { | ||||
|       pool[emojiId] = getData(emoji); | ||||
|       emojisList[emojiId] = getSanitizedData(emoji); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) { | ||||
|   addCustomToPool(custom, originalPool); | ||||
|  | ||||
|   maxResults = maxResults || 75; | ||||
|   include = include || []; | ||||
|   exclude = exclude || []; | ||||
|  | ||||
|   let results = null, | ||||
|     pool = originalPool; | ||||
|  | ||||
|   if (value.length) { | ||||
|     if (value === '-' || value === '-1') { | ||||
|       return [emojisList['-1']]; | ||||
|     } | ||||
|  | ||||
|     let values = value.toLowerCase().split(/[\s|,|\-|_]+/), | ||||
|       allResults = []; | ||||
|  | ||||
|     if (values.length > 2) { | ||||
|       values = [values[0], values[1]]; | ||||
|     } | ||||
|  | ||||
|     if (include.length || exclude.length) { | ||||
|       pool = {}; | ||||
|  | ||||
|       data.categories.forEach(category => { | ||||
|         let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true; | ||||
|         let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false; | ||||
|         if (!isIncluded || isExcluded) { | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]); | ||||
|       }); | ||||
|  | ||||
|       if (custom.length) { | ||||
|         let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true; | ||||
|         let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false; | ||||
|         if (customIsIncluded && !customIsExcluded) { | ||||
|           addCustomToPool(custom, pool); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     allResults = values.map((value) => { | ||||
|       let aPool = pool, | ||||
|         aIndex = index, | ||||
|         length = 0; | ||||
|  | ||||
|       for (let charIndex = 0; charIndex < value.length; charIndex++) { | ||||
|         const char = value[charIndex]; | ||||
|         length++; | ||||
|  | ||||
|         aIndex[char] = aIndex[char] || {}; | ||||
|         aIndex = aIndex[char]; | ||||
|  | ||||
|         if (!aIndex.results) { | ||||
|           let scores = {}; | ||||
|  | ||||
|           aIndex.results = []; | ||||
|           aIndex.pool = {}; | ||||
|  | ||||
|           for (let id in aPool) { | ||||
|             let emoji = aPool[id], | ||||
|               { search } = emoji, | ||||
|               sub = value.substr(0, length), | ||||
|               subIndex = search.indexOf(sub); | ||||
|  | ||||
|             if (subIndex !== -1) { | ||||
|               let score = subIndex + 1; | ||||
|               if (sub === id) score = 0; | ||||
|  | ||||
|               aIndex.results.push(emojisList[id]); | ||||
|               aIndex.pool[id] = emoji; | ||||
|  | ||||
|               scores[id] = score; | ||||
|             } | ||||
|           } | ||||
|  | ||||
|           aIndex.results.sort((a, b) => { | ||||
|             let aScore = scores[a.id], | ||||
|               bScore = scores[b.id]; | ||||
|  | ||||
|             return aScore - bScore; | ||||
|           }); | ||||
|         } | ||||
|  | ||||
|         aPool = aIndex.pool; | ||||
|       } | ||||
|  | ||||
|       return aIndex.results; | ||||
|     }).filter(a => a); | ||||
|  | ||||
|     if (allResults.length > 1) { | ||||
|       results = intersect.apply(null, allResults); | ||||
|     } else if (allResults.length) { | ||||
|       results = allResults[0]; | ||||
|     } else { | ||||
|       results = []; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (results) { | ||||
|     if (emojisToShowFilter) { | ||||
|       results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified)); | ||||
|     } | ||||
|  | ||||
|     if (results && results.length > maxResults) { | ||||
|       results = results.slice(0, maxResults); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return results; | ||||
| } | ||||
|  | ||||
| export { search }; | ||||
							
								
								
									
										7
									
								
								app/javascript/mastodon/features/emoji/emoji_picker.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								app/javascript/mastodon/features/emoji/emoji_picker.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| import Picker from 'emoji-mart/dist-es/components/picker'; | ||||
| import Emoji from 'emoji-mart/dist-es/components/emoji'; | ||||
|  | ||||
| export { | ||||
|   Picker, | ||||
|   Emoji, | ||||
| }; | ||||
| @@ -0,0 +1,35 @@ | ||||
| // A mapping of unicode strings to an object containing the filename | ||||
| // (i.e. the svg filename) and a shortCode intended to be shown | ||||
| // as a "title" attribute in an HTML element (aka tooltip). | ||||
|  | ||||
| const [ | ||||
|   shortCodesToEmojiData, | ||||
|   skins, // eslint-disable-line no-unused-vars | ||||
|   categories, // eslint-disable-line no-unused-vars | ||||
|   short_names, // eslint-disable-line no-unused-vars | ||||
|   emojisWithoutShortCodes, | ||||
| ] = require('./emoji_compressed'); | ||||
| const { unicodeToFilename } = require('./unicode_to_filename'); | ||||
|  | ||||
| // decompress | ||||
| const unicodeMapping = {}; | ||||
|  | ||||
| function processEmojiMapData(emojiMapData, shortCode) { | ||||
|   let [ native, filename ] = emojiMapData; | ||||
|   if (!filename) { | ||||
|     // filename name can be derived from unicodeToFilename | ||||
|     filename = unicodeToFilename(native); | ||||
|   } | ||||
|   unicodeMapping[native] = { | ||||
|     shortCode: shortCode, | ||||
|     filename: filename, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| Object.keys(shortCodesToEmojiData).forEach((shortCode) => { | ||||
|   let [ filenameData ] = shortCodesToEmojiData[shortCode]; | ||||
|   filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode)); | ||||
| }); | ||||
| emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData)); | ||||
|  | ||||
| module.exports = unicodeMapping; | ||||
							
								
								
									
										258
									
								
								app/javascript/mastodon/features/emoji/emoji_utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								app/javascript/mastodon/features/emoji/emoji_utils.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,258 @@ | ||||
| // This code is largely borrowed from: | ||||
| // https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js | ||||
|  | ||||
| import data from './emoji_mart_data_light'; | ||||
|  | ||||
| const buildSearch = (data) => { | ||||
|   const search = []; | ||||
|  | ||||
|   let addToSearch = (strings, split) => { | ||||
|     if (!strings) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     (Array.isArray(strings) ? strings : [strings]).forEach((string) => { | ||||
|       (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => { | ||||
|         s = s.toLowerCase(); | ||||
|  | ||||
|         if (search.indexOf(s) === -1) { | ||||
|           search.push(s); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   addToSearch(data.short_names, true); | ||||
|   addToSearch(data.name, true); | ||||
|   addToSearch(data.keywords, false); | ||||
|   addToSearch(data.emoticons, false); | ||||
|  | ||||
|   return search.join(','); | ||||
| }; | ||||
|  | ||||
| const _String = String; | ||||
|  | ||||
| const stringFromCodePoint = _String.fromCodePoint || function () { | ||||
|   let MAX_SIZE = 0x4000; | ||||
|   let codeUnits = []; | ||||
|   let highSurrogate; | ||||
|   let lowSurrogate; | ||||
|   let index = -1; | ||||
|   let length = arguments.length; | ||||
|   if (!length) { | ||||
|     return ''; | ||||
|   } | ||||
|   let result = ''; | ||||
|   while (++index < length) { | ||||
|     let codePoint = Number(arguments[index]); | ||||
|     if ( | ||||
|       !isFinite(codePoint) ||       // `NaN`, `+Infinity`, or `-Infinity` | ||||
|       codePoint < 0 ||              // not a valid Unicode code point | ||||
|       codePoint > 0x10FFFF ||       // not a valid Unicode code point | ||||
|       Math.floor(codePoint) !== codePoint // not an integer | ||||
|     ) { | ||||
|       throw RangeError('Invalid code point: ' + codePoint); | ||||
|     } | ||||
|     if (codePoint <= 0xFFFF) { // BMP code point | ||||
|       codeUnits.push(codePoint); | ||||
|     } else { // Astral code point; split in surrogate halves | ||||
|       // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae | ||||
|       codePoint -= 0x10000; | ||||
|       highSurrogate = (codePoint >> 10) + 0xD800; | ||||
|       lowSurrogate = (codePoint % 0x400) + 0xDC00; | ||||
|       codeUnits.push(highSurrogate, lowSurrogate); | ||||
|     } | ||||
|     if (index + 1 === length || codeUnits.length > MAX_SIZE) { | ||||
|       result += String.fromCharCode.apply(null, codeUnits); | ||||
|       codeUnits.length = 0; | ||||
|     } | ||||
|   } | ||||
|   return result; | ||||
| }; | ||||
|  | ||||
|  | ||||
| const _JSON = JSON; | ||||
|  | ||||
| const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/; | ||||
| const SKINS = [ | ||||
|   '1F3FA', '1F3FB', '1F3FC', | ||||
|   '1F3FD', '1F3FE', '1F3FF', | ||||
| ]; | ||||
|  | ||||
| function unifiedToNative(unified) { | ||||
|   let unicodes = unified.split('-'), | ||||
|     codePoints = unicodes.map((u) => `0x${u}`); | ||||
|  | ||||
|   return stringFromCodePoint.apply(null, codePoints); | ||||
| } | ||||
|  | ||||
| function sanitize(emoji) { | ||||
|   let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji, | ||||
|     id = emoji.id || short_names[0], | ||||
|     colons = `:${id}:`; | ||||
|  | ||||
|   if (custom) { | ||||
|     return { | ||||
|       id, | ||||
|       name, | ||||
|       colons, | ||||
|       emoticons, | ||||
|       custom, | ||||
|       imageUrl, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   if (skin_tone) { | ||||
|     colons += `:skin-tone-${skin_tone}:`; | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     id, | ||||
|     name, | ||||
|     colons, | ||||
|     emoticons, | ||||
|     unified: unified.toLowerCase(), | ||||
|     skin: skin_tone || (skin_variations ? 1 : null), | ||||
|     native: unifiedToNative(unified), | ||||
|   }; | ||||
| } | ||||
|  | ||||
| function getSanitizedData() { | ||||
|   return sanitize(getData(...arguments)); | ||||
| } | ||||
|  | ||||
| function getData(emoji, skin, set) { | ||||
|   let emojiData = {}; | ||||
|  | ||||
|   if (typeof emoji === 'string') { | ||||
|     let matches = emoji.match(COLONS_REGEX); | ||||
|  | ||||
|     if (matches) { | ||||
|       emoji = matches[1]; | ||||
|  | ||||
|       if (matches[2]) { | ||||
|         skin = parseInt(matches[2]); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (data.short_names.hasOwnProperty(emoji)) { | ||||
|       emoji = data.short_names[emoji]; | ||||
|     } | ||||
|  | ||||
|     if (data.emojis.hasOwnProperty(emoji)) { | ||||
|       emojiData = data.emojis[emoji]; | ||||
|     } | ||||
|   } else if (emoji.id) { | ||||
|     if (data.short_names.hasOwnProperty(emoji.id)) { | ||||
|       emoji.id = data.short_names[emoji.id]; | ||||
|     } | ||||
|  | ||||
|     if (data.emojis.hasOwnProperty(emoji.id)) { | ||||
|       emojiData = data.emojis[emoji.id]; | ||||
|       skin = skin || emoji.skin; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (!Object.keys(emojiData).length) { | ||||
|     emojiData = emoji; | ||||
|     emojiData.custom = true; | ||||
|  | ||||
|     if (!emojiData.search) { | ||||
|       emojiData.search = buildSearch(emoji); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   emojiData.emoticons = emojiData.emoticons || []; | ||||
|   emojiData.variations = emojiData.variations || []; | ||||
|  | ||||
|   if (emojiData.skin_variations && skin > 1 && set) { | ||||
|     emojiData = JSON.parse(_JSON.stringify(emojiData)); | ||||
|  | ||||
|     let skinKey = SKINS[skin - 1], | ||||
|       variationData = emojiData.skin_variations[skinKey]; | ||||
|  | ||||
|     if (!variationData.variations && emojiData.variations) { | ||||
|       delete emojiData.variations; | ||||
|     } | ||||
|  | ||||
|     if (variationData[`has_img_${set}`]) { | ||||
|       emojiData.skin_tone = skin; | ||||
|  | ||||
|       for (let k in variationData) { | ||||
|         let v = variationData[k]; | ||||
|         emojiData[k] = v; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (emojiData.variations && emojiData.variations.length) { | ||||
|     emojiData = JSON.parse(_JSON.stringify(emojiData)); | ||||
|     emojiData.unified = emojiData.variations.shift(); | ||||
|   } | ||||
|  | ||||
|   return emojiData; | ||||
| } | ||||
|  | ||||
| function uniq(arr) { | ||||
|   return arr.reduce((acc, item) => { | ||||
|     if (acc.indexOf(item) === -1) { | ||||
|       acc.push(item); | ||||
|     } | ||||
|     return acc; | ||||
|   }, []); | ||||
| } | ||||
|  | ||||
| function intersect(a, b) { | ||||
|   const uniqA = uniq(a); | ||||
|   const uniqB = uniq(b); | ||||
|  | ||||
|   return uniqA.filter(item => uniqB.indexOf(item) >= 0); | ||||
| } | ||||
|  | ||||
| function deepMerge(a, b) { | ||||
|   let o = {}; | ||||
|  | ||||
|   for (let key in a) { | ||||
|     let originalValue = a[key], | ||||
|       value = originalValue; | ||||
|  | ||||
|     if (b.hasOwnProperty(key)) { | ||||
|       value = b[key]; | ||||
|     } | ||||
|  | ||||
|     if (typeof value === 'object') { | ||||
|       value = deepMerge(originalValue, value); | ||||
|     } | ||||
|  | ||||
|     o[key] = value; | ||||
|   } | ||||
|  | ||||
|   return o; | ||||
| } | ||||
|  | ||||
| // https://github.com/sonicdoe/measure-scrollbar | ||||
| function measureScrollbar() { | ||||
|   const div = document.createElement('div'); | ||||
|  | ||||
|   div.style.width = '100px'; | ||||
|   div.style.height = '100px'; | ||||
|   div.style.overflow = 'scroll'; | ||||
|   div.style.position = 'absolute'; | ||||
|   div.style.top = '-9999px'; | ||||
|  | ||||
|   document.body.appendChild(div); | ||||
|   const scrollbarWidth = div.offsetWidth - div.clientWidth; | ||||
|   document.body.removeChild(div); | ||||
|  | ||||
|   return scrollbarWidth; | ||||
| } | ||||
|  | ||||
| export { | ||||
|   getData, | ||||
|   getSanitizedData, | ||||
|   uniq, | ||||
|   intersect, | ||||
|   deepMerge, | ||||
|   unifiedToNative, | ||||
|   measureScrollbar, | ||||
| }; | ||||
| @@ -0,0 +1,26 @@ | ||||
| // taken from: | ||||
| // https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866 | ||||
| exports.unicodeToFilename = (str) => { | ||||
|   let result = ''; | ||||
|   let charCode = 0; | ||||
|   let p = 0; | ||||
|   let i = 0; | ||||
|   while (i < str.length) { | ||||
|     charCode = str.charCodeAt(i++); | ||||
|     if (p) { | ||||
|       if (result.length > 0) { | ||||
|         result += '-'; | ||||
|       } | ||||
|       result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16); | ||||
|       p = 0; | ||||
|     } else if (0xD800 <= charCode && charCode <= 0xDBFF) { | ||||
|       p = charCode; | ||||
|     } else { | ||||
|       if (result.length > 0) { | ||||
|         result += '-'; | ||||
|       } | ||||
|       result += charCode.toString(16); | ||||
|     } | ||||
|   } | ||||
|   return result; | ||||
| }; | ||||
| @@ -0,0 +1,17 @@ | ||||
| function padLeft(str, num) { | ||||
|   while (str.length < num) { | ||||
|     str = '0' + str; | ||||
|   } | ||||
|   return str; | ||||
| } | ||||
|  | ||||
| exports.unicodeToUnifiedName = (str) => { | ||||
|   let output = ''; | ||||
|   for (let i = 0; i < str.length; i += 2) { | ||||
|     if (i > 0) { | ||||
|       output += '-'; | ||||
|     } | ||||
|     output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4); | ||||
|   } | ||||
|   return output; | ||||
| }; | ||||
| @@ -11,7 +11,7 @@ import ColumnBackButton from '../../components/column_back_button'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
|  | ||||
| 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) | ||||
| @@ -24,12 +24,12 @@ export default class Favourites extends ImmutablePureComponent { | ||||
|   }; | ||||
|  | ||||
|   componentWillMount () { | ||||
|     this.props.dispatch(fetchFavourites(Number(this.props.params.statusId))); | ||||
|     this.props.dispatch(fetchFavourites(this.props.params.statusId)); | ||||
|   } | ||||
|  | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|     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'; | ||||
|  | ||||
| const mapStateToProps = (state, props) => ({ | ||||
|   accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items']), | ||||
|   hasMore: !!state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'next']), | ||||
|   accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']), | ||||
|   hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']), | ||||
| }); | ||||
|  | ||||
| @connect(mapStateToProps) | ||||
| @@ -32,14 +32,14 @@ export default class Followers extends ImmutablePureComponent { | ||||
|   }; | ||||
|  | ||||
|   componentWillMount () { | ||||
|     this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); | ||||
|     this.props.dispatch(fetchFollowers(Number(this.props.params.accountId))); | ||||
|     this.props.dispatch(fetchAccount(this.props.params.accountId)); | ||||
|     this.props.dispatch(fetchFollowers(this.props.params.accountId)); | ||||
|   } | ||||
|  | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { | ||||
|       this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); | ||||
|       this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId))); | ||||
|       this.props.dispatch(fetchAccount(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; | ||||
|  | ||||
|     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) => { | ||||
|     e.preventDefault(); | ||||
|     this.props.dispatch(expandFollowers(Number(this.props.params.accountId))); | ||||
|     this.props.dispatch(expandFollowers(this.props.params.accountId)); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|   | ||||
| @@ -17,8 +17,8 @@ import ColumnBackButton from '../../components/column_back_button'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
|  | ||||
| const mapStateToProps = (state, props) => ({ | ||||
|   accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items']), | ||||
|   hasMore: !!state.getIn(['user_lists', 'following', Number(props.params.accountId), 'next']), | ||||
|   accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']), | ||||
|   hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']), | ||||
| }); | ||||
|  | ||||
| @connect(mapStateToProps) | ||||
| @@ -32,14 +32,14 @@ export default class Following extends ImmutablePureComponent { | ||||
|   }; | ||||
|  | ||||
|   componentWillMount () { | ||||
|     this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); | ||||
|     this.props.dispatch(fetchFollowing(Number(this.props.params.accountId))); | ||||
|     this.props.dispatch(fetchAccount(this.props.params.accountId)); | ||||
|     this.props.dispatch(fetchFollowing(this.props.params.accountId)); | ||||
|   } | ||||
|  | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { | ||||
|       this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); | ||||
|       this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId))); | ||||
|       this.props.dispatch(fetchAccount(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; | ||||
|  | ||||
|     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) => { | ||||
|     e.preventDefault(); | ||||
|     this.props.dispatch(expandFollowing(Number(this.props.params.accountId))); | ||||
|     this.props.dispatch(expandFollowing(this.props.params.accountId)); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header'; | ||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import ColumnSettingsContainer from './containers/column_settings_container'; | ||||
| import Link from 'react-router-dom/Link'; | ||||
| import { Link } from 'react-router-dom'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   title: { id: 'column.home', defaultMessage: 'Home' }, | ||||
|   | ||||
| @@ -6,61 +6,126 @@ import AccountContainer from '../../../containers/account_container'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import Permalink from '../../../components/permalink'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { HotKeys } from 'react-hotkeys'; | ||||
|  | ||||
| export default class Notification extends ImmutablePureComponent { | ||||
|  | ||||
|   static contextTypes = { | ||||
|     router: PropTypes.object, | ||||
|   }; | ||||
|  | ||||
|   static propTypes = { | ||||
|     notification: ImmutablePropTypes.map.isRequired, | ||||
|     hidden: PropTypes.bool, | ||||
|     onMoveUp: PropTypes.func.isRequired, | ||||
|     onMoveDown: PropTypes.func.isRequired, | ||||
|     onMention: PropTypes.func.isRequired, | ||||
|   }; | ||||
|  | ||||
|   handleMoveUp = () => { | ||||
|     const { notification, onMoveUp } = this.props; | ||||
|     onMoveUp(notification.get('id')); | ||||
|   } | ||||
|  | ||||
|   handleMoveDown = () => { | ||||
|     const { notification, onMoveDown } = this.props; | ||||
|     onMoveDown(notification.get('id')); | ||||
|   } | ||||
|  | ||||
|   handleOpen = () => { | ||||
|     const { notification } = this.props; | ||||
|  | ||||
|     if (notification.get('status')) { | ||||
|       this.context.router.history.push(`/statuses/${notification.get('status')}`); | ||||
|     } else { | ||||
|       this.handleOpenProfile(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleOpenProfile = () => { | ||||
|     const { notification } = this.props; | ||||
|     this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`); | ||||
|   } | ||||
|  | ||||
|   handleMention = e => { | ||||
|     e.preventDefault(); | ||||
|  | ||||
|     const { notification, onMention } = this.props; | ||||
|     onMention(notification.get('account'), this.context.router.history); | ||||
|   } | ||||
|  | ||||
|   getHandlers () { | ||||
|     return { | ||||
|       moveUp: this.handleMoveUp, | ||||
|       moveDown: this.handleMoveDown, | ||||
|       open: this.handleOpen, | ||||
|       openProfile: this.handleOpenProfile, | ||||
|       mention: this.handleMention, | ||||
|       reply: this.handleMention, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   renderFollow (account, link) { | ||||
|     return ( | ||||
|       <div className='notification notification-follow'> | ||||
|         <div className='notification__message'> | ||||
|           <div className='notification__favourite-icon-wrapper'> | ||||
|             <i className='fa fa-fw fa-user-plus' /> | ||||
|       <HotKeys handlers={this.getHandlers()}> | ||||
|         <div className='notification notification-follow focusable' tabIndex='0'> | ||||
|           <div className='notification__message'> | ||||
|             <div className='notification__favourite-icon-wrapper'> | ||||
|               <i className='fa fa-fw fa-user-plus' /> | ||||
|             </div> | ||||
|  | ||||
|             <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> | ||||
|           </div> | ||||
|  | ||||
|           <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> | ||||
|           <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> | ||||
|         </div> | ||||
|  | ||||
|         <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> | ||||
|       </div> | ||||
|       </HotKeys> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   renderMention (notification) { | ||||
|     return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />; | ||||
|     return ( | ||||
|       <StatusContainer | ||||
|         id={notification.get('status')} | ||||
|         withDismiss | ||||
|         hidden={this.props.hidden} | ||||
|         onMoveDown={this.handleMoveDown} | ||||
|         onMoveUp={this.handleMoveUp} | ||||
|       /> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   renderFavourite (notification, link) { | ||||
|     return ( | ||||
|       <div className='notification notification-favourite'> | ||||
|         <div className='notification__message'> | ||||
|           <div className='notification__favourite-icon-wrapper'> | ||||
|             <i className='fa fa-fw fa-star star-icon' /> | ||||
|       <HotKeys handlers={this.getHandlers()}> | ||||
|         <div className='notification notification-favourite focusable' tabIndex='0'> | ||||
|           <div className='notification__message'> | ||||
|             <div className='notification__favourite-icon-wrapper'> | ||||
|               <i className='fa fa-fw fa-star star-icon' /> | ||||
|             </div> | ||||
|             <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> | ||||
|           </div> | ||||
|           <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> | ||||
|         </div> | ||||
|  | ||||
|         <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> | ||||
|       </div> | ||||
|           <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> | ||||
|         </div> | ||||
|       </HotKeys> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   renderReblog (notification, link) { | ||||
|     return ( | ||||
|       <div className='notification notification-reblog'> | ||||
|         <div className='notification__message'> | ||||
|           <div className='notification__favourite-icon-wrapper'> | ||||
|             <i className='fa fa-fw fa-retweet' /> | ||||
|       <HotKeys handlers={this.getHandlers()}> | ||||
|         <div className='notification notification-reblog focusable' tabIndex='0'> | ||||
|           <div className='notification__message'> | ||||
|             <div className='notification__favourite-icon-wrapper'> | ||||
|               <i className='fa fa-fw fa-retweet' /> | ||||
|             </div> | ||||
|             <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> | ||||
|           </div> | ||||
|           <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> | ||||
|         </div> | ||||
|  | ||||
|         <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> | ||||
|       </div> | ||||
|           <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> | ||||
|         </div> | ||||
|       </HotKeys> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { connect } from 'react-redux'; | ||||
| import { makeGetNotification } from '../../../selectors'; | ||||
| import Notification from '../components/notification'; | ||||
| import { mentionCompose } from '../../../actions/compose'; | ||||
|  | ||||
| const makeMapStateToProps = () => { | ||||
|   const getNotification = makeGetNotification(); | ||||
| @@ -12,4 +13,10 @@ const makeMapStateToProps = () => { | ||||
|   return mapStateToProps; | ||||
| }; | ||||
|  | ||||
| export default connect(makeMapStateToProps)(Notification); | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|   onMention: (account, router) => { | ||||
|     dispatch(mentionCompose(account, router)); | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| export default connect(makeMapStateToProps, mapDispatchToProps)(Notification); | ||||
|   | ||||
| @@ -86,6 +86,24 @@ export default class Notifications extends React.PureComponent { | ||||
|     this.column = c; | ||||
|   } | ||||
|  | ||||
|   handleMoveUp = id => { | ||||
|     const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1; | ||||
|     this._selectChild(elementIndex); | ||||
|   } | ||||
|  | ||||
|   handleMoveDown = id => { | ||||
|     const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1; | ||||
|     this._selectChild(elementIndex); | ||||
|   } | ||||
|  | ||||
|   _selectChild (index) { | ||||
|     const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); | ||||
|  | ||||
|     if (element) { | ||||
|       element.focus(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; | ||||
|     const pinned = !!columnId; | ||||
| @@ -96,7 +114,15 @@ export default class Notifications extends React.PureComponent { | ||||
|     if (isLoading && this.scrollableContent) { | ||||
|       scrollableContent = this.scrollableContent; | ||||
|     } else if (notifications.size > 0 || hasMore) { | ||||
|       scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />); | ||||
|       scrollableContent = notifications.map((item) => ( | ||||
|         <NotificationContainer | ||||
|           key={item.get('id')} | ||||
|           notification={item} | ||||
|           accountId={item.get('account')} | ||||
|           onMoveUp={this.handleMoveUp} | ||||
|           onMoveDown={this.handleMoveDown} | ||||
|         /> | ||||
|       )); | ||||
|     } else { | ||||
|       scrollableContent = null; | ||||
|     } | ||||
|   | ||||
| @@ -11,7 +11,7 @@ import ColumnBackButton from '../../components/column_back_button'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
|  | ||||
| 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) | ||||
| @@ -24,12 +24,12 @@ export default class Reblogs extends ImmutablePureComponent { | ||||
|   }; | ||||
|  | ||||
|   componentWillMount () { | ||||
|     this.props.dispatch(fetchReblogs(Number(this.props.params.statusId))); | ||||
|     this.props.dispatch(fetchReblogs(this.props.params.statusId)); | ||||
|   } | ||||
|  | ||||
|   componentWillReceiveProps(nextProps) { | ||||
|     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)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,70 @@ | ||||
| import React from 'react'; | ||||
| import { connect } from 'react-redux'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import StatusListContainer from '../../ui/containers/status_list_container'; | ||||
| import { | ||||
|   refreshHashtagTimeline, | ||||
|   expandHashtagTimeline, | ||||
| } from '../../../actions/timelines'; | ||||
| import Column from '../../../components/column'; | ||||
| import ColumnHeader from '../../../components/column_header'; | ||||
|  | ||||
| @connect() | ||||
| export default class HashtagTimeline extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     dispatch: PropTypes.func.isRequired, | ||||
|     hashtag: PropTypes.string.isRequired, | ||||
|   }; | ||||
|  | ||||
|   handleHeaderClick = () => { | ||||
|     this.column.scrollTop(); | ||||
|   } | ||||
|  | ||||
|   setRef = c => { | ||||
|     this.column = c; | ||||
|   } | ||||
|  | ||||
|   componentDidMount () { | ||||
|     const { dispatch, hashtag } = this.props; | ||||
|  | ||||
|     dispatch(refreshHashtagTimeline(hashtag)); | ||||
|  | ||||
|     this.polling = setInterval(() => { | ||||
|       dispatch(refreshHashtagTimeline(hashtag)); | ||||
|     }, 10000); | ||||
|   } | ||||
|  | ||||
|   componentWillUnmount () { | ||||
|     if (typeof this.polling !== 'undefined') { | ||||
|       clearInterval(this.polling); | ||||
|       this.polling = null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleLoadMore = () => { | ||||
|     this.props.dispatch(expandHashtagTimeline(this.props.hashtag)); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { hashtag } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <Column ref={this.setRef}> | ||||
|         <ColumnHeader | ||||
|           icon='hashtag' | ||||
|           title={hashtag} | ||||
|           onClick={this.handleHeaderClick} | ||||
|         /> | ||||
|  | ||||
|         <StatusListContainer | ||||
|           trackScroll={false} | ||||
|           scrollKey='standalone_hashtag_timeline' | ||||
|           timelineId={`hashtag:${hashtag}`} | ||||
|           loadMore={this.handleLoadMore} | ||||
|         /> | ||||
|       </Column> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user