Compare commits
	
		
			266 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 4819e2913d | ||
|  | 72e662bb0d | ||
|  | 7d7844a47f | ||
|  | f2cbfb2eb3 | ||
|  | 3f333a8d31 | ||
|  | bc077018b8 | ||
|  | 90712d4293 | ||
|  | 6867681c7c | ||
|  | bdc8b4fd91 | ||
|  | 2ff7146b6d | ||
|  | c7908e2d09 | ||
|  | c9d04f1c39 | ||
|  | 9e15eeec63 | ||
|  | 3c45d3963a | ||
|  | baa8b82179 | ||
|  | 4b460bc571 | ||
|  | 7ca173be47 | ||
|  | 1ae5d49a71 | ||
|  | a12572e074 | ||
|  | dabc309ca3 | ||
|  | 1caf11ddcc | ||
|  | 95f018a3d4 | ||
|  | a4caa7eb62 | ||
|  | 7c2d84910c | ||
|  | b00cc4b9bd | ||
|  | dd6ede554f | ||
|  | 6859d4c028 | ||
|  | 7d853b514a | ||
|  | 85c7c42098 | ||
|  | 8185f98872 | ||
|  | 5264496240 | ||
|  | be75b13d68 | ||
|  | 9417c9bb8f | ||
|  | 11bddd31ce | ||
|  | dd5cb5085c | ||
|  | e7adbf572a | ||
|  | 13ffa3c59e | ||
|  | aec5097d44 | ||
|  | 1646f622a5 | ||
|  | e0cda4a851 | ||
|  | d8d2a54741 | ||
|  | fa21d004c7 | ||
|  | 6994664a13 | ||
|  | be7ffa2d75 | ||
|  | e821c00e74 | ||
|  | 9b994c4aee | ||
|  | 4c3dd0b254 | ||
|  | 672df4ecc0 | ||
|  | aefb4719bc | ||
|  | 4d67bf18fe | ||
|  | f09a250a7c | ||
|  | 9b50a9dd83 | ||
|  | 2293466edd | ||
|  | b6f3869f8d | ||
|  | 09cffaaf04 | ||
|  | 334a633c2a | ||
|  | 8b12e3cc7f | ||
|  | d3f46a77c3 | ||
|  | a789315361 | ||
|  | 579c7a88e0 | ||
|  | 8538170c2d | ||
|  | 249bdc169c | ||
|  | 9dd8dff683 | ||
|  | a187dcefa1 | ||
|  | 5d170587e3 | ||
|  | 37fdddd927 | ||
|  | 6ec1aa372d | ||
|  | 2c3544eedd | ||
|  | d3b6746173 | ||
|  | 2a5d1d5a1b | ||
|  | 6a4e2db661 | ||
|  | bfa7f9ebf2 | ||
|  | 8cc1ed3c55 | ||
|  | 5e1e466da0 | ||
|  | cfe39fb58d | ||
|  | a0294c8880 | ||
|  | ba8fb2fd0f | ||
|  | 6fd2e8c3c5 | ||
|  | 15963a15c6 | ||
|  | 1b5806b744 | ||
|  | 1b1e025b41 | ||
|  | ab9f1b6e50 | ||
|  | b767eb7ff8 | ||
|  | 0b32338e3f | ||
|  | e482595a5d | ||
|  | 9c04fadec9 | ||
|  | 390bfec6da | ||
|  | c2980d5b17 | ||
|  | a75aa62f5b | ||
|  | 8fd8f81ae7 | ||
|  | 921cf3e9c8 | ||
|  | 7dc5035031 | ||
|  | 2305f7c391 | ||
|  | ff7d02b236 | ||
|  | 1a0df58878 | ||
|  | 74437c6bff | ||
|  | 504737e860 | ||
|  | af2d22f88c | ||
|  | 9a5ae09620 | ||
|  | f7937d903c | ||
|  | 6b2be5dbfb | ||
|  | 69957ed10a | ||
|  | d1a78eba15 | ||
|  | 2db9ccaf3e | ||
|  | cecf204bbb | ||
|  | fec13735a7 | ||
|  | 7b8f262840 | ||
|  | 3f51a22d3b | ||
|  | 39e7a763ff | ||
|  | e95bdec7c5 | ||
|  | fcca31350d | ||
|  | ee72a39641 | ||
|  | f59ed3a4fa | ||
|  | 7be620775e | ||
|  | 4c76402ba1 | ||
|  | 9958eba356 | ||
|  | 0827c09c44 | ||
|  | 938cd2875b | ||
|  | 7876aed134 | ||
|  | ce9a5f358e | ||
|  | 8f527bd588 | ||
|  | 07994eed00 | ||
|  | bab9afaa09 | ||
|  | 15093f9113 | ||
|  | f92d991e52 | ||
|  | 26402ee2cb | ||
|  | f095a9f8a5 | ||
|  | 0d5d11eeff | ||
|  | 0397c58b61 | ||
|  | 884b085f53 | ||
|  | 2a2698e450 | ||
|  | 8ecfdd8795 | ||
|  | 00840f4f2e | ||
|  | 1cebfed23e | ||
|  | 649a20ab46 | ||
|  | 3ac7b353f8 | ||
|  | 21bb4a6c3b | ||
|  | c2af138113 | ||
|  | fb8aa2b3ba | ||
|  | 00f9f16f94 | ||
|  | 18f69fb964 | ||
|  | 04c3fb2189 | ||
|  | 7c03e59338 | ||
|  | b88635202f | ||
|  | 409051c22c | ||
|  | 9caa90025f | ||
|  | c5157ef07b | ||
|  | f72ed21cd6 | ||
|  | da172a8b1b | ||
|  | cf615abbf9 | ||
|  | b01a19fe39 | ||
|  | c66fe2aeba | ||
|  | fbe1115114 | ||
|  | e4c761f902 | ||
|  | 2c6a85832c | ||
|  | 829e2e8c5d | ||
|  | 8a716c9e96 | ||
|  | 80393a23d0 | ||
|  | 8d23667536 | ||
|  | 9846806cb5 | ||
|  | 760cfe328f | ||
|  | c1b086a538 | ||
|  | 696c2c6f2f | ||
|  | 5927b43c0f | ||
|  | 871c0d251a | ||
|  | 11a7507318 | ||
|  | d63de55ef8 | ||
|  | 72bb3e03fd | ||
|  | f391a4673a | ||
|  | 143b77e10d | ||
|  | 4cbb638604 | ||
|  | 3534e115e5 | ||
|  | ea958cae7f | ||
|  | 10e9a9a3f9 | ||
|  | 6e9eda5331 | ||
|  | 4c23544714 | ||
|  | 74e5078795 | ||
|  | 110227ac5e | ||
|  | f26758dc01 | ||
|  | 23792f5a7c | ||
|  | fe5b66aa08 | ||
|  | 93d4192a67 | ||
|  | d5acf4275f | ||
|  | 412ea87306 | ||
|  | 774b8661bc | ||
|  | c7d2619ab1 | ||
|  | 2edfdab6e6 | ||
|  | 4edf9d849f | ||
|  | 10489b4e4a | ||
|  | 40c45f5dd9 | ||
|  | efec02f153 | ||
|  | 116b8a6363 | ||
|  | ad892dbc0c | ||
|  | 075d6a1e13 | ||
|  | 54a04e3658 | ||
|  | 462c30e26c | ||
|  | 2a04bdc87a | ||
|  | ca7ea1aba9 | ||
|  | f814661fca | ||
|  | e33c28a6d8 | ||
|  | e120d09c98 | ||
|  | 4fcbb1f838 | ||
|  | a855956185 | ||
|  | 5b9ae7981e | ||
|  | 5f22c0189d | ||
|  | 26d26644ac | ||
|  | 3c6503038e | ||
|  | 96e9ed13de | ||
|  | 6df8bd277b | ||
|  | 4e75f0d889 | ||
|  | a2aeacbfee | ||
|  | b7370ac8ba | ||
|  | ccdd5a9576 | ||
|  | 40be4ea239 | ||
|  | 3d47154c20 | ||
|  | d0a217eb92 | ||
|  | 81c1303cd6 | ||
|  | 4b8e4dca26 | ||
|  | 10cdad3e7d | ||
|  | d9a1fb134a | ||
|  | fdea173237 | ||
|  | 4e1bf082ce | ||
|  | b1c8a702a4 | ||
|  | 820099813f | ||
|  | 2ebe4ff568 | ||
|  | 61bfce5aa9 | ||
|  | dd7ef0dc41 | ||
|  | dcbc1af38a | ||
|  | 81c41d8681 | ||
|  | ec3be87a2b | ||
|  | b42c018bb8 | ||
|  | c9fd6f386c | ||
|  | 1b5d26735e | ||
|  | a3e53bd442 | ||
|  | 8eb6d171e6 | ||
|  | 5942347407 | ||
|  | 22db947225 | ||
|  | 5d408fd9aa | ||
|  | 47579ec58c | ||
|  | 3363a05539 | ||
|  | 87f10d476c | ||
|  | 41c3389d76 | ||
|  | e7a5a188ef | ||
|  | 71384b2ef9 | ||
|  | d1d465347a | ||
|  | 5eba129b0f | ||
|  | 021a83ead4 | ||
|  | 5ee45fa571 | ||
|  | 61a06eb328 | ||
|  | df605f0f8b | ||
|  | 029786442a | ||
|  | 9d1f8b9d6a | ||
|  | 400616813e | ||
|  | 724be2d5fe | ||
|  | 76da330155 | ||
|  | ab60aa2266 | ||
|  | 0bbd5789b5 | ||
|  | fae71b653a | ||
|  | dfcd2834f9 | ||
|  | 09e86ef90b | ||
|  | 9ba7d526a0 | ||
|  | 94e233e7b2 | ||
|  | ac53736814 | ||
|  | 8c0e78ae43 | ||
|  | 26ab702304 | ||
|  | 7ef8482568 | 
| @@ -26,7 +26,7 @@ LOCAL_HTTPS=true | |||||||
| # ALTERNATE_DOMAINS=example1.com,example2.com | # ALTERNATE_DOMAINS=example1.com,example2.com | ||||||
|  |  | ||||||
| # Application secrets | # Application secrets | ||||||
| # Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) | # Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) | ||||||
| PAPERCLIP_SECRET= | PAPERCLIP_SECRET= | ||||||
| SECRET_KEY_BASE= | SECRET_KEY_BASE= | ||||||
| OTP_SECRET= | OTP_SECRET= | ||||||
| @@ -36,7 +36,7 @@ OTP_SECRET= | |||||||
| # You should only generate this once per instance. If you later decide to change it, all push subscription will | # You should only generate this once per instance. If you later decide to change it, all push subscription will | ||||||
| # be invalidated, requiring the users to access the website again to resubscribe. | # be invalidated, requiring the users to access the website again to resubscribe. | ||||||
| # | # | ||||||
| # Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) | # Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) | ||||||
| # | # | ||||||
| # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html | # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html | ||||||
| VAPID_PRIVATE_KEY= | VAPID_PRIVATE_KEY= | ||||||
| @@ -98,6 +98,15 @@ SMTP_FROM_ADDRESS=notifications@example.com | |||||||
| # S3_ENDPOINT= | # S3_ENDPOINT= | ||||||
| # S3_SIGNATURE_VERSION= | # S3_SIGNATURE_VERSION= | ||||||
|  |  | ||||||
|  | # Swift (optional) | ||||||
|  | # SWIFT_ENABLED=true | ||||||
|  | # SWIFT_USERNAME= | ||||||
|  | # SWIFT_TENANT= | ||||||
|  | # SWIFT_PASSWORD= | ||||||
|  | # SWIFT_AUTH_URL= | ||||||
|  | # SWIFT_CONTAINER= | ||||||
|  | # SWIFT_OBJECT_URL= | ||||||
|  |  | ||||||
| # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front | # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front | ||||||
| # S3_CLOUDFRONT_HOST= | # S3_CLOUDFRONT_HOST= | ||||||
|  |  | ||||||
|   | |||||||
| @@ -49,6 +49,7 @@ rules: | |||||||
|   - warn |   - warn | ||||||
|   - allow: |   - allow: | ||||||
|     - error |     - error | ||||||
|  |     - warn | ||||||
|   no-fallthrough: error |   no-fallthrough: error | ||||||
|   no-irregular-whitespace: error |   no-irregular-whitespace: error | ||||||
|   no-mixed-spaces-and-tabs: warn |   no-mixed-spaces-and-tabs: warn | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ AllCops: | |||||||
|   - 'node_modules/**/*' |   - 'node_modules/**/*' | ||||||
|   - 'Vagrantfile' |   - 'Vagrantfile' | ||||||
|   - 'vendor/**/*' |   - 'vendor/**/*' | ||||||
|  |   - 'lib/json_ld/*' | ||||||
|  |  | ||||||
| Bundler/OrderedGems: | Bundler/OrderedGems: | ||||||
|   Enabled: false |   Enabled: false | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ cache: | |||||||
|   - node_modules |   - node_modules | ||||||
|   - public/assets |   - public/assets | ||||||
|   - public/packs-test |   - public/packs-test | ||||||
|  |   - tmp/cache/babel-loader | ||||||
| dist: trusty | dist: trusty | ||||||
| sudo: required | sudo: required | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										15
									
								
								CODEOWNERS
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								CODEOWNERS
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | # CODEOWNERS for tootsuite/mastodon | ||||||
|  |  | ||||||
|  | # Translators | ||||||
|  | # To add translator, copy these lines, replace `fr` with appropriate language code and replace `@żelipapą` with user's GitHub nickname preceded by `@` sign or e-mail address. | ||||||
|  | # /app/javascript/mastodon/locales/fr.json @żelipapą | ||||||
|  | # /app/views/user_mailer/*.fr.html.erb @żelipapą | ||||||
|  | # /app/views/user_mailer/*.fr.text.erb @żelipapą | ||||||
|  | # /config/locales/*.fr.yml @żelipapą | ||||||
|  | # /config/locales/fr.yml @żelipapą | ||||||
|  |  | ||||||
|  | /app/javascript/mastodon/locales/pl.json @m4sk1n | ||||||
|  | /app/views/user_mailer/*.pl.html.erb @m4sk1n | ||||||
|  | /app/views/user_mailer/*.pl.text.erb @m4sk1n | ||||||
|  | /config/locales/*.pl.yml @m4sk1n | ||||||
|  | /config/locales/pl.yml @m4sk1n | ||||||
							
								
								
									
										36
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										36
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -1,4 +1,4 @@ | |||||||
| FROM ruby:2.4.1-alpine | FROM ruby:2.4.1-alpine3.6 | ||||||
|  |  | ||||||
| LABEL maintainer="https://github.com/tootsuite/mastodon" \ | LABEL maintainer="https://github.com/tootsuite/mastodon" \ | ||||||
|       description="A GNU Social-compatible microblogging server" |       description="A GNU Social-compatible microblogging server" | ||||||
| @@ -7,19 +7,19 @@ ENV UID=991 GID=991 \ | |||||||
|     RAILS_SERVE_STATIC_FILES=true \ |     RAILS_SERVE_STATIC_FILES=true \ | ||||||
|     RAILS_ENV=production NODE_ENV=production |     RAILS_ENV=production NODE_ENV=production | ||||||
|  |  | ||||||
|  | ARG LIBICONV_VERSION=1.15 | ||||||
|  | ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 | ||||||
|  |  | ||||||
| EXPOSE 3000 4000 | EXPOSE 3000 4000 | ||||||
|  |  | ||||||
| WORKDIR /mastodon | WORKDIR /mastodon | ||||||
|  |  | ||||||
| RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories \ | RUN apk -U upgrade \ | ||||||
|  && echo "@edge https://nl.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories \ |  | ||||||
|  && apk -U upgrade \ |  | ||||||
|  && apk add -t build-dependencies \ |  && apk add -t build-dependencies \ | ||||||
|     build-base \ |     build-base \ | ||||||
|     icu-dev \ |     icu-dev \ | ||||||
|     libidn-dev \ |     libidn-dev \ | ||||||
|     libxml2-dev \ |     libtool \ | ||||||
|     libxslt-dev \ |  | ||||||
|     postgresql-dev \ |     postgresql-dev \ | ||||||
|     protobuf-dev \ |     protobuf-dev \ | ||||||
|     python \ |     python \ | ||||||
| @@ -29,23 +29,33 @@ RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/reposit | |||||||
|     file \ |     file \ | ||||||
|     git \ |     git \ | ||||||
|     icu-libs \ |     icu-libs \ | ||||||
|     imagemagick@edge \ |     imagemagick \ | ||||||
|     libidn \ |     libidn \ | ||||||
|     libpq \ |     libpq \ | ||||||
|     libxml2 \ |     nodejs-npm \ | ||||||
|     libxslt \ |     nodejs \ | ||||||
|     nodejs-npm@edge \ |  | ||||||
|     nodejs@edge \ |  | ||||||
|     protobuf \ |     protobuf \ | ||||||
|     su-exec \ |     su-exec \ | ||||||
|     tini \ |     tini \ | ||||||
|     yarn@edge \ |     yarn \ | ||||||
|  && update-ca-certificates \ |  && update-ca-certificates \ | ||||||
|  |  && 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 \ | ||||||
|  |  && ./configure --prefix=/usr/local \ | ||||||
|  |  && make -j$(getconf _NPROCESSORS_ONLN)\ | ||||||
|  |  && make install \ | ||||||
|  |  && libtool --finish /usr/local/lib \ | ||||||
|  |  && cd /mastodon \ | ||||||
|  && rm -rf /tmp/* /var/cache/apk/* |  && rm -rf /tmp/* /var/cache/apk/* | ||||||
|  |  | ||||||
| COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/ | COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/ | ||||||
|  |  | ||||||
| RUN bundle install --deployment --without test development \ | 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 --ignore-optional --pure-lockfile | ||||||
|  |  | ||||||
| COPY . /mastodon | COPY . /mastodon | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								Gemfile
									
									
									
									
									
								
							| @@ -15,6 +15,7 @@ gem 'pghero', '~> 1.7' | |||||||
| gem 'dotenv-rails', '~> 2.2' | gem 'dotenv-rails', '~> 2.2' | ||||||
|  |  | ||||||
| gem 'aws-sdk', '~> 2.9' | gem 'aws-sdk', '~> 2.9' | ||||||
|  | gem 'fog-openstack', '~> 0.1' | ||||||
| gem 'paperclip', '~> 5.1' | gem 'paperclip', '~> 5.1' | ||||||
| gem 'paperclip-av-transcoder', '~> 0.6' | gem 'paperclip-av-transcoder', '~> 0.6' | ||||||
|  |  | ||||||
| @@ -22,7 +23,8 @@ gem 'active_model_serializers', '~> 0.10' | |||||||
| gem 'addressable', '~> 2.5' | gem 'addressable', '~> 2.5' | ||||||
| gem 'bootsnap' | gem 'bootsnap' | ||||||
| gem 'browser' | gem 'browser' | ||||||
| gem 'charlock_holmes', '~> 0.7.3' | gem 'charlock_holmes', '~> 0.7.5' | ||||||
|  | gem 'iso-639' | ||||||
| gem 'cld3', '~> 3.1' | gem 'cld3', '~> 3.1' | ||||||
| gem 'devise', '~> 4.2' | gem 'devise', '~> 4.2' | ||||||
| gem 'devise-two-factor', '~> 3.0' | gem 'devise-two-factor', '~> 3.0' | ||||||
| @@ -68,6 +70,9 @@ gem 'tzinfo-data', '~> 1.2017' | |||||||
| gem 'webpacker', '~> 2.0' | gem 'webpacker', '~> 2.0' | ||||||
| gem 'webpush' | gem 'webpush' | ||||||
|  |  | ||||||
|  | gem 'json-ld-preloaded', '~> 2.2.1' | ||||||
|  | gem 'rdf-normalize', '~> 0.3.1' | ||||||
|  |  | ||||||
| group :development, :test do | group :development, :test do | ||||||
|   gem 'fabrication', '~> 2.16' |   gem 'fabrication', '~> 2.16' | ||||||
|   gem 'fuubar', '~> 2.2' |   gem 'fuubar', '~> 2.2' | ||||||
|   | |||||||
							
								
								
									
										169
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										169
									
								
								Gemfile.lock
									
									
									
									
									
								
							| @@ -1,25 +1,25 @@ | |||||||
| GEM | GEM | ||||||
|   remote: https://rubygems.org/ |   remote: https://rubygems.org/ | ||||||
|   specs: |   specs: | ||||||
|     actioncable (5.1.2) |     actioncable (5.1.3) | ||||||
|       actionpack (= 5.1.2) |       actionpack (= 5.1.3) | ||||||
|       nio4r (~> 2.0) |       nio4r (~> 2.0) | ||||||
|       websocket-driver (~> 0.6.1) |       websocket-driver (~> 0.6.1) | ||||||
|     actionmailer (5.1.2) |     actionmailer (5.1.3) | ||||||
|       actionpack (= 5.1.2) |       actionpack (= 5.1.3) | ||||||
|       actionview (= 5.1.2) |       actionview (= 5.1.3) | ||||||
|       activejob (= 5.1.2) |       activejob (= 5.1.3) | ||||||
|       mail (~> 2.5, >= 2.5.4) |       mail (~> 2.5, >= 2.5.4) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
|     actionpack (5.1.2) |     actionpack (5.1.3) | ||||||
|       actionview (= 5.1.2) |       actionview (= 5.1.3) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.3) | ||||||
|       rack (~> 2.0) |       rack (~> 2.0) | ||||||
|       rack-test (~> 0.6.3) |       rack-test (~> 0.6.3) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
|       rails-html-sanitizer (~> 1.0, >= 1.0.2) |       rails-html-sanitizer (~> 1.0, >= 1.0.2) | ||||||
|     actionview (5.1.2) |     actionview (5.1.3) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.3) | ||||||
|       builder (~> 3.1) |       builder (~> 3.1) | ||||||
|       erubi (~> 1.4) |       erubi (~> 1.4) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
| @@ -30,22 +30,22 @@ GEM | |||||||
|       case_transform (>= 0.2) |       case_transform (>= 0.2) | ||||||
|       jsonapi-renderer (>= 0.1.1.beta1, < 0.2) |       jsonapi-renderer (>= 0.1.1.beta1, < 0.2) | ||||||
|     active_record_query_trace (1.5.4) |     active_record_query_trace (1.5.4) | ||||||
|     activejob (5.1.2) |     activejob (5.1.3) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.3) | ||||||
|       globalid (>= 0.3.6) |       globalid (>= 0.3.6) | ||||||
|     activemodel (5.1.2) |     activemodel (5.1.3) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.3) | ||||||
|     activerecord (5.1.2) |     activerecord (5.1.3) | ||||||
|       activemodel (= 5.1.2) |       activemodel (= 5.1.3) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.3) | ||||||
|       arel (~> 8.0) |       arel (~> 8.0) | ||||||
|     activesupport (5.1.2) |     activesupport (5.1.3) | ||||||
|       concurrent-ruby (~> 1.0, >= 1.0.2) |       concurrent-ruby (~> 1.0, >= 1.0.2) | ||||||
|       i18n (~> 0.7) |       i18n (~> 0.7) | ||||||
|       minitest (~> 5.1) |       minitest (~> 5.1) | ||||||
|       tzinfo (~> 1.1) |       tzinfo (~> 1.1) | ||||||
|     addressable (2.5.1) |     addressable (2.5.2) | ||||||
|       public_suffix (~> 2.0, >= 2.0.2) |       public_suffix (>= 2.0.2, < 4.0) | ||||||
|     airbrussh (1.3.0) |     airbrussh (1.3.0) | ||||||
|       sshkit (>= 1.6.1, != 1.7.0) |       sshkit (>= 1.6.1, != 1.7.0) | ||||||
|     annotate (2.7.2) |     annotate (2.7.2) | ||||||
| @@ -57,14 +57,14 @@ GEM | |||||||
|       encryptor (~> 3.0.0) |       encryptor (~> 3.0.0) | ||||||
|     av (0.9.0) |     av (0.9.0) | ||||||
|       cocaine (~> 0.5.3) |       cocaine (~> 0.5.3) | ||||||
|     aws-sdk (2.10.6) |     aws-sdk (2.10.21) | ||||||
|       aws-sdk-resources (= 2.10.6) |       aws-sdk-resources (= 2.10.21) | ||||||
|     aws-sdk-core (2.10.6) |     aws-sdk-core (2.10.21) | ||||||
|       aws-sigv4 (~> 1.0) |       aws-sigv4 (~> 1.0) | ||||||
|       jmespath (~> 1.0) |       jmespath (~> 1.0) | ||||||
|     aws-sdk-resources (2.10.6) |     aws-sdk-resources (2.10.21) | ||||||
|       aws-sdk-core (= 2.10.6) |       aws-sdk-core (= 2.10.21) | ||||||
|     aws-sigv4 (1.0.0) |     aws-sigv4 (1.0.1) | ||||||
|     bcrypt (3.1.11) |     bcrypt (3.1.11) | ||||||
|     better_errors (2.1.1) |     better_errors (2.1.1) | ||||||
|       coderay (>= 1.0.0) |       coderay (>= 1.0.0) | ||||||
| @@ -72,15 +72,15 @@ GEM | |||||||
|       rack (>= 0.9.0) |       rack (>= 0.9.0) | ||||||
|     binding_of_caller (0.7.2) |     binding_of_caller (0.7.2) | ||||||
|       debug_inspector (>= 0.0.1) |       debug_inspector (>= 0.0.1) | ||||||
|     bootsnap (1.1.1) |     bootsnap (1.1.2) | ||||||
|       msgpack (~> 1.0) |       msgpack (~> 1.0) | ||||||
|     brakeman (3.6.2) |     brakeman (3.7.2) | ||||||
|     browser (2.4.0) |     browser (2.4.0) | ||||||
|     builder (3.2.3) |     builder (3.2.3) | ||||||
|     bullet (5.5.1) |     bullet (5.5.1) | ||||||
|       activesupport (>= 3.0.0) |       activesupport (>= 3.0.0) | ||||||
|       uniform_notifier (~> 1.10.0) |       uniform_notifier (~> 1.10.0) | ||||||
|     bundler-audit (0.5.0) |     bundler-audit (0.6.0) | ||||||
|       bundler (~> 1.2) |       bundler (~> 1.2) | ||||||
|       thor (~> 0.18) |       thor (~> 0.18) | ||||||
|     capistrano (3.8.2) |     capistrano (3.8.2) | ||||||
| @@ -108,7 +108,7 @@ GEM | |||||||
|       xpath (~> 2.0) |       xpath (~> 2.0) | ||||||
|     case_transform (0.2) |     case_transform (0.2) | ||||||
|       activesupport |       activesupport | ||||||
|     charlock_holmes (0.7.3) |     charlock_holmes (0.7.5) | ||||||
|     chunky_png (1.3.8) |     chunky_png (1.3.8) | ||||||
|     cld3 (3.1.3) |     cld3 (3.1.3) | ||||||
|       ffi (>= 1.1.0, < 1.10.0) |       ffi (>= 1.1.0, < 1.10.0) | ||||||
| @@ -154,18 +154,31 @@ GEM | |||||||
|     erubis (2.7.0) |     erubis (2.7.0) | ||||||
|     et-orbi (1.0.5) |     et-orbi (1.0.5) | ||||||
|       tzinfo |       tzinfo | ||||||
|  |     excon (0.58.0) | ||||||
|     execjs (2.7.0) |     execjs (2.7.0) | ||||||
|     fabrication (2.16.1) |     fabrication (2.16.2) | ||||||
|     faker (1.7.3) |     faker (1.7.3) | ||||||
|       i18n (~> 0.5) |       i18n (~> 0.5) | ||||||
|     fast_blank (1.0.0) |     fast_blank (1.0.0) | ||||||
|     ffi (1.9.18) |     ffi (1.9.18) | ||||||
|  |     fog-core (1.45.0) | ||||||
|  |       builder | ||||||
|  |       excon (~> 0.58) | ||||||
|  |       formatador (~> 0.2) | ||||||
|  |     fog-json (1.0.2) | ||||||
|  |       fog-core (~> 1.0) | ||||||
|  |       multi_json (~> 1.10) | ||||||
|  |     fog-openstack (0.1.21) | ||||||
|  |       fog-core (>= 1.40) | ||||||
|  |       fog-json (>= 1.0) | ||||||
|  |       ipaddress (>= 0.8) | ||||||
|  |     formatador (0.2.5) | ||||||
|     fuubar (2.2.0) |     fuubar (2.2.0) | ||||||
|       rspec-core (~> 3.0) |       rspec-core (~> 3.0) | ||||||
|       ruby-progressbar (~> 1.4) |       ruby-progressbar (~> 1.4) | ||||||
|     globalid (0.4.0) |     globalid (0.4.0) | ||||||
|       activesupport (>= 4.2.0) |       activesupport (>= 4.2.0) | ||||||
|     goldfinger (2.0.0) |     goldfinger (2.0.1) | ||||||
|       addressable (~> 2.5) |       addressable (~> 2.5) | ||||||
|       http (~> 2.2) |       http (~> 2.2) | ||||||
|       nokogiri (~> 1.8) |       nokogiri (~> 1.8) | ||||||
| @@ -179,7 +192,9 @@ GEM | |||||||
|       activesupport (>= 4.0.1) |       activesupport (>= 4.0.1) | ||||||
|       hamlit (>= 1.2.0) |       hamlit (>= 1.2.0) | ||||||
|       railties (>= 4.0.1) |       railties (>= 4.0.1) | ||||||
|     hashdiff (0.3.4) |     hamster (3.0.0) | ||||||
|  |       concurrent-ruby (~> 1.0) | ||||||
|  |     hashdiff (0.3.5) | ||||||
|     highline (1.7.8) |     highline (1.7.8) | ||||||
|     hiredis (0.6.1) |     hiredis (0.6.1) | ||||||
|     hkdf (0.3.0) |     hkdf (0.3.0) | ||||||
| @@ -194,11 +209,11 @@ GEM | |||||||
|     http-form_data (1.0.3) |     http-form_data (1.0.3) | ||||||
|     http_accept_language (2.1.1) |     http_accept_language (2.1.1) | ||||||
|     http_parser.rb (0.6.0) |     http_parser.rb (0.6.0) | ||||||
|     httplog (0.99.4) |     httplog (0.99.7) | ||||||
|       colorize |       colorize | ||||||
|       rack |       rack | ||||||
|     i18n (0.8.4) |     i18n (0.8.6) | ||||||
|     i18n-tasks (0.9.15) |     i18n-tasks (0.9.16) | ||||||
|       activesupport (>= 4.0.2) |       activesupport (>= 4.0.2) | ||||||
|       ast (>= 2.1.0) |       ast (>= 2.1.0) | ||||||
|       easy_translate (>= 0.5.0) |       easy_translate (>= 0.5.0) | ||||||
| @@ -209,9 +224,18 @@ GEM | |||||||
|       rainbow (~> 2.2) |       rainbow (~> 2.2) | ||||||
|       terminal-table (>= 1.5.1) |       terminal-table (>= 1.5.1) | ||||||
|     idn-ruby (0.1.0) |     idn-ruby (0.1.0) | ||||||
|  |     ipaddress (0.8.3) | ||||||
|  |     iso-639 (0.2.8) | ||||||
|     jmespath (1.3.1) |     jmespath (1.3.1) | ||||||
|     json (2.1.0) |     json (2.1.0) | ||||||
|     jsonapi-renderer (0.1.2) |     json-ld (2.1.5) | ||||||
|  |       multi_json (~> 1.12) | ||||||
|  |       rdf (~> 2.2) | ||||||
|  |     json-ld-preloaded (2.2.1) | ||||||
|  |       json-ld (~> 2.1, >= 2.1.5) | ||||||
|  |       multi_json (~> 1.11) | ||||||
|  |       rdf (~> 2.2) | ||||||
|  |     jsonapi-renderer (0.1.3) | ||||||
|     jwt (1.5.6) |     jwt (1.5.6) | ||||||
|     kaminari (1.0.1) |     kaminari (1.0.1) | ||||||
|       activesupport (>= 4.1.0) |       activesupport (>= 4.1.0) | ||||||
| @@ -253,7 +277,7 @@ GEM | |||||||
|     mime-types-data (3.2016.0521) |     mime-types-data (3.2016.0521) | ||||||
|     mimemagic (0.3.2) |     mimemagic (0.3.2) | ||||||
|     mini_portile2 (2.2.0) |     mini_portile2 (2.2.0) | ||||||
|     minitest (5.10.2) |     minitest (5.10.3) | ||||||
|     msgpack (1.1.0) |     msgpack (1.1.0) | ||||||
|     multi_json (1.12.1) |     multi_json (1.12.1) | ||||||
|     net-scp (1.2.1) |     net-scp (1.2.1) | ||||||
| @@ -264,7 +288,7 @@ GEM | |||||||
|       mini_portile2 (~> 2.2.0) |       mini_portile2 (~> 2.2.0) | ||||||
|     nokogumbo (1.4.13) |     nokogumbo (1.4.13) | ||||||
|       nokogiri |       nokogiri | ||||||
|     oj (3.2.0) |     oj (3.3.4) | ||||||
|     openssl (2.0.4) |     openssl (2.0.4) | ||||||
|     orm_adapter (0.5.0) |     orm_adapter (0.5.0) | ||||||
|     ostatus2 (2.0.1) |     ostatus2 (2.0.1) | ||||||
| @@ -283,14 +307,14 @@ GEM | |||||||
|       av (~> 0.9.0) |       av (~> 0.9.0) | ||||||
|       paperclip (>= 2.5.2) |       paperclip (>= 2.5.2) | ||||||
|     parallel (1.11.2) |     parallel (1.11.2) | ||||||
|     parallel_tests (2.14.1) |     parallel_tests (2.14.2) | ||||||
|       parallel |       parallel | ||||||
|     parser (2.4.0.0) |     parser (2.4.0.0) | ||||||
|       ast (~> 2.2) |       ast (~> 2.2) | ||||||
|     pg (0.21.0) |     pg (0.21.0) | ||||||
|     pghero (1.7.0) |     pghero (1.7.0) | ||||||
|       activerecord |       activerecord | ||||||
|     pkg-config (1.2.3) |     pkg-config (1.2.4) | ||||||
|     powerpack (0.1.1) |     powerpack (0.1.1) | ||||||
|     pry (0.10.4) |     pry (0.10.4) | ||||||
|       coderay (~> 1.1.0) |       coderay (~> 1.1.0) | ||||||
| @@ -298,7 +322,7 @@ GEM | |||||||
|       slop (~> 3.4) |       slop (~> 3.4) | ||||||
|     pry-rails (0.3.6) |     pry-rails (0.3.6) | ||||||
|       pry (>= 0.10.4) |       pry (>= 0.10.4) | ||||||
|     public_suffix (2.0.5) |     public_suffix (3.0.0) | ||||||
|     puma (3.9.1) |     puma (3.9.1) | ||||||
|     pundit (1.1.0) |     pundit (1.1.0) | ||||||
|       activesupport (>= 3.0.0) |       activesupport (>= 3.0.0) | ||||||
| @@ -313,17 +337,17 @@ GEM | |||||||
|     rack-test (0.6.3) |     rack-test (0.6.3) | ||||||
|       rack (>= 1.0) |       rack (>= 1.0) | ||||||
|     rack-timeout (0.4.2) |     rack-timeout (0.4.2) | ||||||
|     rails (5.1.2) |     rails (5.1.3) | ||||||
|       actioncable (= 5.1.2) |       actioncable (= 5.1.3) | ||||||
|       actionmailer (= 5.1.2) |       actionmailer (= 5.1.3) | ||||||
|       actionpack (= 5.1.2) |       actionpack (= 5.1.3) | ||||||
|       actionview (= 5.1.2) |       actionview (= 5.1.3) | ||||||
|       activejob (= 5.1.2) |       activejob (= 5.1.3) | ||||||
|       activemodel (= 5.1.2) |       activemodel (= 5.1.3) | ||||||
|       activerecord (= 5.1.2) |       activerecord (= 5.1.3) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.3) | ||||||
|       bundler (>= 1.3.0, < 2.0) |       bundler (>= 1.3.0) | ||||||
|       railties (= 5.1.2) |       railties (= 5.1.3) | ||||||
|       sprockets-rails (>= 2.0.0) |       sprockets-rails (>= 2.0.0) | ||||||
|     rails-controller-testing (1.0.2) |     rails-controller-testing (1.0.2) | ||||||
|       actionpack (~> 5.x, >= 5.0.1) |       actionpack (~> 5.x, >= 5.0.1) | ||||||
| @@ -337,23 +361,28 @@ GEM | |||||||
|     rails-i18n (5.0.4) |     rails-i18n (5.0.4) | ||||||
|       i18n (~> 0.7) |       i18n (~> 0.7) | ||||||
|       railties (~> 5.0) |       railties (~> 5.0) | ||||||
|     rails-settings-cached (0.6.5) |     rails-settings-cached (0.6.6) | ||||||
|       rails (>= 4.2.0) |       rails (>= 4.2.0) | ||||||
|     railties (5.1.2) |     railties (5.1.3) | ||||||
|       actionpack (= 5.1.2) |       actionpack (= 5.1.3) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.3) | ||||||
|       method_source |       method_source | ||||||
|       rake (>= 0.8.7) |       rake (>= 0.8.7) | ||||||
|       thor (>= 0.18.1, < 2.0) |       thor (>= 0.18.1, < 2.0) | ||||||
|     rainbow (2.2.2) |     rainbow (2.2.2) | ||||||
|       rake |       rake | ||||||
|     rake (12.0.0) |     rake (12.0.0) | ||||||
|  |     rdf (2.2.8) | ||||||
|  |       hamster (~> 3.0) | ||||||
|  |       link_header (~> 0.0, >= 0.0.8) | ||||||
|  |     rdf-normalize (0.3.2) | ||||||
|  |       rdf (~> 2.0) | ||||||
|     redis (3.3.3) |     redis (3.3.3) | ||||||
|     redis-actionpack (5.0.1) |     redis-actionpack (5.0.1) | ||||||
|       actionpack (>= 4.0, < 6) |       actionpack (>= 4.0, < 6) | ||||||
|       redis-rack (>= 1, < 3) |       redis-rack (>= 1, < 3) | ||||||
|       redis-store (>= 1.1.0, < 1.4.0) |       redis-store (>= 1.1.0, < 1.4.0) | ||||||
|     redis-activesupport (5.0.2) |     redis-activesupport (5.0.3) | ||||||
|       activesupport (>= 3, < 6) |       activesupport (>= 3, < 6) | ||||||
|       redis-store (~> 1.3.0) |       redis-store (~> 1.3.0) | ||||||
|     redis-namespace (1.5.3) |     redis-namespace (1.5.3) | ||||||
| @@ -413,7 +442,7 @@ GEM | |||||||
|     scss_lint (0.54.0) |     scss_lint (0.54.0) | ||||||
|       rake (>= 0.9, < 13) |       rake (>= 0.9, < 13) | ||||||
|       sass (~> 3.4.20) |       sass (~> 3.4.20) | ||||||
|     sidekiq (5.0.3) |     sidekiq (5.0.4) | ||||||
|       concurrent-ruby (~> 1.0) |       concurrent-ruby (~> 1.0) | ||||||
|       connection_pool (~> 2.2, >= 2.2.0) |       connection_pool (~> 2.2, >= 2.2.0) | ||||||
|       rack-protection (>= 1.5.0) |       rack-protection (>= 1.5.0) | ||||||
| @@ -421,12 +450,12 @@ GEM | |||||||
|     sidekiq-bulk (0.1.1) |     sidekiq-bulk (0.1.1) | ||||||
|       activesupport |       activesupport | ||||||
|       sidekiq |       sidekiq | ||||||
|     sidekiq-scheduler (2.1.7) |     sidekiq-scheduler (2.1.8) | ||||||
|       redis (~> 3) |       redis (~> 3) | ||||||
|       rufus-scheduler (~> 3.2) |       rufus-scheduler (~> 3.2) | ||||||
|       sidekiq (>= 3) |       sidekiq (>= 3) | ||||||
|       tilt (>= 1.4.0) |       tilt (>= 1.4.0) | ||||||
|     sidekiq-unique-jobs (5.0.8) |     sidekiq-unique-jobs (5.0.9) | ||||||
|       sidekiq (>= 4.0, <= 6.0) |       sidekiq (>= 4.0, <= 6.0) | ||||||
|       thor (~> 0) |       thor (~> 0) | ||||||
|     simple-navigation (4.0.5) |     simple-navigation (4.0.5) | ||||||
| @@ -450,15 +479,15 @@ GEM | |||||||
|     sshkit (1.13.1) |     sshkit (1.13.1) | ||||||
|       net-scp (>= 1.1.2) |       net-scp (>= 1.1.2) | ||||||
|       net-ssh (>= 2.8.0) |       net-ssh (>= 2.8.0) | ||||||
|     statsd-instrument (2.1.2) |     statsd-instrument (2.1.4) | ||||||
|     temple (0.8.0) |     temple (0.8.0) | ||||||
|     terminal-table (1.8.0) |     terminal-table (1.8.0) | ||||||
|       unicode-display_width (~> 1.1, >= 1.1.1) |       unicode-display_width (~> 1.1, >= 1.1.1) | ||||||
|     thor (0.19.4) |     thor (0.20.0) | ||||||
|     thread (0.2.2) |     thread (0.2.2) | ||||||
|     thread_safe (0.3.6) |     thread_safe (0.3.6) | ||||||
|     tilt (2.0.7) |     tilt (2.0.8) | ||||||
|     twitter-text (1.14.6) |     twitter-text (1.14.7) | ||||||
|       unf (~> 0.1.0) |       unf (~> 0.1.0) | ||||||
|     tzinfo (1.2.3) |     tzinfo (1.2.3) | ||||||
|       thread_safe (~> 0.1) |       thread_safe (~> 0.1) | ||||||
| @@ -511,7 +540,7 @@ DEPENDENCIES | |||||||
|   capistrano-rbenv (~> 2.1) |   capistrano-rbenv (~> 2.1) | ||||||
|   capistrano-yarn (~> 2.0) |   capistrano-yarn (~> 2.0) | ||||||
|   capybara (~> 2.14) |   capybara (~> 2.14) | ||||||
|   charlock_holmes (~> 0.7.3) |   charlock_holmes (~> 0.7.5) | ||||||
|   cld3 (~> 3.1) |   cld3 (~> 3.1) | ||||||
|   climate_control (~> 0.2) |   climate_control (~> 0.2) | ||||||
|   devise (~> 4.2) |   devise (~> 4.2) | ||||||
| @@ -521,6 +550,7 @@ DEPENDENCIES | |||||||
|   fabrication (~> 2.16) |   fabrication (~> 2.16) | ||||||
|   faker (~> 1.7) |   faker (~> 1.7) | ||||||
|   fast_blank (~> 1.0) |   fast_blank (~> 1.0) | ||||||
|  |   fog-openstack (~> 0.1) | ||||||
|   fuubar (~> 2.2) |   fuubar (~> 2.2) | ||||||
|   goldfinger (~> 2.0) |   goldfinger (~> 2.0) | ||||||
|   hamlit-rails (~> 0.2) |   hamlit-rails (~> 0.2) | ||||||
| @@ -531,6 +561,8 @@ DEPENDENCIES | |||||||
|   httplog (~> 0.99) |   httplog (~> 0.99) | ||||||
|   i18n-tasks (~> 0.9) |   i18n-tasks (~> 0.9) | ||||||
|   idn-ruby |   idn-ruby | ||||||
|  |   iso-639 | ||||||
|  |   json-ld-preloaded (~> 2.2.1) | ||||||
|   kaminari (~> 1.0) |   kaminari (~> 1.0) | ||||||
|   letter_opener (~> 1.4) |   letter_opener (~> 1.4) | ||||||
|   letter_opener_web (~> 1.3) |   letter_opener_web (~> 1.3) | ||||||
| @@ -560,6 +592,7 @@ DEPENDENCIES | |||||||
|   rails-controller-testing (~> 1.0) |   rails-controller-testing (~> 1.0) | ||||||
|   rails-i18n (~> 5.0) |   rails-i18n (~> 5.0) | ||||||
|   rails-settings-cached (~> 0.6) |   rails-settings-cached (~> 0.6) | ||||||
|  |   rdf-normalize (~> 0.3.1) | ||||||
|   redis (~> 3.3) |   redis (~> 3.3) | ||||||
|   redis-namespace (~> 1.5) |   redis-namespace (~> 1.5) | ||||||
|   redis-rails (~> 5.0) |   redis-rails (~> 5.0) | ||||||
| @@ -590,4 +623,4 @@ RUBY VERSION | |||||||
|    ruby 2.4.1p111 |    ruby 2.4.1p111 | ||||||
|  |  | ||||||
| BUNDLED WITH | BUNDLED WITH | ||||||
|    1.15.2 |    1.15.4 | ||||||
|   | |||||||
| @@ -7,24 +7,78 @@ class AccountsController < ApplicationController | |||||||
|   def show |   def show | ||||||
|     respond_to do |format| |     respond_to do |format| | ||||||
|       format.html do |       format.html do | ||||||
|         @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) |         @pinned_statuses = [] | ||||||
|  |  | ||||||
|  |         if current_account && @account.blocking?(current_account) | ||||||
|  |           @statuses = [] | ||||||
|  |           return | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? | ||||||
|  |         @statuses        = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id]) | ||||||
|         @statuses        = cache_collection(@statuses, Status) |         @statuses        = cache_collection(@statuses, Status) | ||||||
|  |         @next_url        = next_url unless @statuses.empty? | ||||||
|       end |       end | ||||||
|  |  | ||||||
|       format.atom do |       format.atom do | ||||||
|         @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) |         @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) | ||||||
|         render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.to_a)) |         render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? })) | ||||||
|       end |       end | ||||||
|  |  | ||||||
|       format.json do |       format.json do | ||||||
|         render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter |         render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  |   def show_pinned_statuses? | ||||||
|  |     [replies_requested?, media_requested?, params[:max_id].present?, params[:since_id].present?].none? | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def filtered_statuses | ||||||
|  |     default_statuses.tap do |statuses| | ||||||
|  |       statuses.merge!(only_media_scope) if media_requested? | ||||||
|  |       statuses.merge!(no_replies_scope) unless replies_requested? | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def default_statuses | ||||||
|  |     @account.statuses.where(visibility: [:public, :unlisted]) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def only_media_scope | ||||||
|  |     Status.where(id: account_media_status_ids) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def account_media_status_ids | ||||||
|  |     @account.media_attachments.attached.reorder(nil).select(:status_id).distinct | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def no_replies_scope | ||||||
|  |     Status.without_replies | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def set_account |   def set_account | ||||||
|     @account = Account.find_local!(params[:username]) |     @account = Account.find_local!(params[:username]) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def next_url | ||||||
|  |     if media_requested? | ||||||
|  |       short_account_media_url(@account, max_id: @statuses.last.id) | ||||||
|  |     elsif replies_requested? | ||||||
|  |       short_account_with_replies_url(@account, max_id: @statuses.last.id) | ||||||
|  |     else | ||||||
|  |       short_account_url(@account, max_id: @statuses.last.id) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def media_requested? | ||||||
|  |     request.path.ends_with?('/media') | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def replies_requested? | ||||||
|  |     request.path.ends_with?('/with_replies') | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										40
									
								
								app/controllers/activitypub/inboxes_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								app/controllers/activitypub/inboxes_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class ActivityPub::InboxesController < Api::BaseController | ||||||
|  |   include SignatureVerification | ||||||
|  |  | ||||||
|  |   before_action :set_account | ||||||
|  |  | ||||||
|  |   def create | ||||||
|  |     if signed_request_account | ||||||
|  |       upgrade_account | ||||||
|  |       process_payload | ||||||
|  |       head 201 | ||||||
|  |     else | ||||||
|  |       head 202 | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def set_account | ||||||
|  |     @account = Account.find_local!(params[:account_username]) if params[:account_username] | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def body | ||||||
|  |     @body ||= request.body.read | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def upgrade_account | ||||||
|  |     if signed_request_account.ostatus? | ||||||
|  |       signed_request_account.update(last_webfingered_at: nil) | ||||||
|  |       ResolveRemoteAccountWorker.perform_async(signed_request_account.acct) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def process_payload | ||||||
|  |     ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8')) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -7,7 +7,7 @@ class ActivityPub::OutboxesController < Api::BaseController | |||||||
|     @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) |     @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) | ||||||
|     @statuses = cache_collection(@statuses, Status) |     @statuses = cache_collection(@statuses, Status) | ||||||
|  |  | ||||||
|     render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter |     render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ module Admin | |||||||
|     end |     end | ||||||
|  |  | ||||||
|     def unsubscribe |     def unsubscribe | ||||||
|       UnsubscribeService.new.call(@account) |       Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id) | ||||||
|       redirect_to admin_account_path(@account.id) |       redirect_to admin_account_path(@account.id) | ||||||
|     end |     end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ module Admin | |||||||
|       closed_registrations_message |       closed_registrations_message | ||||||
|       open_deletion |       open_deletion | ||||||
|       timeline_preview |       timeline_preview | ||||||
|  |       bootstrap_timeline_accounts | ||||||
|     ).freeze |     ).freeze | ||||||
|  |  | ||||||
|     BOOLEAN_SETTINGS = %w( |     BOOLEAN_SETTINGS = %w( | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ module Admin | |||||||
|     before_action :set_account |     before_action :set_account | ||||||
|     before_action :set_status, only: [:update, :destroy] |     before_action :set_status, only: [:update, :destroy] | ||||||
|  |  | ||||||
|     PAR_PAGE = 20 |     PER_PAGE = 20 | ||||||
|  |  | ||||||
|     def index |     def index | ||||||
|       @statuses = @account.statuses |       @statuses = @account.statuses | ||||||
| @@ -17,7 +17,7 @@ module Admin | |||||||
|         account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct |         account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct | ||||||
|         @statuses.merge!(Status.where(id: account_media_status_ids)) |         @statuses.merge!(Status.where(id: account_media_status_ids)) | ||||||
|       end |       end | ||||||
|       @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE) |       @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) | ||||||
|  |  | ||||||
|       @form = Form::StatusBatch.new |       @form = Form::StatusBatch.new | ||||||
|     end |     end | ||||||
|   | |||||||
| @@ -43,7 +43,7 @@ class Api::BaseController < ApplicationController | |||||||
|     links = [] |     links = [] | ||||||
|     links << [next_path, [%w(rel next)]] if next_path |     links << [next_path, [%w(rel next)]] if next_path | ||||||
|     links << [prev_path, [%w(rel prev)]] if prev_path |     links << [prev_path, [%w(rel prev)]] if prev_path | ||||||
|     response.headers['Link'] = LinkHeader.new(links) |     response.headers['Link'] = LinkHeader.new(links) unless links.empty? | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def limit_param(default_limit) |   def limit_param(default_limit) | ||||||
| @@ -62,11 +62,12 @@ class Api::BaseController < ApplicationController | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def require_user! |   def require_user! | ||||||
|     current_resource_owner |     if current_user | ||||||
|       set_user_activity |       set_user_activity | ||||||
|   rescue ActiveRecord::RecordNotFound |     else | ||||||
|       render json: { error: 'This method requires an authenticated user' }, status: 422 |       render json: { error: 'This method requires an authenticated user' }, status: 422 | ||||||
|     end |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def render_empty |   def render_empty | ||||||
|     render json: {}, status: 200 |     render json: {}, status: 200 | ||||||
|   | |||||||
| @@ -4,14 +4,14 @@ class Api::OEmbedController < Api::BaseController | |||||||
|   respond_to :json |   respond_to :json | ||||||
|  |  | ||||||
|   def show |   def show | ||||||
|     @stream_entry = find_stream_entry.stream_entry |     @status = status_finder.status | ||||||
|     render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default |     render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|   def find_stream_entry |   def status_finder | ||||||
|     StreamEntryFinder.new(params[:url]) |     StatusFinder.new(params[:url]) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def maxwidth_or_default |   def maxwidth_or_default | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
|  |  | ||||||
| class Api::V1::Accounts::CredentialsController < Api::BaseController | class Api::V1::Accounts::CredentialsController < Api::BaseController | ||||||
|  |   before_action -> { doorkeeper_authorize! :read }, except: [:update] | ||||||
|   before_action -> { doorkeeper_authorize! :write }, only: [:update] |   before_action -> { doorkeeper_authorize! :write }, only: [:update] | ||||||
|   before_action :require_user! |   before_action :require_user! | ||||||
|  |  | ||||||
| @@ -10,8 +11,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def update |   def update | ||||||
|     current_account.update!(account_params) |  | ||||||
|     @account = current_account |     @account = current_account | ||||||
|  |     UpdateAccountService.new.call(@account, account_params, raise_error: true) | ||||||
|  |     ActivityPub::UpdateDistributionWorker.perform_async(@account.id) | ||||||
|     render json: @account, serializer: REST::CredentialAccountSerializer |     render json: @account, serializer: REST::CredentialAccountSerializer | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -29,6 +29,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController | |||||||
|   def account_statuses |   def account_statuses | ||||||
|     default_statuses.tap do |statuses| |     default_statuses.tap do |statuses| | ||||||
|       statuses.merge!(only_media_scope) if params[:only_media] |       statuses.merge!(only_media_scope) if params[:only_media] | ||||||
|  |       statuses.merge!(pinned_scope) if params[:pinned] | ||||||
|       statuses.merge!(no_replies_scope) if params[:exclude_replies] |       statuses.merge!(no_replies_scope) if params[:exclude_replies] | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| @@ -53,6 +54,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController | |||||||
|     @account.media_attachments.attached.reorder(nil).select(:status_id).distinct |     @account.media_attachments.attached.reorder(nil).select(:status_id).distinct | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def pinned_scope | ||||||
|  |     @account.pinned_statuses | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def no_replies_scope |   def no_replies_scope | ||||||
|     Status.without_replies |     Status.without_replies | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -14,6 +14,16 @@ class Api::V1::AccountsController < Api::BaseController | |||||||
|  |  | ||||||
|   def follow |   def follow | ||||||
|     FollowService.new.call(current_user.account, @account.acct) |     FollowService.new.call(current_user.account, @account.acct) | ||||||
|  |  | ||||||
|  |     unless @account.locked? | ||||||
|  |       relationships = AccountRelationshipsPresenter.new( | ||||||
|  |         [@account.id], | ||||||
|  |         current_user.account_id, | ||||||
|  |         following_map: { @account.id => true }, | ||||||
|  |         requested_map: { @account.id => false } | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |  | ||||||
|     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships |     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										28
									
								
								app/controllers/api/v1/statuses/pins_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/controllers/api/v1/statuses/pins_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class Api::V1::Statuses::PinsController < Api::BaseController | ||||||
|  |   include Authorization | ||||||
|  |  | ||||||
|  |   before_action -> { doorkeeper_authorize! :write } | ||||||
|  |   before_action :require_user! | ||||||
|  |   before_action :set_status | ||||||
|  |  | ||||||
|  |   respond_to :json | ||||||
|  |  | ||||||
|  |   def create | ||||||
|  |     StatusPin.create!(account: current_account, status: @status) | ||||||
|  |     render json: @status, serializer: REST::StatusSerializer | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def destroy | ||||||
|  |     pin = StatusPin.find_by(account: current_account, status: @status) | ||||||
|  |     pin&.destroy! | ||||||
|  |     render json: @status, serializer: REST::StatusSerializer | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def set_status | ||||||
|  |     @status = Status.find(params[:status_id]) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -29,7 +29,7 @@ class Api::V1::StatusesController < Api::BaseController | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def card |   def card | ||||||
|     @card = PreviewCard.find_by(status: @status) |     @card = @status.preview_cards.first | ||||||
|  |  | ||||||
|     if @card.nil? |     if @card.nil? | ||||||
|       render_empty |       render_empty | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								app/controllers/api/web/embeds_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/controllers/api/web/embeds_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class Api::Web::EmbedsController < Api::BaseController | ||||||
|  |   respond_to :json | ||||||
|  |  | ||||||
|  |   before_action :require_user! | ||||||
|  |  | ||||||
|  |   def create | ||||||
|  |     status = StatusFinder.new(params[:url]).status | ||||||
|  |     render json: status, serializer: OEmbedSerializer, width: 400 | ||||||
|  |   rescue ActiveRecord::RecordNotFound | ||||||
|  |     oembed = OEmbed::Providers.get(params[:url]) | ||||||
|  |     render json: Oj.dump(oembed.fields) | ||||||
|  |   rescue OEmbed::NotFound | ||||||
|  |     render json: {}, status: :not_found | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -43,6 +43,10 @@ class ApplicationController < ActionController::Base | |||||||
|     forbidden if current_user.account.suspended? |     forbidden if current_user.account.suspended? | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def after_sign_out_path_for(_resource_or_scope) | ||||||
|  |     new_user_session_path | ||||||
|  |   end | ||||||
|  |  | ||||||
|   protected |   protected | ||||||
|  |  | ||||||
|   def forbidden |   def forbidden | ||||||
|   | |||||||
| @@ -2,4 +2,10 @@ | |||||||
|  |  | ||||||
| class Auth::ConfirmationsController < Devise::ConfirmationsController | class Auth::ConfirmationsController < Devise::ConfirmationsController | ||||||
|   layout 'auth' |   layout 'auth' | ||||||
|  |  | ||||||
|  |   def show | ||||||
|  |     super do |user| | ||||||
|  |       BootstrapTimelineWorker.perform_async(user.account_id) if user.errors.empty? | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,5 +1,20 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
|  |  | ||||||
| class Auth::PasswordsController < Devise::PasswordsController | class Auth::PasswordsController < Devise::PasswordsController | ||||||
|  |   before_action :check_validity_of_reset_password_token, only: :edit | ||||||
|  |  | ||||||
|   layout 'auth' |   layout 'auth' | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def check_validity_of_reset_password_token | ||||||
|  |     unless reset_password_token_is_valid? | ||||||
|  |       flash[:error] = I18n.t('auth.invalid_reset_password_token') | ||||||
|  |       redirect_to new_password_path(resource_name) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def reset_password_token_is_valid? | ||||||
|  |     resource_class.with_reset_password_token(params[:reset_password_token]).present? | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
|  |  | ||||||
| class AuthorizeFollowsController < ApplicationController | class AuthorizeFollowsController < ApplicationController | ||||||
|   layout 'public' |   layout 'modal' | ||||||
|  |  | ||||||
|   before_action :authenticate_user! |   before_action :authenticate_user! | ||||||
|  |  | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ module AccountControllerConcern | |||||||
|       [ |       [ | ||||||
|         webfinger_account_link, |         webfinger_account_link, | ||||||
|         atom_account_url_link, |         atom_account_url_link, | ||||||
|  |         actor_url_link, | ||||||
|       ] |       ] | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
| @@ -41,6 +42,13 @@ module AccountControllerConcern | |||||||
|     ] |     ] | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def actor_url_link | ||||||
|  |     [ | ||||||
|  |       ActivityPub::TagManager.instance.uri_for(@account), | ||||||
|  |       [%w(rel alternate), %w(type application/activity+json)], | ||||||
|  |     ] | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def webfinger_account_url |   def webfinger_account_url | ||||||
|     webfinger_url(resource: @account.to_webfinger_s) |     webfinger_url(resource: @account.to_webfinger_s) | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -31,7 +31,7 @@ module SignatureVerification | |||||||
|       return |       return | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, '')) |     account = account_from_key_id(signature_params['keyId']) | ||||||
|  |  | ||||||
|     if account.nil? |     if account.nil? | ||||||
|       @signed_request_account = nil |       @signed_request_account = nil | ||||||
| @@ -49,6 +49,10 @@ module SignatureVerification | |||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def request_body | ||||||
|  |     @request_body ||= request.raw_post | ||||||
|  |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|   def build_signed_string(signed_headers) |   def build_signed_string(signed_headers) | ||||||
| @@ -57,6 +61,8 @@ module SignatureVerification | |||||||
|     signed_headers.split(' ').map do |signed_header| |     signed_headers.split(' ').map do |signed_header| | ||||||
|       if signed_header == Request::REQUEST_TARGET |       if signed_header == Request::REQUEST_TARGET | ||||||
|         "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" |         "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" | ||||||
|  |       elsif signed_header == 'digest' | ||||||
|  |         "digest: #{body_digest}" | ||||||
|       else |       else | ||||||
|         "#{signed_header}: #{request.headers[to_header_name(signed_header)]}" |         "#{signed_header}: #{request.headers[to_header_name(signed_header)]}" | ||||||
|       end |       end | ||||||
| @@ -73,6 +79,10 @@ module SignatureVerification | |||||||
|     (Time.now.utc - time_sent).abs <= 30 |     (Time.now.utc - time_sent).abs <= 30 | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def body_digest | ||||||
|  |     "SHA-256=#{Digest::SHA256.base64digest(request_body)}" | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def to_header_name(name) |   def to_header_name(name) | ||||||
|     name.split(/-/).map(&:capitalize).join('-') |     name.split(/-/).map(&:capitalize).join('-') | ||||||
|   end |   end | ||||||
| @@ -81,7 +91,16 @@ module SignatureVerification | |||||||
|     signature_params['keyId'].blank? || |     signature_params['keyId'].blank? || | ||||||
|       signature_params['signature'].blank? || |       signature_params['signature'].blank? || | ||||||
|       signature_params['algorithm'].blank? || |       signature_params['algorithm'].blank? || | ||||||
|       signature_params['algorithm'] != 'rsa-sha256' || |       signature_params['algorithm'] != 'rsa-sha256' | ||||||
|       !signature_params['keyId'].start_with?('acct:') |   end | ||||||
|  |  | ||||||
|  |   def account_from_key_id(key_id) | ||||||
|  |     if key_id.start_with?('acct:') | ||||||
|  |       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 | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ class FollowerAccountsController < ApplicationController | |||||||
|       format.html |       format.html | ||||||
|  |  | ||||||
|       format.json do |       format.json do | ||||||
|         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter |         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ class FollowingAccountsController < ApplicationController | |||||||
|       format.html |       format.html | ||||||
|  |  | ||||||
|       format.json do |       format.json do | ||||||
|         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter |         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								app/controllers/intents_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/controllers/intents_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class IntentsController < ApplicationController | ||||||
|  |   def show | ||||||
|  |     uri = Addressable::URI.parse(params[:uri]) | ||||||
|  |  | ||||||
|  |     if uri.scheme == 'web+mastodon' | ||||||
|  |       case uri.host | ||||||
|  |       when 'follow' | ||||||
|  |         return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, '')) | ||||||
|  |       when 'share' | ||||||
|  |         return redirect_to share_path(text: uri.query_values['text']) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     not_found | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
|  |  | ||||||
| class RemoteFollowController < ApplicationController | class RemoteFollowController < ApplicationController | ||||||
|   layout 'public' |   layout 'modal' | ||||||
|  |  | ||||||
|   before_action :set_account |   before_action :set_account | ||||||
|   before_action :gone, if: :suspended_account? |   before_action :gone, if: :suspended_account? | ||||||
|   | |||||||
							
								
								
									
										72
									
								
								app/controllers/settings/applications_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								app/controllers/settings/applications_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class Settings::ApplicationsController < ApplicationController | ||||||
|  |   layout 'admin' | ||||||
|  |  | ||||||
|  |   before_action :authenticate_user! | ||||||
|  |   before_action :set_application, only: [:show, :update, :destroy, :regenerate] | ||||||
|  |   before_action :prepare_scopes, only: [:create, :update] | ||||||
|  |  | ||||||
|  |   def index | ||||||
|  |     @applications = current_user.applications.page(params[:page]) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def new | ||||||
|  |     @application = Doorkeeper::Application.new( | ||||||
|  |       redirect_uri: Doorkeeper.configuration.native_redirect_uri, | ||||||
|  |       scopes: 'read write follow' | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def show; end | ||||||
|  |  | ||||||
|  |   def create | ||||||
|  |     @application = current_user.applications.build(application_params) | ||||||
|  |  | ||||||
|  |     if @application.save | ||||||
|  |       redirect_to settings_applications_path, notice: I18n.t('applications.created') | ||||||
|  |     else | ||||||
|  |       render :new | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def update | ||||||
|  |     if @application.update(application_params) | ||||||
|  |       redirect_to settings_applications_path, notice: I18n.t('generic.changes_saved_msg') | ||||||
|  |     else | ||||||
|  |       render :show | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def destroy | ||||||
|  |     @application.destroy | ||||||
|  |     redirect_to settings_applications_path, notice: I18n.t('applications.destroyed') | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def regenerate | ||||||
|  |     @access_token = current_user.token_for_app(@application) | ||||||
|  |     @access_token.destroy | ||||||
|  |  | ||||||
|  |     redirect_to settings_application_path(@application), notice: I18n.t('applications.token_regenerated') | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def set_application | ||||||
|  |     @application = current_user.applications.find(params[:id]) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def application_params | ||||||
|  |     params.require(:doorkeeper_application).permit( | ||||||
|  |       :name, | ||||||
|  |       :redirect_uri, | ||||||
|  |       :scopes, | ||||||
|  |       :website | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def prepare_scopes | ||||||
|  |     scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil) | ||||||
|  |     params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -14,7 +14,8 @@ class Settings::ProfilesController < ApplicationController | |||||||
|   def show; end |   def show; end | ||||||
|  |  | ||||||
|   def update |   def update | ||||||
|     if @account.update(account_params) |     if UpdateAccountService.new.call(@account, account_params) | ||||||
|  |       ActivityPub::UpdateDistributionWorker.perform_async(@account.id) | ||||||
|       redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') |       redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') | ||||||
|     else |     else | ||||||
|       render :show |       render :show | ||||||
|   | |||||||
							
								
								
									
										30
									
								
								app/controllers/shares_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app/controllers/shares_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class SharesController < ApplicationController | ||||||
|  |   layout 'modal' | ||||||
|  |  | ||||||
|  |   before_action :authenticate_user! | ||||||
|  |   before_action :set_body_classes | ||||||
|  |  | ||||||
|  |   def show | ||||||
|  |     serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) | ||||||
|  |     @initial_state_json   = serializable_resource.to_json | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def initial_state_params | ||||||
|  |     { | ||||||
|  |       settings: Web::Setting.find_by(user: current_user)&.data || {}, | ||||||
|  |       push_subscription: current_account.user.web_push_subscription(current_session), | ||||||
|  |       current_account: current_account, | ||||||
|  |       token: current_session.token, | ||||||
|  |       admin: Account.find_local(Setting.site_contact_username), | ||||||
|  |       text: params[:text], | ||||||
|  |     } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def set_body_classes | ||||||
|  |     @body_classes = 'compose-standalone' | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -9,6 +9,7 @@ class StatusesController < ApplicationController | |||||||
|   before_action :set_status |   before_action :set_status | ||||||
|   before_action :set_link_headers |   before_action :set_link_headers | ||||||
|   before_action :check_account_suspension |   before_action :check_account_suspension | ||||||
|  |   before_action :redirect_to_original, only: [:show] | ||||||
|  |  | ||||||
|   def show |   def show | ||||||
|     respond_to do |format| |     respond_to do |format| | ||||||
| @@ -20,13 +21,18 @@ class StatusesController < ApplicationController | |||||||
|       end |       end | ||||||
|  |  | ||||||
|       format.json do |       format.json do | ||||||
|         render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter |         render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def activity |   def activity | ||||||
|     render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter |     render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def embed | ||||||
|  |     response.headers['X-Frame-Options'] = 'ALLOWALL' | ||||||
|  |     render 'stream_entries/embed', layout: 'embedded' | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
| @@ -36,7 +42,12 @@ class StatusesController < ApplicationController | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def set_link_headers |   def set_link_headers | ||||||
|     response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) |     response.headers['Link'] = LinkHeader.new( | ||||||
|  |       [ | ||||||
|  |         [account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], | ||||||
|  |         [ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]], | ||||||
|  |       ] | ||||||
|  |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def set_status |   def set_status | ||||||
| @@ -53,4 +64,8 @@ class StatusesController < ApplicationController | |||||||
|   def check_account_suspension |   def check_account_suspension | ||||||
|     gone if @account.suspended? |     gone if @account.suspended? | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def redirect_to_original | ||||||
|  |     redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog? | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -25,10 +25,7 @@ class StreamEntriesController < ApplicationController | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def embed |   def embed | ||||||
|     response.headers['X-Frame-Options'] = 'ALLOWALL' |     redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301 | ||||||
|     return gone if @stream_entry.activity.nil? |  | ||||||
|  |  | ||||||
|     render layout: 'embedded' |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
| @@ -38,7 +35,12 @@ class StreamEntriesController < ApplicationController | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def set_link_headers |   def set_link_headers | ||||||
|     response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) |     response.headers['Link'] = LinkHeader.new( | ||||||
|  |       [ | ||||||
|  |         [account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], | ||||||
|  |         [ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]], | ||||||
|  |       ] | ||||||
|  |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def set_stream_entry |   def set_stream_entry | ||||||
|   | |||||||
| @@ -12,7 +12,7 @@ class TagsController < ApplicationController | |||||||
|       format.html |       format.html | ||||||
|  |  | ||||||
|       format.json do |       format.json do | ||||||
|         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter |         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -5,6 +5,10 @@ module ApplicationHelper | |||||||
|     current_page?(path) ? 'active' : '' |     current_page?(path) ? 'active' : '' | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def active_link_to(label, path, options = {}) | ||||||
|  |     link_to label, path, options.merge(class: active_nav_class(path)) | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def show_landing_strip? |   def show_landing_strip? | ||||||
|     !user_signed_in? && !single_user_mode? |     !user_signed_in? && !single_user_mode? | ||||||
|   end |   end | ||||||
|   | |||||||
							
								
								
									
										52
									
								
								app/helpers/jsonld_helper.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								app/helpers/jsonld_helper.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | module JsonLdHelper | ||||||
|  |   def equals_or_includes?(haystack, needle) | ||||||
|  |     haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def first_of_value(value) | ||||||
|  |     value.is_a?(Array) ? value.first : value | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def value_or_id(value) | ||||||
|  |     value.is_a?(String) || value.nil? ? value : value['id'] | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def supported_context?(json) | ||||||
|  |     !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def canonicalize(json) | ||||||
|  |     graph = RDF::Graph.new << JSON::LD::API.toRdf(json) | ||||||
|  |     graph.dump(:normalize) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def fetch_resource(uri) | ||||||
|  |     response = build_request(uri).perform | ||||||
|  |     return if response.code != 200 | ||||||
|  |     body_to_json(response.to_s) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def body_to_json(body) | ||||||
|  |     body.is_a?(String) ? Oj.load(body, mode: :strict) : body | ||||||
|  |   rescue Oj::ParseError | ||||||
|  |     nil | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def merge_context(context, new_context) | ||||||
|  |     if context.is_a?(Array) | ||||||
|  |       context << new_context | ||||||
|  |     else | ||||||
|  |       [context, new_context] | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def build_request(uri) | ||||||
|  |     request = Request.new(:get, uri) | ||||||
|  |     request.add_headers('Accept' => 'application/activity+json, application/ld+json') | ||||||
|  |     request | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -12,6 +12,14 @@ module RoutingHelper | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def full_asset_url(source, options = {}) |   def full_asset_url(source, options = {}) | ||||||
|     Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source, options)).to_s |     source = ActionController::Base.helpers.asset_url(source, options) unless use_storage? | ||||||
|  |  | ||||||
|  |     URI.join(root_url, source).to_s | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def use_storage? | ||||||
|  |     Rails.configuration.x.use_s3 || Rails.configuration.x.use_swift | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -30,6 +30,7 @@ module SettingsHelper | |||||||
|     th: 'ภาษาไทย', |     th: 'ภาษาไทย', | ||||||
|     tr: 'Türkçe', |     tr: 'Türkçe', | ||||||
|     uk: 'Українська', |     uk: 'Українська', | ||||||
|  |     zh: '中文', | ||||||
|     'zh-CN': '简体中文', |     'zh-CN': '简体中文', | ||||||
|     'zh-HK': '繁體中文(香港)', |     'zh-HK': '繁體中文(香港)', | ||||||
|     'zh-TW': '繁體中文(臺灣)', |     'zh-TW': '繁體中文(臺灣)', | ||||||
| @@ -39,6 +40,10 @@ module SettingsHelper | |||||||
|     HUMAN_LOCALES[locale] |     HUMAN_LOCALES[locale] | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def filterable_languages | ||||||
|  |     I18n.available_locales.map { |locale| locale.to_s.split('-').first.to_sym }.uniq | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def hash_to_object(hash) |   def hash_to_object(hash) | ||||||
|     HashObject.new(hash) |     HashObject.new(hash) | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
|  |  | ||||||
| module StreamEntriesHelper | module StreamEntriesHelper | ||||||
|   EMBEDDED_CONTROLLER = 'stream_entries' |   EMBEDDED_CONTROLLER = 'statuses' | ||||||
|   EMBEDDED_ACTION = 'embed' |   EMBEDDED_ACTION = 'embed' | ||||||
|  |  | ||||||
|   def display_name(account) |   def display_name(account) | ||||||
|   | |||||||
| @@ -1,6 +1,11 @@ | |||||||
| import api from '../api'; | import api from '../api'; | ||||||
|  |  | ||||||
| import { updateTimeline } from './timelines'; | import { | ||||||
|  |   updateTimeline, | ||||||
|  |   refreshHomeTimeline, | ||||||
|  |   refreshCommunityTimeline, | ||||||
|  |   refreshPublicTimeline, | ||||||
|  | } from './timelines'; | ||||||
|  |  | ||||||
| export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE'; | export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE'; | ||||||
| export const COMPOSE_SUBMIT_REQUEST  = 'COMPOSE_SUBMIT_REQUEST'; | export const COMPOSE_SUBMIT_REQUEST  = 'COMPOSE_SUBMIT_REQUEST'; | ||||||
| @@ -95,16 +100,20 @@ export function submitCompose() { | |||||||
|       dispatch(submitComposeSuccess({ ...response.data })); |       dispatch(submitComposeSuccess({ ...response.data })); | ||||||
|  |  | ||||||
|       // To make the app more responsive, immediately get the status into the columns |       // To make the app more responsive, immediately get the status into the columns | ||||||
|       dispatch(updateTimeline('home', { ...response.data })); |  | ||||||
|  |       const insertOrRefresh = (timelineId, refreshAction) => { | ||||||
|  |         if (getState().getIn(['timelines', timelineId, 'online'])) { | ||||||
|  |           dispatch(updateTimeline(timelineId, { ...response.data })); | ||||||
|  |         } else if (getState().getIn(['timelines', timelineId, 'loaded'])) { | ||||||
|  |           dispatch(refreshAction()); | ||||||
|  |         } | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       insertOrRefresh('home', refreshHomeTimeline); | ||||||
|  |  | ||||||
|       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { |       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { | ||||||
|         if (getState().getIn(['timelines', 'community', 'loaded'])) { |         insertOrRefresh('community', refreshCommunityTimeline); | ||||||
|           dispatch(updateTimeline('community', { ...response.data })); |         insertOrRefresh('public', refreshPublicTimeline); | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (getState().getIn(['timelines', 'public', 'loaded'])) { |  | ||||||
|           dispatch(updateTimeline('public', { ...response.data })); |  | ||||||
|         } |  | ||||||
|       } |       } | ||||||
|     }).catch(function (error) { |     }).catch(function (error) { | ||||||
|       dispatch(submitComposeFail(error)); |       dispatch(submitComposeFail(error)); | ||||||
|   | |||||||
| @@ -24,6 +24,14 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; | |||||||
| export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; | export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; | ||||||
| export const FAVOURITES_FETCH_FAIL    = 'FAVOURITES_FETCH_FAIL'; | export const FAVOURITES_FETCH_FAIL    = 'FAVOURITES_FETCH_FAIL'; | ||||||
|  |  | ||||||
|  | export const PIN_REQUEST = 'PIN_REQUEST'; | ||||||
|  | export const PIN_SUCCESS = 'PIN_SUCCESS'; | ||||||
|  | export const PIN_FAIL    = 'PIN_FAIL'; | ||||||
|  |  | ||||||
|  | export const UNPIN_REQUEST = 'UNPIN_REQUEST'; | ||||||
|  | export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; | ||||||
|  | export const UNPIN_FAIL    = 'UNPIN_FAIL'; | ||||||
|  |  | ||||||
| export function reblog(status) { | export function reblog(status) { | ||||||
|   return function (dispatch, getState) { |   return function (dispatch, getState) { | ||||||
|     dispatch(reblogRequest(status)); |     dispatch(reblogRequest(status)); | ||||||
| @@ -233,3 +241,73 @@ export function fetchFavouritesFail(id, error) { | |||||||
|     error, |     error, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export function pin(status) { | ||||||
|  |   return (dispatch, getState) => { | ||||||
|  |     dispatch(pinRequest(status)); | ||||||
|  |  | ||||||
|  |     api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { | ||||||
|  |       dispatch(pinSuccess(status, response.data)); | ||||||
|  |     }).catch(error => { | ||||||
|  |       dispatch(pinFail(status, error)); | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function pinRequest(status) { | ||||||
|  |   return { | ||||||
|  |     type: PIN_REQUEST, | ||||||
|  |     status, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function pinSuccess(status, response) { | ||||||
|  |   return { | ||||||
|  |     type: PIN_SUCCESS, | ||||||
|  |     status, | ||||||
|  |     response, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function pinFail(status, error) { | ||||||
|  |   return { | ||||||
|  |     type: PIN_FAIL, | ||||||
|  |     status, | ||||||
|  |     error, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function unpin (status) { | ||||||
|  |   return (dispatch, getState) => { | ||||||
|  |     dispatch(unpinRequest(status)); | ||||||
|  |  | ||||||
|  |     api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { | ||||||
|  |       dispatch(unpinSuccess(status, response.data)); | ||||||
|  |     }).catch(error => { | ||||||
|  |       dispatch(unpinFail(status, error)); | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function unpinRequest(status) { | ||||||
|  |   return { | ||||||
|  |     type: UNPIN_REQUEST, | ||||||
|  |     status, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function unpinSuccess(status, response) { | ||||||
|  |   return { | ||||||
|  |     type: UNPIN_SUCCESS, | ||||||
|  |     status, | ||||||
|  |     response, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function unpinFail(status, error) { | ||||||
|  |   return { | ||||||
|  |     type: UNPIN_FAIL, | ||||||
|  |     status, | ||||||
|  |     error, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								app/javascript/mastodon/actions/pin_statuses.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/javascript/mastodon/actions/pin_statuses.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | import api from '../api'; | ||||||
|  |  | ||||||
|  | export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; | ||||||
|  | export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; | ||||||
|  | export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; | ||||||
|  |  | ||||||
|  | export function fetchPinnedStatuses() { | ||||||
|  |   return (dispatch, getState) => { | ||||||
|  |     dispatch(fetchPinnedStatusesRequest()); | ||||||
|  |  | ||||||
|  |     const accountId = getState().getIn(['meta', 'me']); | ||||||
|  |     api(getState).get(`/api/v1/accounts/${accountId}/statuses`, { params: { pinned: true } }).then(response => { | ||||||
|  |       dispatch(fetchPinnedStatusesSuccess(response.data, null)); | ||||||
|  |     }).catch(error => { | ||||||
|  |       dispatch(fetchPinnedStatusesFail(error)); | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function fetchPinnedStatusesRequest() { | ||||||
|  |   return { | ||||||
|  |     type: PINNED_STATUSES_FETCH_REQUEST, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function fetchPinnedStatusesSuccess(statuses, next) { | ||||||
|  |   return { | ||||||
|  |     type: PINNED_STATUSES_FETCH_SUCCESS, | ||||||
|  |     statuses, | ||||||
|  |     next, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function fetchPinnedStatusesFail(error) { | ||||||
|  |   return { | ||||||
|  |     type: PINNED_STATUSES_FETCH_FAIL, | ||||||
|  |     error, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -23,6 +23,9 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; | |||||||
| export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; | export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; | ||||||
| export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL'; | export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL'; | ||||||
|  |  | ||||||
|  | export const STATUS_SET_HEIGHT = 'STATUS_SET_HEIGHT'; | ||||||
|  | export const STATUSES_CLEAR_HEIGHT = 'STATUSES_CLEAR_HEIGHT'; | ||||||
|  |  | ||||||
| export function fetchStatusRequest(id, skipLoading) { | export function fetchStatusRequest(id, skipLoading) { | ||||||
|   return { |   return { | ||||||
|     type: STATUS_FETCH_REQUEST, |     type: STATUS_FETCH_REQUEST, | ||||||
| @@ -215,3 +218,17 @@ export function unmuteStatusFail(id, error) { | |||||||
|     error, |     error, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export function setStatusHeight (id, height) { | ||||||
|  |   return { | ||||||
|  |     type: STATUS_SET_HEIGHT, | ||||||
|  |     id, | ||||||
|  |     height, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function clearStatusesHeight () { | ||||||
|  |   return { | ||||||
|  |     type: STATUSES_CLEAR_HEIGHT, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|   | |||||||
							
								
								
									
										94
									
								
								app/javascript/mastodon/actions/streaming.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								app/javascript/mastodon/actions/streaming.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | |||||||
|  | import createStream from '../stream'; | ||||||
|  | import { | ||||||
|  |   updateTimeline, | ||||||
|  |   deleteFromTimelines, | ||||||
|  |   refreshHomeTimeline, | ||||||
|  |   connectTimeline, | ||||||
|  |   disconnectTimeline, | ||||||
|  | } from './timelines'; | ||||||
|  | import { updateNotifications, refreshNotifications } from './notifications'; | ||||||
|  | import { getLocale } from '../locales'; | ||||||
|  |  | ||||||
|  | const { messages } = getLocale(); | ||||||
|  |  | ||||||
|  | export function connectTimelineStream (timelineId, path, pollingRefresh = null) { | ||||||
|  |   return (dispatch, getState) => { | ||||||
|  |     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); | ||||||
|  |     const accessToken = getState().getIn(['meta', 'access_token']); | ||||||
|  |     const locale = getState().getIn(['meta', 'locale']); | ||||||
|  |     let polling = null; | ||||||
|  |  | ||||||
|  |     const setupPolling = () => { | ||||||
|  |       polling = setInterval(() => { | ||||||
|  |         pollingRefresh(dispatch); | ||||||
|  |       }, 20000); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const clearPolling = () => { | ||||||
|  |       if (polling) { | ||||||
|  |         clearInterval(polling); | ||||||
|  |         polling = null; | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const subscription = createStream(streamingAPIBaseURL, accessToken, path, { | ||||||
|  |  | ||||||
|  |       connected () { | ||||||
|  |         if (pollingRefresh) { | ||||||
|  |           clearPolling(); | ||||||
|  |         } | ||||||
|  |         dispatch(connectTimeline(timelineId)); | ||||||
|  |       }, | ||||||
|  |  | ||||||
|  |       disconnected () { | ||||||
|  |         if (pollingRefresh) { | ||||||
|  |           setupPolling(); | ||||||
|  |         } | ||||||
|  |         dispatch(disconnectTimeline(timelineId)); | ||||||
|  |       }, | ||||||
|  |  | ||||||
|  |       received (data) { | ||||||
|  |         switch(data.event) { | ||||||
|  |         case 'update': | ||||||
|  |           dispatch(updateTimeline(timelineId, JSON.parse(data.payload))); | ||||||
|  |           break; | ||||||
|  |         case 'delete': | ||||||
|  |           dispatch(deleteFromTimelines(data.payload)); | ||||||
|  |           break; | ||||||
|  |         case 'notification': | ||||||
|  |           dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); | ||||||
|  |           break; | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |  | ||||||
|  |       reconnected () { | ||||||
|  |         if (pollingRefresh) { | ||||||
|  |           clearPolling(); | ||||||
|  |           pollingRefresh(dispatch); | ||||||
|  |         } | ||||||
|  |         dispatch(connectTimeline(timelineId)); | ||||||
|  |       }, | ||||||
|  |  | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const disconnect = () => { | ||||||
|  |       if (subscription) { | ||||||
|  |         subscription.close(); | ||||||
|  |       } | ||||||
|  |       clearPolling(); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     return disconnect; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function refreshHomeTimelineAndNotification (dispatch) { | ||||||
|  |   dispatch(refreshHomeTimeline()); | ||||||
|  |   dispatch(refreshNotifications()); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); | ||||||
|  | export const connectCommunityStream = () => connectTimelineStream('community', 'public:local'); | ||||||
|  | export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); | ||||||
|  | export const connectPublicStream = () => connectTimelineStream('public', 'public'); | ||||||
|  | export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); | ||||||
| @@ -26,6 +26,7 @@ export default class Account extends ImmutablePureComponent { | |||||||
|     onBlock: PropTypes.func.isRequired, |     onBlock: PropTypes.func.isRequired, | ||||||
|     onMute: PropTypes.func.isRequired, |     onMute: PropTypes.func.isRequired, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|  |     hidden: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   handleFollow = () => { |   handleFollow = () => { | ||||||
| @@ -41,12 +42,21 @@ export default class Account extends ImmutablePureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { account, me, intl } = this.props; |     const { account, me, intl, hidden } = this.props; | ||||||
|  |  | ||||||
|     if (!account) { |     if (!account) { | ||||||
|       return <div />; |       return <div />; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     if (hidden) { | ||||||
|  |       return ( | ||||||
|  |         <div> | ||||||
|  |           {account.get('display_name')} | ||||||
|  |           {account.get('username')} | ||||||
|  |         </div> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     let buttons; |     let buttons; | ||||||
|  |  | ||||||
|     if (account.get('id') !== me && account.get('relationship', null) !== null) { |     if (account.get('id') !== me && account.get('relationship', null) !== null) { | ||||||
| @@ -70,7 +80,7 @@ export default class Account extends ImmutablePureComponent { | |||||||
|       <div className='account'> |       <div className='account'> | ||||||
|         <div className='account__wrapper'> |         <div className='account__wrapper'> | ||||||
|           <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> |           <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> | ||||||
|             <div className='account__avatar-wrapper'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={36} /></div> |             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> | ||||||
|             <DisplayName account={account} /> |             <DisplayName account={account} /> | ||||||
|           </Permalink> |           </Permalink> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,11 +1,11 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
|  | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
|  |  | ||||||
| export default class Avatar extends React.PureComponent { | export default class Avatar extends React.PureComponent { | ||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     src: PropTypes.string.isRequired, |     account: ImmutablePropTypes.map.isRequired, | ||||||
|     staticSrc: PropTypes.string, |  | ||||||
|     size: PropTypes.number.isRequired, |     size: PropTypes.number.isRequired, | ||||||
|     style: PropTypes.object, |     style: PropTypes.object, | ||||||
|     animate: PropTypes.bool, |     animate: PropTypes.bool, | ||||||
| @@ -33,9 +33,12 @@ export default class Avatar extends React.PureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { src, size, staticSrc, animate, inline } = this.props; |     const { account, size, animate, inline } = this.props; | ||||||
|     const { hovering } = this.state; |     const { hovering } = this.state; | ||||||
|  |  | ||||||
|  |     const src = account.get('avatar'); | ||||||
|  |     const staticSrc = account.get('avatar_static'); | ||||||
|  |  | ||||||
|     let className = 'account__avatar'; |     let className = 'account__avatar'; | ||||||
|  |  | ||||||
|     if (inline) { |     if (inline) { | ||||||
|   | |||||||
| @@ -1,22 +1,22 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
|  |  | ||||||
| export default class AvatarOverlay extends React.PureComponent { | export default class AvatarOverlay extends React.PureComponent { | ||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     staticSrc: PropTypes.string.isRequired, |     account: ImmutablePropTypes.map.isRequired, | ||||||
|     overlaySrc: PropTypes.string.isRequired, |     friend: ImmutablePropTypes.map.isRequired, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   render() { |   render() { | ||||||
|     const { staticSrc, overlaySrc } = this.props; |     const { account, friend } = this.props; | ||||||
|  |  | ||||||
|     const baseStyle = { |     const baseStyle = { | ||||||
|       backgroundImage: `url(${staticSrc})`, |       backgroundImage: `url(${account.get('avatar_static')})`, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     const overlayStyle = { |     const overlayStyle = { | ||||||
|       backgroundImage: `url(${overlaySrc})`, |       backgroundImage: `url(${friend.get('avatar_static')})`, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import detectPassiveEvents from 'detect-passive-events'; | import detectPassiveEvents from 'detect-passive-events'; | ||||||
| import scrollTop from '../scroll'; | import { scrollTop } from '../scroll'; | ||||||
|  |  | ||||||
| export default class Column extends React.PureComponent { | export default class Column extends React.PureComponent { | ||||||
|  |  | ||||||
| @@ -32,7 +32,7 @@ export default class Column extends React.PureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents ? { passive: true } : false); |     this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|   | |||||||
| @@ -1,7 +1,5 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import escapeTextContentForBrowser from 'escape-html'; |  | ||||||
| import emojify from '../emoji'; |  | ||||||
|  |  | ||||||
| export default class DisplayName extends React.PureComponent { | export default class DisplayName extends React.PureComponent { | ||||||
|  |  | ||||||
| @@ -10,12 +8,11 @@ export default class DisplayName extends React.PureComponent { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const displayName     = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name'); |     const displayNameHtml = { __html: this.props.account.get('display_name_html') }; | ||||||
|     const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; |  | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <span className='display-name'> |       <span className='display-name'> | ||||||
|         <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHTML} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span> |         <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span> | ||||||
|       </span> |       </span> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -0,0 +1,122 @@ | |||||||
|  | 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'; | ||||||
|  |  | ||||||
|  | export default class IntersectionObserverArticle extends ImmutablePureComponent { | ||||||
|  |  | ||||||
|  |   static propTypes = { | ||||||
|  |     intersectionObserverWrapper: PropTypes.object, | ||||||
|  |     id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||||||
|  |     index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||||||
|  |     listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||||||
|  |     children: PropTypes.node, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   state = { | ||||||
|  |     isHidden: false, // set to true in requestIdleCallback to trigger un-render | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   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 | ||||||
|  |       return true; | ||||||
|  |     } | ||||||
|  |     // Otherwise, diff based on "updateOnProps" and "updateOnStates" | ||||||
|  |     return super.shouldComponentUpdate(nextProps, nextState); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentDidMount () { | ||||||
|  |     if (!this.props.intersectionObserverWrapper) { | ||||||
|  |       // TODO: enable IntersectionObserver optimization for notification statuses. | ||||||
|  |       // These are managed in notifications/index.js rather than status_list.js | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     this.props.intersectionObserverWrapper.observe( | ||||||
|  |       this.props.id, | ||||||
|  |       this.node, | ||||||
|  |       this.handleIntersection | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     this.componentMounted = true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     if (this.props.intersectionObserverWrapper) { | ||||||
|  |       this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this.componentMounted = false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleIntersection = (entry) => { | ||||||
|  |     if (this.node && this.node.children.length !== 0) { | ||||||
|  |       // save the height of the fully-rendered element | ||||||
|  |       this.height = getRectFromEntry(entry).height; | ||||||
|  |  | ||||||
|  |       if (this.props.onHeightChange) { | ||||||
|  |         this.props.onHeightChange(this.props.status, this.height); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this.setState((prevState) => { | ||||||
|  |       if (prevState.isIntersecting && !entry.isIntersecting) { | ||||||
|  |         scheduleIdleTask(this.hideIfNotIntersecting); | ||||||
|  |       } | ||||||
|  |       return { | ||||||
|  |         isIntersecting: entry.isIntersecting, | ||||||
|  |         isHidden: false, | ||||||
|  |       }; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   hideIfNotIntersecting = () => { | ||||||
|  |     if (!this.componentMounted) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // When the browser gets a chance, test if we're still not intersecting, | ||||||
|  |     // and if so, set our isHidden to true to trigger an unrender. The point of | ||||||
|  |     // this is to save DOM nodes and avoid using up too much memory. | ||||||
|  |     // See: https://github.com/tootsuite/mastodon/issues/2900 | ||||||
|  |     this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleRef = (node) => { | ||||||
|  |     this.node = node; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     const { children, id, index, listLength } = this.props; | ||||||
|  |     const { isIntersecting, isHidden } = this.state; | ||||||
|  |  | ||||||
|  |     if (!isIntersecting && isHidden) { | ||||||
|  |       return ( | ||||||
|  |         <article | ||||||
|  |           ref={this.handleRef} | ||||||
|  |           aria-posinset={index} | ||||||
|  |           aria-setsize={listLength} | ||||||
|  |           style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }} | ||||||
|  |           data-id={id} | ||||||
|  |           tabIndex='0' | ||||||
|  |         > | ||||||
|  |           {children && React.cloneElement(children, { hidden: true })} | ||||||
|  |         </article> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'> | ||||||
|  |         {children && React.cloneElement(children, { hidden: false })} | ||||||
|  |       </article> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										204
									
								
								app/javascript/mastodon/components/scrollable_list.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								app/javascript/mastodon/components/scrollable_list.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,204 @@ | |||||||
|  | import React, { PureComponent } from 'react'; | ||||||
|  | import { ScrollContainer } from 'react-router-scroll'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import IntersectionObserverArticle from './intersection_observer_article'; | ||||||
|  | import LoadMore from './load_more'; | ||||||
|  | import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; | ||||||
|  | import { throttle } from 'lodash'; | ||||||
|  | import { List as ImmutableList } from 'immutable'; | ||||||
|  |  | ||||||
|  | export default class ScrollableList extends PureComponent { | ||||||
|  |  | ||||||
|  |   static propTypes = { | ||||||
|  |     scrollKey: PropTypes.string.isRequired, | ||||||
|  |     onScrollToBottom: PropTypes.func, | ||||||
|  |     onScrollToTop: PropTypes.func, | ||||||
|  |     onScroll: PropTypes.func, | ||||||
|  |     trackScroll: PropTypes.bool, | ||||||
|  |     shouldUpdateScroll: PropTypes.func, | ||||||
|  |     isLoading: PropTypes.bool, | ||||||
|  |     hasMore: PropTypes.bool, | ||||||
|  |     prepend: PropTypes.node, | ||||||
|  |     emptyMessage: PropTypes.node, | ||||||
|  |     children: PropTypes.node, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   static defaultProps = { | ||||||
|  |     trackScroll: true, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   state = { | ||||||
|  |     lastMouseMove: null, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   intersectionObserverWrapper = new IntersectionObserverWrapper(); | ||||||
|  |  | ||||||
|  |   handleScroll = throttle(() => { | ||||||
|  |     if (this.node) { | ||||||
|  |       const { scrollTop, scrollHeight, clientHeight } = this.node; | ||||||
|  |       const offset = scrollHeight - scrollTop - clientHeight; | ||||||
|  |       this._oldScrollPosition = scrollHeight - scrollTop; | ||||||
|  |  | ||||||
|  |       if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { | ||||||
|  |         this.props.onScrollToBottom(); | ||||||
|  |       } else if (scrollTop < 100 && this.props.onScrollToTop) { | ||||||
|  |         this.props.onScrollToTop(); | ||||||
|  |       } else if (this.props.onScroll) { | ||||||
|  |         this.props.onScroll(); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, 150, { | ||||||
|  |     trailing: true, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   handleMouseMove = throttle(() => { | ||||||
|  |     this._lastMouseMove = new Date(); | ||||||
|  |   }, 300); | ||||||
|  |  | ||||||
|  |   handleMouseLeave = () => { | ||||||
|  |     this._lastMouseMove = null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentDidMount () { | ||||||
|  |     this.attachScrollListener(); | ||||||
|  |     this.attachIntersectionObserver(); | ||||||
|  |  | ||||||
|  |     // Handle initial scroll posiiton | ||||||
|  |     this.handleScroll(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentDidUpdate (prevProps) { | ||||||
|  |     const someItemInserted = React.Children.count(prevProps.children) > 0 && | ||||||
|  |       React.Children.count(prevProps.children) < React.Children.count(this.props.children) && | ||||||
|  |       this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); | ||||||
|  |  | ||||||
|  |     // Reset the scroll position when a new child comes in in order not to | ||||||
|  |     // jerk the scrollbar around if you're already scrolled down the page. | ||||||
|  |     if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) { | ||||||
|  |       const newScrollTop = this.node.scrollHeight - this._oldScrollPosition; | ||||||
|  |  | ||||||
|  |       if (this.node.scrollTop !== newScrollTop) { | ||||||
|  |         this.node.scrollTop = newScrollTop; | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     this.detachScrollListener(); | ||||||
|  |     this.detachIntersectionObserver(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   attachIntersectionObserver () { | ||||||
|  |     this.intersectionObserverWrapper.connect({ | ||||||
|  |       root: this.node, | ||||||
|  |       rootMargin: '300% 0px', | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   detachIntersectionObserver () { | ||||||
|  |     this.intersectionObserverWrapper.disconnect(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   attachScrollListener () { | ||||||
|  |     this.node.addEventListener('scroll', this.handleScroll); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   detachScrollListener () { | ||||||
|  |     this.node.removeEventListener('scroll', this.handleScroll); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   getFirstChildKey (props) { | ||||||
|  |     const { children } = props; | ||||||
|  |     let firstChild = children; | ||||||
|  |     if (children instanceof ImmutableList) { | ||||||
|  |       firstChild = children.get(0); | ||||||
|  |     } else if (Array.isArray(children)) { | ||||||
|  |       firstChild = children[0]; | ||||||
|  |     } | ||||||
|  |     return firstChild && firstChild.key; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setRef = (c) => { | ||||||
|  |     this.node = c; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleLoadMore = (e) => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     this.props.onScrollToBottom(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   _recentlyMoved () { | ||||||
|  |     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 childrenCount = React.Children.count(children); | ||||||
|  |  | ||||||
|  |     const loadMore     = <LoadMore visible={!isLoading && childrenCount > 0 && hasMore} onClick={this.handleLoadMore} />; | ||||||
|  |     let scrollableArea = null; | ||||||
|  |  | ||||||
|  |     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}> | ||||||
|  |             {prepend} | ||||||
|  |  | ||||||
|  |             {React.Children.map(this.props.children, (child, index) => ( | ||||||
|  |               <IntersectionObserverArticle key={child.key} id={child.key} index={index} listLength={childrenCount} intersectionObserverWrapper={this.intersectionObserverWrapper}> | ||||||
|  |                 {child} | ||||||
|  |               </IntersectionObserverArticle> | ||||||
|  |             ))} | ||||||
|  |  | ||||||
|  |             {loadMore} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       scrollableArea = ( | ||||||
|  |         <div className='empty-column-indicator' ref={this.setRef}> | ||||||
|  |           {emptyMessage} | ||||||
|  |         </div> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (trackScroll) { | ||||||
|  |       return ( | ||||||
|  |         <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> | ||||||
|  |           {scrollableArea} | ||||||
|  |         </ScrollContainer> | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       return scrollableArea; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -8,16 +8,12 @@ import DisplayName from './display_name'; | |||||||
| import StatusContent from './status_content'; | import StatusContent from './status_content'; | ||||||
| import StatusActionBar from './status_action_bar'; | import StatusActionBar from './status_action_bar'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| import emojify from '../emoji'; |  | ||||||
| import escapeTextContentForBrowser from 'escape-html'; |  | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; |  | ||||||
| import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components'; | import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components'; | ||||||
|  |  | ||||||
| // We use the component (and not the container) since we do not want | // We use the component (and not the container) since we do not want | ||||||
| // to use the progress bar to show download progress | // to use the progress bar to show download progress | ||||||
| import Bundle from '../features/ui/components/bundle'; | import Bundle from '../features/ui/components/bundle'; | ||||||
| import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; |  | ||||||
|  |  | ||||||
| export default class Status extends ImmutablePureComponent { | export default class Status extends ImmutablePureComponent { | ||||||
|  |  | ||||||
| @@ -28,27 +24,25 @@ export default class Status extends ImmutablePureComponent { | |||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     status: ImmutablePropTypes.map, |     status: ImmutablePropTypes.map, | ||||||
|     account: ImmutablePropTypes.map, |     account: ImmutablePropTypes.map, | ||||||
|     wrapped: PropTypes.bool, |  | ||||||
|     onReply: PropTypes.func, |     onReply: PropTypes.func, | ||||||
|     onFavourite: PropTypes.func, |     onFavourite: PropTypes.func, | ||||||
|     onReblog: PropTypes.func, |     onReblog: PropTypes.func, | ||||||
|     onDelete: PropTypes.func, |     onDelete: PropTypes.func, | ||||||
|  |     onPin: PropTypes.func, | ||||||
|     onOpenMedia: PropTypes.func, |     onOpenMedia: PropTypes.func, | ||||||
|     onOpenVideo: PropTypes.func, |     onOpenVideo: PropTypes.func, | ||||||
|     onBlock: PropTypes.func, |     onBlock: PropTypes.func, | ||||||
|  |     onEmbed: PropTypes.func, | ||||||
|  |     onHeightChange: PropTypes.func, | ||||||
|     me: PropTypes.number, |     me: PropTypes.number, | ||||||
|     boostModal: PropTypes.bool, |     boostModal: PropTypes.bool, | ||||||
|     autoPlayGif: PropTypes.bool, |     autoPlayGif: PropTypes.bool, | ||||||
|     muted: PropTypes.bool, |     muted: PropTypes.bool, | ||||||
|     intersectionObserverWrapper: PropTypes.object, |     hidden: PropTypes.bool, | ||||||
|     index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |  | ||||||
|     listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |  | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   state = { |   state = { | ||||||
|     isExpanded: false, |     isExpanded: false, | ||||||
|     isIntersecting: true, // assume intersecting until told otherwise |  | ||||||
|     isHidden: false, // set to true in requestIdleCallback to trigger un-render |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Avoid checking props that are functions (and whose equality will always |   // Avoid checking props that are functions (and whose equality will always | ||||||
| @@ -56,87 +50,15 @@ export default class Status extends ImmutablePureComponent { | |||||||
|   updateOnProps = [ |   updateOnProps = [ | ||||||
|     'status', |     'status', | ||||||
|     'account', |     'account', | ||||||
|     'wrapped', |  | ||||||
|     'me', |     'me', | ||||||
|     'boostModal', |     'boostModal', | ||||||
|     'autoPlayGif', |     'autoPlayGif', | ||||||
|     'muted', |     'muted', | ||||||
|     'listLength', |     'hidden', | ||||||
|   ] |   ] | ||||||
|  |  | ||||||
|   updateOnStates = ['isExpanded'] |   updateOnStates = ['isExpanded'] | ||||||
|  |  | ||||||
|   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 |  | ||||||
|       return true; |  | ||||||
|     } |  | ||||||
|     // Otherwise, diff based on "updateOnProps" and "updateOnStates" |  | ||||||
|     return super.shouldComponentUpdate(nextProps, nextState); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   componentDidMount () { |  | ||||||
|     if (!this.props.intersectionObserverWrapper) { |  | ||||||
|       // TODO: enable IntersectionObserver optimization for notification statuses. |  | ||||||
|       // These are managed in notifications/index.js rather than status_list.js |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|     this.props.intersectionObserverWrapper.observe( |  | ||||||
|       this.props.id, |  | ||||||
|       this.node, |  | ||||||
|       this.handleIntersection |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     this.componentMounted = true; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   componentWillUnmount () { |  | ||||||
|     if (this.props.intersectionObserverWrapper) { |  | ||||||
|       this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this.componentMounted = false; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleIntersection = (entry) => { |  | ||||||
|     if (this.node && this.node.children.length !== 0) { |  | ||||||
|       // save the height of the fully-rendered element |  | ||||||
|       this.height = getRectFromEntry(entry).height; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this.setState((prevState) => { |  | ||||||
|       if (prevState.isIntersecting && !entry.isIntersecting) { |  | ||||||
|         scheduleIdleTask(this.hideIfNotIntersecting); |  | ||||||
|       } |  | ||||||
|       return { |  | ||||||
|         isIntersecting: entry.isIntersecting, |  | ||||||
|         isHidden: false, |  | ||||||
|       }; |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   hideIfNotIntersecting = () => { |  | ||||||
|     if (!this.componentMounted) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // When the browser gets a chance, test if we're still not intersecting, |  | ||||||
|     // and if so, set our isHidden to true to trigger an unrender. The point of |  | ||||||
|     // this is to save DOM nodes and avoid using up too much memory. |  | ||||||
|     // See: https://github.com/tootsuite/mastodon/issues/2900 |  | ||||||
|     this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleRef = (node) => { |  | ||||||
|     this.node = node; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleClick = () => { |   handleClick = () => { | ||||||
|     if (!this.context.router) { |     if (!this.context.router) { | ||||||
|       return; |       return; | ||||||
| @@ -170,42 +92,34 @@ export default class Status extends ImmutablePureComponent { | |||||||
|     let media = null; |     let media = null; | ||||||
|     let statusAvatar; |     let statusAvatar; | ||||||
|  |  | ||||||
|     // Exclude intersectionObserverWrapper from `other` variable |     const { status, account, hidden, ...other } = this.props; | ||||||
|     // because intersection is managed in here. |     const { isExpanded } = this.state; | ||||||
|     const { status, account, intersectionObserverWrapper, index, listLength, wrapped, ...other } = this.props; |  | ||||||
|     const { isExpanded, isIntersecting, isHidden } = this.state; |  | ||||||
|  |  | ||||||
|     if (status === null) { |     if (status === null) { | ||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (!isIntersecting && isHidden) { |     if (hidden) { | ||||||
|       return ( |       return ( | ||||||
|         <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}> |         <div> | ||||||
|           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} |           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} | ||||||
|           {status.get('content')} |           {status.get('content')} | ||||||
|         </article> |         </div> | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { |     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | ||||||
|       let displayName = status.getIn(['account', 'display_name']); |       const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; | ||||||
|  |  | ||||||
|       if (displayName.length === 0) { |  | ||||||
|         displayName = status.getIn(['account', 'username']); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; |  | ||||||
|  |  | ||||||
|       return ( |       return ( | ||||||
|         <article className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0'> |         <div className='status__wrapper' data-id={status.get('id')} > | ||||||
|           <div className='status__prepend'> |           <div className='status__prepend'> | ||||||
|             <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> |             <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> | ||||||
|             <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} /> |             <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} /> | ||||||
|           </div> |           </div> | ||||||
|  |  | ||||||
|           <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} /> |           <Status {...other} status={status.get('reblog')} account={status.get('account')} /> | ||||||
|         </article> |         </div> | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -228,13 +142,13 @@ export default class Status extends ImmutablePureComponent { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (account === undefined || account === null) { |     if (account === undefined || account === null) { | ||||||
|       statusAvatar = <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />; |       statusAvatar = <Avatar account={status.get('account')} size={48} />; | ||||||
|     }else{ |     }else{ | ||||||
|       statusAvatar = <AvatarOverlay staticSrc={status.getIn(['account', 'avatar_static'])} overlaySrc={account.get('avatar_static')} />; |       statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <article aria-posinset={index} aria-setsize={listLength} className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} tabIndex={wrapped ? null : '0'}  ref={this.handleRef}> |       <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}> | ||||||
|         <div className='status__info'> |         <div className='status__info'> | ||||||
|           <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> |           <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> | ||||||
|  |  | ||||||
| @@ -252,7 +166,7 @@ export default class Status extends ImmutablePureComponent { | |||||||
|         {media} |         {media} | ||||||
|  |  | ||||||
|         <StatusActionBar {...this.props} /> |         <StatusActionBar {...this.props} /> | ||||||
|       </article> |       </div> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -21,6 +21,9 @@ const messages = defineMessages({ | |||||||
|   report: { id: 'status.report', defaultMessage: 'Report @{name}' }, |   report: { id: 'status.report', defaultMessage: 'Report @{name}' }, | ||||||
|   muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, |   muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, | ||||||
|   unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, |   unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, | ||||||
|  |   pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, | ||||||
|  |   unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, | ||||||
|  |   embed: { id: 'status.embed', defaultMessage: 'Embed' }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @injectIntl | @injectIntl | ||||||
| @@ -40,7 +43,9 @@ export default class StatusActionBar extends ImmutablePureComponent { | |||||||
|     onMute: PropTypes.func, |     onMute: PropTypes.func, | ||||||
|     onBlock: PropTypes.func, |     onBlock: PropTypes.func, | ||||||
|     onReport: PropTypes.func, |     onReport: PropTypes.func, | ||||||
|  |     onEmbed: PropTypes.func, | ||||||
|     onMuteConversation: PropTypes.func, |     onMuteConversation: PropTypes.func, | ||||||
|  |     onPin: PropTypes.func, | ||||||
|     me: PropTypes.number, |     me: PropTypes.number, | ||||||
|     withDismiss: PropTypes.bool, |     withDismiss: PropTypes.bool, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
| @@ -77,6 +82,10 @@ export default class StatusActionBar extends ImmutablePureComponent { | |||||||
|     this.props.onDelete(this.props.status); |     this.props.onDelete(this.props.status); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   handlePinClick = () => { | ||||||
|  |     this.props.onPin(this.props.status); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   handleMentionClick = () => { |   handleMentionClick = () => { | ||||||
|     this.props.onMention(this.props.status.get('account'), this.context.router.history); |     this.props.onMention(this.props.status.get('account'), this.context.router.history); | ||||||
|   } |   } | ||||||
| @@ -93,6 +102,10 @@ export default class StatusActionBar extends ImmutablePureComponent { | |||||||
|     this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); |     this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   handleEmbed = () => { | ||||||
|  |     this.props.onEmbed(this.props.status); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   handleReport = () => { |   handleReport = () => { | ||||||
|     this.props.onReport(this.props.status); |     this.props.onReport(this.props.status); | ||||||
|   } |   } | ||||||
| @@ -103,9 +116,10 @@ export default class StatusActionBar extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { status, me, intl, withDismiss } = this.props; |     const { status, me, intl, withDismiss } = this.props; | ||||||
|     const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct'; |  | ||||||
|     const mutingConversation = status.get('muted'); |     const mutingConversation = status.get('muted'); | ||||||
|     const anonymousAccess    = !me; |     const anonymousAccess    = !me; | ||||||
|  |     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility')); | ||||||
|  |  | ||||||
|     let menu = []; |     let menu = []; | ||||||
|     let reblogIcon = 'retweet'; |     let reblogIcon = 'retweet'; | ||||||
| @@ -113,14 +127,23 @@ export default class StatusActionBar extends ImmutablePureComponent { | |||||||
|     let replyTitle; |     let replyTitle; | ||||||
|  |  | ||||||
|     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); |     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); | ||||||
|  |  | ||||||
|  |     if (publicStatus) { | ||||||
|  |       menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     menu.push(null); |     menu.push(null); | ||||||
|  |  | ||||||
|     if (withDismiss) { |     if (status.getIn(['account', 'id']) === me || withDismiss) { | ||||||
|       menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); |       menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); | ||||||
|       menu.push(null); |       menu.push(null); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (status.getIn(['account', 'id']) === me) { |     if (status.getIn(['account', 'id']) === me) { | ||||||
|  |       if (publicStatus) { | ||||||
|  |         menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); | ||||||
|  |       } | ||||||
|  |  | ||||||
|       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); |       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); | ||||||
|     } else { |     } else { | ||||||
|       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); |       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); | ||||||
| @@ -151,7 +174,7 @@ export default class StatusActionBar extends ImmutablePureComponent { | |||||||
|     return ( |     return ( | ||||||
|       <div className='status__action-bar'> |       <div className='status__action-bar'> | ||||||
|         <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> |         <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> | ||||||
|         <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> |         <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> | ||||||
|         <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> |         <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> | ||||||
|         {shareButton} |         {shareButton} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,8 +1,6 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import escapeTextContentForBrowser from 'escape-html'; |  | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import emojify from '../emoji'; |  | ||||||
| import { isRtl } from '../rtl'; | import { isRtl } from '../rtl'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| import Permalink from './permalink'; | import Permalink from './permalink'; | ||||||
| @@ -119,8 +117,8 @@ export default class StatusContent extends React.PureComponent { | |||||||
|  |  | ||||||
|     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; |     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; | ||||||
|  |  | ||||||
|     const content = { __html: emojify(status.get('content')) }; |     const content = { __html: status.get('contentHtml') }; | ||||||
|     const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) }; |     const spoilerContent = { __html: status.get('spoilerHtml') }; | ||||||
|     const directionStyle = { direction: 'ltr' }; |     const directionStyle = { direction: 'ltr' }; | ||||||
|     const classNames = classnames('status__content', { |     const classNames = classnames('status__content', { | ||||||
|       'status__content--with-action': this.props.onClick && this.context.router, |       'status__content--with-action': this.props.onClick && this.context.router, | ||||||
|   | |||||||
| @@ -1,12 +1,9 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import { ScrollContainer } from 'react-router-scroll'; |  | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import StatusContainer from '../containers/status_container'; | import StatusContainer from '../containers/status_container'; | ||||||
| import LoadMore from './load_more'; |  | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; | import ScrollableList from './scrollable_list'; | ||||||
| import { throttle } from 'lodash'; |  | ||||||
|  |  | ||||||
| export default class StatusList extends ImmutablePureComponent { | export default class StatusList extends ImmutablePureComponent { | ||||||
|  |  | ||||||
| @@ -28,145 +25,21 @@ export default class StatusList extends ImmutablePureComponent { | |||||||
|     trackScroll: true, |     trackScroll: true, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   intersectionObserverWrapper = new IntersectionObserverWrapper(); |  | ||||||
|  |  | ||||||
|   handleScroll = throttle(() => { |  | ||||||
|     if (this.node) { |  | ||||||
|       const { scrollTop, scrollHeight, clientHeight } = this.node; |  | ||||||
|       const offset = scrollHeight - scrollTop - clientHeight; |  | ||||||
|       this._oldScrollPosition = scrollHeight - scrollTop; |  | ||||||
|  |  | ||||||
|       if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { |  | ||||||
|         this.props.onScrollToBottom(); |  | ||||||
|       } else if (scrollTop < 100 && this.props.onScrollToTop) { |  | ||||||
|         this.props.onScrollToTop(); |  | ||||||
|       } else if (this.props.onScroll) { |  | ||||||
|         this.props.onScroll(); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   }, 150, { |  | ||||||
|     trailing: true, |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   componentDidMount () { |  | ||||||
|     this.attachScrollListener(); |  | ||||||
|     this.attachIntersectionObserver(); |  | ||||||
|  |  | ||||||
|     // Handle initial scroll posiiton |  | ||||||
|     this.handleScroll(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   componentDidUpdate (prevProps) { |  | ||||||
|     // Reset the scroll position when a new toot comes in in order not to |  | ||||||
|     // jerk the scrollbar around if you're already scrolled down the page. |  | ||||||
|     if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) { |  | ||||||
|       if (prevProps.statusIds.first() !== this.props.statusIds.first()) { |  | ||||||
|         let newScrollTop = this.node.scrollHeight - this._oldScrollPosition; |  | ||||||
|         if (this.node.scrollTop !== newScrollTop) { |  | ||||||
|           this.node.scrollTop = newScrollTop; |  | ||||||
|         } |  | ||||||
|       } else { |  | ||||||
|         this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   componentWillUnmount () { |  | ||||||
|     this.detachScrollListener(); |  | ||||||
|     this.detachIntersectionObserver(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   attachIntersectionObserver () { |  | ||||||
|     this.intersectionObserverWrapper.connect({ |  | ||||||
|       root: this.node, |  | ||||||
|       rootMargin: '300% 0px', |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   detachIntersectionObserver () { |  | ||||||
|     this.intersectionObserverWrapper.disconnect(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   attachScrollListener () { |  | ||||||
|     this.node.addEventListener('scroll', this.handleScroll); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   detachScrollListener () { |  | ||||||
|     this.node.removeEventListener('scroll', this.handleScroll); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   setRef = (c) => { |  | ||||||
|     this.node = c; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleLoadMore = (e) => { |  | ||||||
|     e.preventDefault(); |  | ||||||
|     this.props.onScrollToBottom(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleKeyDown = (e) => { |  | ||||||
|     if (['PageDown', 'PageUp', 'End', 'Home'].includes(e.key)) { |  | ||||||
|       const article = (() => { |  | ||||||
|         switch (e.key) { |  | ||||||
|         case 'PageDown': |  | ||||||
|           return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; |  | ||||||
|         case 'PageUp': |  | ||||||
|           return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; |  | ||||||
|         case 'End': |  | ||||||
|           return this.node.querySelector('[role="feed"] > article:last-of-type'); |  | ||||||
|         case 'Home': |  | ||||||
|           return this.node.querySelector('[role="feed"] > article:first-of-type'); |  | ||||||
|         default: |  | ||||||
|           return null; |  | ||||||
|         } |  | ||||||
|       })(); |  | ||||||
|  |  | ||||||
|  |  | ||||||
|       if (article) { |  | ||||||
|         e.preventDefault(); |  | ||||||
|         article.focus(); |  | ||||||
|         article.scrollIntoView(); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; |     const { statusIds, ...other } = this.props; | ||||||
|  |     const { isLoading } = other; | ||||||
|  |  | ||||||
|     const loadMore     = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />; |     const scrollableContent = (isLoading || statusIds.size > 0) ? ( | ||||||
|     let scrollableArea = null; |       statusIds.map((statusId) => ( | ||||||
|  |         <StatusContainer key={statusId} id={statusId} /> | ||||||
|  |       )) | ||||||
|  |     ) : null; | ||||||
|  |  | ||||||
|     if (isLoading || statusIds.size > 0 || !emptyMessage) { |  | ||||||
|       scrollableArea = ( |  | ||||||
|         <div className='scrollable' ref={this.setRef}> |  | ||||||
|           <div role='feed' className='status-list' onKeyDown={this.handleKeyDown}> |  | ||||||
|             {prepend} |  | ||||||
|  |  | ||||||
|             {statusIds.map((statusId, index) => { |  | ||||||
|               return <StatusContainer key={statusId} id={statusId} index={index} listLength={statusIds.size} intersectionObserverWrapper={this.intersectionObserverWrapper} />; |  | ||||||
|             })} |  | ||||||
|  |  | ||||||
|             {loadMore} |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
|     } else { |  | ||||||
|       scrollableArea = ( |  | ||||||
|         <div className='empty-column-indicator' ref={this.setRef}> |  | ||||||
|           {emptyMessage} |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (trackScroll) { |  | ||||||
|     return ( |     return ( | ||||||
|         <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> |       <ScrollableList {...other}> | ||||||
|           {scrollableArea} |         {scrollableContent} | ||||||
|         </ScrollContainer> |       </ScrollableList> | ||||||
|     ); |     ); | ||||||
|     } else { |  | ||||||
|       return scrollableArea; |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -146,29 +146,29 @@ export default class VideoPlayer extends React.PureComponent { | |||||||
|     if (!this.state.visible) { |     if (!this.state.visible) { | ||||||
|       if (sensitive) { |       if (sensitive) { | ||||||
|         return ( |         return ( | ||||||
|           <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> |           <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> | ||||||
|             {spoilerButton} |             {spoilerButton} | ||||||
|             <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> |             <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> |             <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> | ||||||
|           </div> |           </button> | ||||||
|         ); |         ); | ||||||
|       } else { |       } else { | ||||||
|         return ( |         return ( | ||||||
|           <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> |           <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> | ||||||
|             {spoilerButton} |             {spoilerButton} | ||||||
|             <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> |             <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> |             <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> | ||||||
|           </div> |           </button> | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (this.state.preview && !autoplay) { |     if (this.state.preview && !autoplay) { | ||||||
|       return ( |       return ( | ||||||
|         <div role='button' tabIndex='0' className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}> |         <button className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}> | ||||||
|           {spoilerButton} |           {spoilerButton} | ||||||
|           <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div> |           <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div> | ||||||
|         </div> |         </button> | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ const makeMapStateToProps = () => { | |||||||
| const mapDispatchToProps = (dispatch, { intl }) => ({ | const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||||
|  |  | ||||||
|   onFollow (account) { |   onFollow (account) { | ||||||
|     if (account.getIn(['relationship', 'following'])) { |     if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { | ||||||
|       if (this.unfollowModal) { |       if (this.unfollowModal) { | ||||||
|         dispatch(openModal('CONFIRM', { |         dispatch(openModal('CONFIRM', { | ||||||
|           message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, |           message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								app/javascript/mastodon/containers/compose_container.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/javascript/mastodon/containers/compose_container.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { Provider } from 'react-redux'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import configureStore from '../store/configureStore'; | ||||||
|  | import { hydrateStore } from '../actions/store'; | ||||||
|  | import { IntlProvider, addLocaleData } from 'react-intl'; | ||||||
|  | import { getLocale } from '../locales'; | ||||||
|  | import Compose from '../features/standalone/compose'; | ||||||
|  |  | ||||||
|  | const { localeData, messages } = getLocale(); | ||||||
|  | addLocaleData(localeData); | ||||||
|  |  | ||||||
|  | const store = configureStore(); | ||||||
|  | const initialStateContainer = document.getElementById('initial-state'); | ||||||
|  |  | ||||||
|  | if (initialStateContainer !== null) { | ||||||
|  |   const initialState = JSON.parse(initialStateContainer.textContent); | ||||||
|  |   store.dispatch(hydrateStore(initialState)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default class TimelineContainer extends React.PureComponent { | ||||||
|  |  | ||||||
|  |   static propTypes = { | ||||||
|  |     locale: PropTypes.string.isRequired, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     const { locale } = this.props; | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <IntlProvider locale={locale} messages={messages}> | ||||||
|  |         <Provider store={store}> | ||||||
|  |           <Compose /> | ||||||
|  |         </Provider> | ||||||
|  |       </IntlProvider> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -2,21 +2,13 @@ import React from 'react'; | |||||||
| import { Provider } from 'react-redux'; | import { Provider } from 'react-redux'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import configureStore from '../store/configureStore'; | import configureStore from '../store/configureStore'; | ||||||
| import { |  | ||||||
|   updateTimeline, |  | ||||||
|   deleteFromTimelines, |  | ||||||
|   refreshHomeTimeline, |  | ||||||
|   connectTimeline, |  | ||||||
|   disconnectTimeline, |  | ||||||
| } from '../actions/timelines'; |  | ||||||
| import { showOnboardingOnce } from '../actions/onboarding'; | import { showOnboardingOnce } from '../actions/onboarding'; | ||||||
| import { updateNotifications, refreshNotifications } from '../actions/notifications'; |  | ||||||
| import BrowserRouter from 'react-router-dom/BrowserRouter'; | import BrowserRouter from 'react-router-dom/BrowserRouter'; | ||||||
| import Route from 'react-router-dom/Route'; | import Route from 'react-router-dom/Route'; | ||||||
| import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext'; | import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext'; | ||||||
| import UI from '../features/ui'; | import UI from '../features/ui'; | ||||||
| import { hydrateStore } from '../actions/store'; | import { hydrateStore } from '../actions/store'; | ||||||
| import createStream from '../stream'; | import { connectUserStream } from '../actions/streaming'; | ||||||
| import { IntlProvider, addLocaleData } from 'react-intl'; | import { IntlProvider, addLocaleData } from 'react-intl'; | ||||||
| import { getLocale } from '../locales'; | import { getLocale } from '../locales'; | ||||||
| const { localeData, messages } = getLocale(); | const { localeData, messages } = getLocale(); | ||||||
| @@ -33,74 +25,28 @@ export default class Mastodon extends React.PureComponent { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   componentDidMount() { |   componentDidMount() { | ||||||
|     const { locale }  = this.props; |     this.disconnect = store.dispatch(connectUserStream()); | ||||||
|     const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']); |  | ||||||
|     const accessToken = store.getState().getIn(['meta', 'access_token']); |  | ||||||
|  |  | ||||||
|     const setupPolling = () => { |  | ||||||
|       this.polling = setInterval(() => { |  | ||||||
|         store.dispatch(refreshHomeTimeline()); |  | ||||||
|         store.dispatch(refreshNotifications()); |  | ||||||
|       }, 20000); |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     const clearPolling = () => { |  | ||||||
|       clearInterval(this.polling); |  | ||||||
|       this.polling = undefined; |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', { |  | ||||||
|  |  | ||||||
|       connected () { |  | ||||||
|         clearPolling(); |  | ||||||
|         store.dispatch(connectTimeline('home')); |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|       disconnected () { |  | ||||||
|         setupPolling(); |  | ||||||
|         store.dispatch(disconnectTimeline('home')); |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|       received (data) { |  | ||||||
|         switch(data.event) { |  | ||||||
|         case 'update': |  | ||||||
|           store.dispatch(updateTimeline('home', JSON.parse(data.payload))); |  | ||||||
|           break; |  | ||||||
|         case 'delete': |  | ||||||
|           store.dispatch(deleteFromTimelines(data.payload)); |  | ||||||
|           break; |  | ||||||
|         case 'notification': |  | ||||||
|           store.dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|       reconnected () { |  | ||||||
|         clearPolling(); |  | ||||||
|         store.dispatch(connectTimeline('home')); |  | ||||||
|         store.dispatch(refreshHomeTimeline()); |  | ||||||
|         store.dispatch(refreshNotifications()); |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     // Desktop notifications |     // Desktop notifications | ||||||
|  |     // Ask after 1 minute | ||||||
|     if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { |     if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { | ||||||
|       Notification.requestPermission(); |       window.setTimeout(() => Notification.requestPermission(), 60 * 1000); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Protocol handler | ||||||
|  |     // Ask after 5 minutes | ||||||
|  |     if (typeof navigator.registerProtocolHandler !== 'undefined') { | ||||||
|  |       const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s'; | ||||||
|  |       window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     store.dispatch(showOnboardingOnce()); |     store.dispatch(showOnboardingOnce()); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|     if (typeof this.subscription !== 'undefined') { |     if (this.disconnect) { | ||||||
|       this.subscription.close(); |       this.disconnect(); | ||||||
|       this.subscription = null; |       this.disconnect = null; | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (typeof this.polling !== 'undefined') { |  | ||||||
|       clearInterval(this.polling); |  | ||||||
|       this.polling = null; |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -11,12 +11,14 @@ import { | |||||||
|   favourite, |   favourite, | ||||||
|   unreblog, |   unreblog, | ||||||
|   unfavourite, |   unfavourite, | ||||||
|  |   pin, | ||||||
|  |   unpin, | ||||||
| } from '../actions/interactions'; | } from '../actions/interactions'; | ||||||
| import { | import { | ||||||
|   blockAccount, |   blockAccount, | ||||||
|   muteAccount, |   muteAccount, | ||||||
| } from '../actions/accounts'; | } from '../actions/accounts'; | ||||||
| import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; | import { muteStatus, unmuteStatus, deleteStatus, setStatusHeight } from '../actions/statuses'; | ||||||
| import { initReport } from '../actions/reports'; | import { initReport } from '../actions/reports'; | ||||||
| import { openModal } from '../actions/modal'; | import { openModal } from '../actions/modal'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| @@ -72,6 +74,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|  |   onPin (status) { | ||||||
|  |     if (status.get('pinned')) { | ||||||
|  |       dispatch(unpin(status)); | ||||||
|  |     } else { | ||||||
|  |       dispatch(pin(status)); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   onEmbed (status) { | ||||||
|  |     dispatch(openModal('EMBED', { url: status.get('url') })); | ||||||
|  |   }, | ||||||
|  |  | ||||||
|   onDelete (status) { |   onDelete (status) { | ||||||
|     if (!this.deleteModal) { |     if (!this.deleteModal) { | ||||||
|       dispatch(deleteStatus(status.get('id'))); |       dispatch(deleteStatus(status.get('id'))); | ||||||
| @@ -124,6 +138,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|  |   onHeightChange (status, height) { | ||||||
|  |     dispatch(setStatusHeight(status.get('id'), height)); | ||||||
|  |   }, | ||||||
|  |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); | export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); | ||||||
|   | |||||||
| @@ -3,34 +3,28 @@ import Trie from 'substring-trie'; | |||||||
|  |  | ||||||
| const trie = new Trie(Object.keys(unicodeMapping)); | const trie = new Trie(Object.keys(unicodeMapping)); | ||||||
|  |  | ||||||
| const excluded = ['™', '©', '®']; | const emojify = str => { | ||||||
|  |   let rtn = ''; | ||||||
| function emojify(str) { |   for (;;) { | ||||||
|   // This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.) |     let match, i = 0; | ||||||
|   // and replacing valid unicode strings |     while (i < str.length && str[i] !== '<' && !(match = trie.search(str.slice(i)))) { | ||||||
|   // that _aren't_ within tags with an <img> version. |       i += str.codePointAt(i) < 65536 ? 1 : 2; | ||||||
|   // The goal is to be the same as an emojione.regUnicode replacement, but faster. |     } | ||||||
|   let i = -1; |     if (i === str.length) | ||||||
|   let insideTag = false; |       break; | ||||||
|   let match; |     else if (str[i] === '<') { | ||||||
|   while (++i < str.length) { |       let tagend = str.indexOf('>', i + 1) + 1; | ||||||
|     const char = str.charAt(i); |       if (!tagend) | ||||||
|     if (insideTag && char === '>') { |         break; | ||||||
|       insideTag = false; |       rtn += str.slice(0, tagend); | ||||||
|     } else if (char === '<') { |       str = str.slice(tagend); | ||||||
|       insideTag = true; |     } else { | ||||||
|     } else if (!insideTag && (match = trie.search(str.substring(i)))) { |       const [filename, shortCode] = unicodeMapping[match]; | ||||||
|       const unicodeStr = match; |       rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`; | ||||||
|       if (unicodeStr in unicodeMapping && excluded.indexOf(unicodeStr) === -1) { |       str = str.slice(i + match.length); | ||||||
|         const [filename, shortCode] = unicodeMapping[unicodeStr]; |  | ||||||
|         const alt      = unicodeStr; |  | ||||||
|         const replacement =  `<img draggable="false" class="emojione" alt="${alt}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`; |  | ||||||
|         str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length); |  | ||||||
|         i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   } |   return rtn + str; | ||||||
|   return str; | }; | ||||||
| } |  | ||||||
|  |  | ||||||
| export default emojify; | export default emojify; | ||||||
|   | |||||||
| @@ -4,8 +4,10 @@ | |||||||
| const emojione = require('emojione'); | const emojione = require('emojione'); | ||||||
|  |  | ||||||
| const mappedUnicode = emojione.mapUnicodeToShort(); | const mappedUnicode = emojione.mapUnicodeToShort(); | ||||||
|  | const excluded = ['®', '©', '™']; | ||||||
|  |  | ||||||
| module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap) | module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap) | ||||||
|  |   .filter(c => !excluded.includes(c)) | ||||||
|   .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]]) |   .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]]) | ||||||
|   .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] })) |   .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] })) | ||||||
|   .reduce((x, y) => Object.assign(x, y), { }); |   .reduce((x, y) => Object.assign(x, y), { }); | ||||||
|   | |||||||
| @@ -1,8 +1,6 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import emojify from '../../../emoji'; |  | ||||||
| import escapeTextContentForBrowser from 'escape-html'; |  | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import IconButton from '../../../components/icon_button'; | import IconButton from '../../../components/icon_button'; | ||||||
| import Motion from 'react-motion/lib/Motion'; | import Motion from 'react-motion/lib/Motion'; | ||||||
| @@ -13,7 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; | |||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, |   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, | ||||||
|   follow: { id: 'account.follow', defaultMessage: 'Follow' }, |   follow: { id: 'account.follow', defaultMessage: 'Follow' }, | ||||||
|   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, |   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const makeMapStateToProps = () => { | const makeMapStateToProps = () => { | ||||||
| @@ -92,15 +90,10 @@ export default class Header extends ImmutablePureComponent { | |||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     let displayName = account.get('display_name'); |  | ||||||
|     let info        = ''; |     let info        = ''; | ||||||
|     let actionBtn   = ''; |     let actionBtn   = ''; | ||||||
|     let lockedIcon  = ''; |     let lockedIcon  = ''; | ||||||
|  |  | ||||||
|     if (displayName.length === 0) { |  | ||||||
|       displayName = account.get('username'); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { |     if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { | ||||||
|       info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>; |       info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>; | ||||||
|     } |     } | ||||||
| @@ -109,7 +102,7 @@ export default class Header extends ImmutablePureComponent { | |||||||
|       if (account.getIn(['relationship', 'requested'])) { |       if (account.getIn(['relationship', 'requested'])) { | ||||||
|         actionBtn = ( |         actionBtn = ( | ||||||
|           <div className='account--action-button'> |           <div className='account--action-button'> | ||||||
|             <IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} /> |             <IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} /> | ||||||
|           </div> |           </div> | ||||||
|         ); |         ); | ||||||
|       } else if (!account.getIn(['relationship', 'blocking'])) { |       } else if (!account.getIn(['relationship', 'blocking'])) { | ||||||
| @@ -125,15 +118,15 @@ export default class Header extends ImmutablePureComponent { | |||||||
|       lockedIcon = <i className='fa fa-lock' />; |       lockedIcon = <i className='fa fa-lock' />; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const content         = { __html: emojify(account.get('note')) }; |     const content         = { __html: account.get('note_emojified') }; | ||||||
|     const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; |     const displayNameHtml = { __html: account.get('display_name_html') }; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> |       <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> | ||||||
|         <div> |         <div> | ||||||
|           <Avatar account={account} autoPlayGif={this.props.autoPlayGif} /> |           <Avatar account={account} autoPlayGif={this.props.autoPlayGif} /> | ||||||
|  |  | ||||||
|           <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> |           <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} /> | ||||||
|           <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span> |           <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span> | ||||||
|           <div className='account__header__content' dangerouslySetInnerHTML={content} /> |           <div className='account__header__content' dangerouslySetInnerHTML={content} /> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -38,7 +38,7 @@ const makeMapStateToProps = () => { | |||||||
| const mapDispatchToProps = (dispatch, { intl }) => ({ | const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||||
|  |  | ||||||
|   onFollow (account) { |   onFollow (account) { | ||||||
|     if (account.getIn(['relationship', 'following'])) { |     if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { | ||||||
|       if (this.unfollowModal) { |       if (this.unfollowModal) { | ||||||
|         dispatch(openModal('CONFIRM', { |         dispatch(openModal('CONFIRM', { | ||||||
|           message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, |           message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, | ||||||
|   | |||||||
| @@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header'; | |||||||
| import { | import { | ||||||
|   refreshCommunityTimeline, |   refreshCommunityTimeline, | ||||||
|   expandCommunityTimeline, |   expandCommunityTimeline, | ||||||
|   updateTimeline, |  | ||||||
|   deleteFromTimelines, |  | ||||||
|   connectTimeline, |  | ||||||
|   disconnectTimeline, |  | ||||||
| } from '../../actions/timelines'; | } from '../../actions/timelines'; | ||||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import ColumnSettingsContainer from './containers/column_settings_container'; | import ColumnSettingsContainer from './containers/column_settings_container'; | ||||||
| import createStream from '../../stream'; | import { connectCommunityStream } from '../../actions/streaming'; | ||||||
|  |  | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   title: { id: 'column.community', defaultMessage: 'Local timeline' }, |   title: { id: 'column.community', defaultMessage: 'Local timeline' }, | ||||||
| @@ -23,8 +19,6 @@ const messages = defineMessages({ | |||||||
|  |  | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, |   hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, | ||||||
|   streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), |  | ||||||
|   accessToken: state.getIn(['meta', 'access_token']), |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @connect(mapStateToProps) | @connect(mapStateToProps) | ||||||
| @@ -35,8 +29,6 @@ export default class CommunityTimeline extends React.PureComponent { | |||||||
|     dispatch: PropTypes.func.isRequired, |     dispatch: PropTypes.func.isRequired, | ||||||
|     columnId: PropTypes.string, |     columnId: PropTypes.string, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|     streamingAPIBaseURL: PropTypes.string.isRequired, |  | ||||||
|     accessToken: PropTypes.string.isRequired, |  | ||||||
|     hasUnread: PropTypes.bool, |     hasUnread: PropTypes.bool, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| @@ -61,46 +53,16 @@ export default class CommunityTimeline extends React.PureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     const { dispatch, streamingAPIBaseURL, accessToken } = this.props; |     const { dispatch } = this.props; | ||||||
|  |  | ||||||
|     dispatch(refreshCommunityTimeline()); |     dispatch(refreshCommunityTimeline()); | ||||||
|  |     this.disconnect = dispatch(connectCommunityStream()); | ||||||
|     if (typeof this._subscription !== 'undefined') { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { |  | ||||||
|  |  | ||||||
|       connected () { |  | ||||||
|         dispatch(connectTimeline('community')); |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|       reconnected () { |  | ||||||
|         dispatch(connectTimeline('community')); |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|       disconnected () { |  | ||||||
|         dispatch(disconnectTimeline('community')); |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|       received (data) { |  | ||||||
|         switch(data.event) { |  | ||||||
|         case 'update': |  | ||||||
|           dispatch(updateTimeline('community', JSON.parse(data.payload))); |  | ||||||
|           break; |  | ||||||
|         case 'delete': |  | ||||||
|           dispatch(deleteFromTimelines(data.payload)); |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|     }); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|     if (typeof this._subscription !== 'undefined') { |     if (this.disconnect) { | ||||||
|       this._subscription.close(); |       this.disconnect(); | ||||||
|       this._subscription = null; |       this.disconnect = null; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ export default class AutosuggestAccount extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className='autosuggest-account'> |       <div className='autosuggest-account'> | ||||||
|         <div className='autosuggest-account-icon'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={18} /></div> |         <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div> | ||||||
|         <DisplayName account={account} /> |         <DisplayName account={account} /> | ||||||
|       </div> |       </div> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ export default class NavigationBar extends ImmutablePureComponent { | |||||||
|       <div className='navigation-bar'> |       <div className='navigation-bar'> | ||||||
|         <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> |         <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> | ||||||
|           <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> |           <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> | ||||||
|           <Avatar src={this.props.account.get('avatar')} animate size={40} /> |           <Avatar account={this.props.account} size={40} /> | ||||||
|         </Permalink> |         </Permalink> | ||||||
|  |  | ||||||
|         <div className='navigation-bar__profile'> |         <div className='navigation-bar__profile'> | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; | |||||||
| import Avatar from '../../../components/avatar'; | import Avatar from '../../../components/avatar'; | ||||||
| import IconButton from '../../../components/icon_button'; | import IconButton from '../../../components/icon_button'; | ||||||
| import DisplayName from '../../../components/display_name'; | import DisplayName from '../../../components/display_name'; | ||||||
| import emojify from '../../../emoji'; |  | ||||||
| import { defineMessages, injectIntl } from 'react-intl'; | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
|  |  | ||||||
| @@ -43,7 +42,7 @@ export default class ReplyIndicator extends ImmutablePureComponent { | |||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const content  = { __html: emojify(status.get('content')) }; |     const content  = { __html: status.get('contentHtml') }; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className='reply-indicator'> |       <div className='reply-indicator'> | ||||||
| @@ -51,7 +50,7 @@ export default class ReplyIndicator extends ImmutablePureComponent { | |||||||
|           <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> |           <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> | ||||||
|  |  | ||||||
|           <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'> |           <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'> | ||||||
|             <div className='reply-indicator__display-avatar'><Avatar size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div> |             <div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div> | ||||||
|             <DisplayName account={status.get('account')} /> |             <DisplayName account={status.get('account')} /> | ||||||
|           </a> |           </a> | ||||||
|         </div> |         </div> | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ const messages = defineMessages({ | |||||||
|  |  | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   statusIds: state.getIn(['status_lists', 'favourites', 'items']), |   statusIds: state.getIn(['status_lists', 'favourites', 'items']), | ||||||
|  |   hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @connect(mapStateToProps) | @connect(mapStateToProps) | ||||||
| @@ -28,6 +29,7 @@ export default class Favourites extends ImmutablePureComponent { | |||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|     columnId: PropTypes.string, |     columnId: PropTypes.string, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|  |     hasMore: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   componentWillMount () { |   componentWillMount () { | ||||||
| @@ -62,7 +64,7 @@ export default class Favourites extends ImmutablePureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { intl, statusIds, columnId, multiColumn } = this.props; |     const { intl, statusIds, columnId, multiColumn, hasMore } = this.props; | ||||||
|     const pinned = !!columnId; |     const pinned = !!columnId; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
| @@ -75,12 +77,14 @@ export default class Favourites extends ImmutablePureComponent { | |||||||
|           onClick={this.handleHeaderClick} |           onClick={this.handleHeaderClick} | ||||||
|           pinned={pinned} |           pinned={pinned} | ||||||
|           multiColumn={multiColumn} |           multiColumn={multiColumn} | ||||||
|  |           showBackButton | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <StatusList |         <StatusList | ||||||
|           trackScroll={!pinned} |           trackScroll={!pinned} | ||||||
|           statusIds={statusIds} |           statusIds={statusIds} | ||||||
|           scrollKey={`favourited_statuses-${columnId}`} |           scrollKey={`favourited_statuses-${columnId}`} | ||||||
|  |           hasMore={hasMore} | ||||||
|           onScrollToBottom={this.handleScrollToBottom} |           onScrollToBottom={this.handleScrollToBottom} | ||||||
|         /> |         /> | ||||||
|       </Column> |       </Column> | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | |||||||
| import Permalink from '../../../components/permalink'; | import Permalink from '../../../components/permalink'; | ||||||
| import Avatar from '../../../components/avatar'; | import Avatar from '../../../components/avatar'; | ||||||
| import DisplayName from '../../../components/display_name'; | import DisplayName from '../../../components/display_name'; | ||||||
| import emojify from '../../../emoji'; |  | ||||||
| import IconButton from '../../../components/icon_button'; | import IconButton from '../../../components/icon_button'; | ||||||
| import { defineMessages, injectIntl } from 'react-intl'; | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| @@ -26,13 +25,13 @@ export default class AccountAuthorize extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { intl, account, onAuthorize, onReject } = this.props; |     const { intl, account, onAuthorize, onReject } = this.props; | ||||||
|     const content = { __html: emojify(account.get('note')) }; |     const content = { __html: account.get('note_emojified') }; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className='account-authorize__wrapper'> |       <div className='account-authorize__wrapper'> | ||||||
|         <div className='account-authorize'> |         <div className='account-authorize'> | ||||||
|           <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'> |           <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'> | ||||||
|             <div className='account-authorize__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={48} /></div> |             <div className='account-authorize__avatar'><Avatar account={account} size={48} /></div> | ||||||
|             <DisplayName account={account} /> |             <DisplayName account={account} /> | ||||||
|           </Permalink> |           </Permalink> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ const messages = defineMessages({ | |||||||
|   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, |   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, | ||||||
|   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, |   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, | ||||||
|   info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }, |   info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }, | ||||||
|  |   pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
| @@ -66,15 +67,16 @@ export default class GettingStarted extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|     navItems = navItems.concat([ |     navItems = navItems.concat([ | ||||||
|       <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, |       <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, | ||||||
|  |       <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, | ||||||
|     ]); |     ]); | ||||||
|  |  | ||||||
|     if (me.get('locked')) { |     if (me.get('locked')) { | ||||||
|       navItems.push(<ColumnLink key='5' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); |       navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     navItems = navItems.concat([ |     navItems = navItems.concat([ | ||||||
|       <ColumnLink key='6' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, |       <ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, | ||||||
|       <ColumnLink key='7' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, |       <ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, | ||||||
|     ]); |     ]); | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|   | |||||||
| @@ -7,17 +7,13 @@ import ColumnHeader from '../../components/column_header'; | |||||||
| import { | import { | ||||||
|   refreshHashtagTimeline, |   refreshHashtagTimeline, | ||||||
|   expandHashtagTimeline, |   expandHashtagTimeline, | ||||||
|   updateTimeline, |  | ||||||
|   deleteFromTimelines, |  | ||||||
| } from '../../actions/timelines'; | } from '../../actions/timelines'; | ||||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| import createStream from '../../stream'; | import { connectHashtagStream } from '../../actions/streaming'; | ||||||
|  |  | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = (state, props) => ({ | ||||||
|   hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, |   hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, | ||||||
|   streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), |  | ||||||
|   accessToken: state.getIn(['meta', 'access_token']), |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @connect(mapStateToProps) | @connect(mapStateToProps) | ||||||
| @@ -27,8 +23,6 @@ export default class HashtagTimeline extends React.PureComponent { | |||||||
|     params: PropTypes.object.isRequired, |     params: PropTypes.object.isRequired, | ||||||
|     columnId: PropTypes.string, |     columnId: PropTypes.string, | ||||||
|     dispatch: PropTypes.func.isRequired, |     dispatch: PropTypes.func.isRequired, | ||||||
|     streamingAPIBaseURL: PropTypes.string.isRequired, |  | ||||||
|     accessToken: PropTypes.string.isRequired, |  | ||||||
|     hasUnread: PropTypes.bool, |     hasUnread: PropTypes.bool, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| @@ -53,28 +47,13 @@ export default class HashtagTimeline extends React.PureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   _subscribe (dispatch, id) { |   _subscribe (dispatch, id) { | ||||||
|     const { streamingAPIBaseURL, accessToken } = this.props; |     this.disconnect = dispatch(connectHashtagStream(id)); | ||||||
|  |  | ||||||
|     this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, { |  | ||||||
|  |  | ||||||
|       received (data) { |  | ||||||
|         switch(data.event) { |  | ||||||
|         case 'update': |  | ||||||
|           dispatch(updateTimeline(`hashtag:${id}`, JSON.parse(data.payload))); |  | ||||||
|           break; |  | ||||||
|         case 'delete': |  | ||||||
|           dispatch(deleteFromTimelines(data.payload)); |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|     }); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   _unsubscribe () { |   _unsubscribe () { | ||||||
|     if (typeof this.subscription !== 'undefined') { |     if (this.disconnect) { | ||||||
|       this.subscription.close(); |       this.disconnect(); | ||||||
|       this.subscription = null; |       this.disconnect = null; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,17 +1,17 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import StatusContainer from '../../../containers/status_container'; | import StatusContainer from '../../../containers/status_container'; | ||||||
| import AccountContainer from '../../../containers/account_container'; | import AccountContainer from '../../../containers/account_container'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| import Permalink from '../../../components/permalink'; | import Permalink from '../../../components/permalink'; | ||||||
| import emojify from '../../../emoji'; |  | ||||||
| import escapeTextContentForBrowser from 'escape-html'; |  | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
|  |  | ||||||
| export default class Notification extends ImmutablePureComponent { | export default class Notification extends ImmutablePureComponent { | ||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     notification: ImmutablePropTypes.map.isRequired, |     notification: ImmutablePropTypes.map.isRequired, | ||||||
|  |     hidden: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   renderFollow (account, link) { |   renderFollow (account, link) { | ||||||
| @@ -25,13 +25,13 @@ export default class Notification extends ImmutablePureComponent { | |||||||
|           <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> |           <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <AccountContainer id={account.get('id')} withNote={false} /> |         <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> | ||||||
|       </div> |       </div> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   renderMention (notification) { |   renderMention (notification) { | ||||||
|     return <StatusContainer id={notification.get('status')} withDismiss />; |     return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   renderFavourite (notification, link) { |   renderFavourite (notification, link) { | ||||||
| @@ -44,7 +44,7 @@ export default class Notification extends ImmutablePureComponent { | |||||||
|           <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> |           <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> |         <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> | ||||||
|       </div> |       </div> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| @@ -59,7 +59,7 @@ export default class Notification extends ImmutablePureComponent { | |||||||
|           <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> |           <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> |         <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> | ||||||
|       </div> |       </div> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| @@ -67,9 +67,8 @@ export default class Notification extends ImmutablePureComponent { | |||||||
|   render () { |   render () { | ||||||
|     const { notification } = this.props; |     const { notification } = this.props; | ||||||
|     const account          = notification.get('account'); |     const account          = notification.get('account'); | ||||||
|     const displayName      = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); |     const displayNameHtml  = { __html: account.get('display_name_html') }; | ||||||
|     const displayNameHTML  = { __html: emojify(escapeTextContentForBrowser(displayName)) }; |     const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHtml} />; | ||||||
|     const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />; |  | ||||||
|  |  | ||||||
|     switch(notification.get('type')) { |     switch(notification.get('type')) { | ||||||
|     case 'follow': |     case 'follow': | ||||||
|   | |||||||
| @@ -18,12 +18,6 @@ export default class SettingToggle extends React.PureComponent { | |||||||
|     this.props.onChange(this.props.settingKey, target.checked); |     this.props.onChange(this.props.settingKey, target.checked); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   onKeyDown = e => { |  | ||||||
|     if (e.key === ' ') { |  | ||||||
|       this.props.onChange(this.props.settingKey, !e.target.checked); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { prefix, settings, settingKey, label, meta } = this.props; |     const { prefix, settings, settingKey, label, meta } = this.props; | ||||||
|     const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); |     const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); | ||||||
|   | |||||||
| @@ -7,13 +7,12 @@ import ColumnHeader from '../../components/column_header'; | |||||||
| import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; | import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; | ||||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||||
| import NotificationContainer from './containers/notification_container'; | import NotificationContainer from './containers/notification_container'; | ||||||
| import { ScrollContainer } from 'react-router-scroll'; |  | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import ColumnSettingsContainer from './containers/column_settings_container'; | import ColumnSettingsContainer from './containers/column_settings_container'; | ||||||
| import { createSelector } from 'reselect'; | import { createSelector } from 'reselect'; | ||||||
| import { List as ImmutableList } from 'immutable'; | import { List as ImmutableList } from 'immutable'; | ||||||
| import LoadMore from '../../components/load_more'; |  | ||||||
| import { debounce } from 'lodash'; | import { debounce } from 'lodash'; | ||||||
|  | import ScrollableList from '../../components/scrollable_list'; | ||||||
|  |  | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   title: { id: 'column.notifications', defaultMessage: 'Notifications' }, |   title: { id: 'column.notifications', defaultMessage: 'Notifications' }, | ||||||
| @@ -51,40 +50,18 @@ export default class Notifications extends React.PureComponent { | |||||||
|     trackScroll: true, |     trackScroll: true, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   dispatchExpandNotifications = debounce(() => { |   handleScrollToBottom = debounce(() => { | ||||||
|  |     this.props.dispatch(scrollTopNotifications(false)); | ||||||
|     this.props.dispatch(expandNotifications()); |     this.props.dispatch(expandNotifications()); | ||||||
|   }, 300, { leading: true }); |   }, 300, { leading: true }); | ||||||
|  |  | ||||||
|   dispatchScrollToTop = debounce((top) => { |   handleScrollToTop = debounce(() => { | ||||||
|     this.props.dispatch(scrollTopNotifications(top)); |     this.props.dispatch(scrollTopNotifications(true)); | ||||||
|   }, 100); |   }, 100); | ||||||
|  |  | ||||||
|   handleScroll = (e) => { |   handleScroll = debounce(() => { | ||||||
|     const { scrollTop, scrollHeight, clientHeight } = e.target; |     this.props.dispatch(scrollTopNotifications(false)); | ||||||
|     const offset = scrollHeight - scrollTop - clientHeight; |   }, 100); | ||||||
|     this._oldScrollPosition = scrollHeight - scrollTop; |  | ||||||
|  |  | ||||||
|     if (250 > offset && this.props.hasMore && !this.props.isLoading) { |  | ||||||
|       this.dispatchExpandNotifications(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (scrollTop < 100) { |  | ||||||
|       this.dispatchScrollToTop(true); |  | ||||||
|     } else { |  | ||||||
|       this.dispatchScrollToTop(false); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   componentDidUpdate (prevProps) { |  | ||||||
|     if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) { |  | ||||||
|       this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleLoadMore = (e) => { |  | ||||||
|     e.preventDefault(); |  | ||||||
|     this.dispatchExpandNotifications(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handlePin = () => { |   handlePin = () => { | ||||||
|     const { columnId, dispatch } = this.props; |     const { columnId, dispatch } = this.props; | ||||||
| @@ -105,10 +82,6 @@ export default class Notifications extends React.PureComponent { | |||||||
|     this.column.scrollTop(); |     this.column.scrollTop(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   setRef = (c) => { |  | ||||||
|     this.node = c; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   setColumnRef = c => { |   setColumnRef = c => { | ||||||
|     this.column = c; |     this.column = c; | ||||||
|   } |   } | ||||||
| @@ -116,52 +89,35 @@ export default class Notifications extends React.PureComponent { | |||||||
|   render () { |   render () { | ||||||
|     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; |     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; | ||||||
|     const pinned = !!columnId; |     const pinned = !!columnId; | ||||||
|  |     const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; | ||||||
|  |  | ||||||
|     let loadMore       = ''; |     let scrollableContent = null; | ||||||
|     let scrollableArea = ''; |  | ||||||
|     let unread         = ''; |  | ||||||
|     let scrollContainer = ''; |  | ||||||
|  |  | ||||||
|     if (!isLoading && hasMore) { |     if (isLoading && this.scrollableContent) { | ||||||
|       loadMore = <LoadMore onClick={this.handleLoadMore} />; |       scrollableContent = this.scrollableContent; | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (isUnread) { |  | ||||||
|       unread = <div className='notifications__unread-indicator' />; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (isLoading && this.scrollableArea) { |  | ||||||
|       scrollableArea = this.scrollableArea; |  | ||||||
|     } else if (notifications.size > 0 || hasMore) { |     } else if (notifications.size > 0 || hasMore) { | ||||||
|       scrollableArea = ( |       scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />); | ||||||
|         <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}> |  | ||||||
|           {unread} |  | ||||||
|  |  | ||||||
|           <div> |  | ||||||
|             {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)} |  | ||||||
|             {loadMore} |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
|     } else { |     } else { | ||||||
|       scrollableArea = ( |       scrollableContent = null; | ||||||
|         <div className='empty-column-indicator' ref={this.setRef}> |  | ||||||
|           <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." /> |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (pinned) { |     this.scrollableContent = scrollableContent; | ||||||
|       scrollContainer = scrollableArea; |  | ||||||
|     } else { |  | ||||||
|       scrollContainer = ( |  | ||||||
|         <ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}> |  | ||||||
|           {scrollableArea} |  | ||||||
|         </ScrollContainer> |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this.scrollableArea = scrollableArea; |     const scrollContainer = ( | ||||||
|  |       <ScrollableList | ||||||
|  |         scrollKey={`notifications-${columnId}`} | ||||||
|  |         trackScroll={!pinned} | ||||||
|  |         isLoading={isLoading} | ||||||
|  |         hasMore={hasMore} | ||||||
|  |         emptyMessage={emptyMessage} | ||||||
|  |         onScrollToBottom={this.handleScrollToBottom} | ||||||
|  |         onScrollToTop={this.handleScrollToTop} | ||||||
|  |         onScroll={this.handleScroll} | ||||||
|  |         shouldUpdateScroll={shouldUpdateScroll} | ||||||
|  |       > | ||||||
|  |         {scrollableContent} | ||||||
|  |       </ScrollableList> | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <Column ref={this.setColumnRef}> |       <Column ref={this.setColumnRef}> | ||||||
|   | |||||||
							
								
								
									
										59
									
								
								app/javascript/mastodon/features/pinned_statuses/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								app/javascript/mastodon/features/pinned_statuses/index.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { connect } from 'react-redux'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
|  | import { fetchPinnedStatuses } from '../../actions/pin_statuses'; | ||||||
|  | import Column from '../ui/components/column'; | ||||||
|  | import ColumnBackButtonSlim from '../../components/column_back_button_slim'; | ||||||
|  | import StatusList from '../../components/status_list'; | ||||||
|  | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
|  | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
|  |  | ||||||
|  | const messages = defineMessages({ | ||||||
|  |   heading: { id: 'column.pins', defaultMessage: 'Pinned toot' }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const mapStateToProps = state => ({ | ||||||
|  |   statusIds: state.getIn(['status_lists', 'pins', 'items']), | ||||||
|  |   hasMore: !!state.getIn(['status_lists', 'pins', 'next']), | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | @connect(mapStateToProps) | ||||||
|  | @injectIntl | ||||||
|  | export default class PinnedStatuses extends ImmutablePureComponent { | ||||||
|  |  | ||||||
|  |   static propTypes = { | ||||||
|  |     dispatch: PropTypes.func.isRequired, | ||||||
|  |     statusIds: ImmutablePropTypes.list.isRequired, | ||||||
|  |     intl: PropTypes.object.isRequired, | ||||||
|  |     hasMore: PropTypes.bool.isRequired, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   componentWillMount () { | ||||||
|  |     this.props.dispatch(fetchPinnedStatuses()); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleHeaderClick = () => { | ||||||
|  |     this.column.scrollTop(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setRef = c => { | ||||||
|  |     this.column = c; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     const { intl, statusIds, hasMore } = this.props; | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> | ||||||
|  |         <ColumnBackButtonSlim /> | ||||||
|  |         <StatusList | ||||||
|  |           statusIds={statusIds} | ||||||
|  |           scrollKey='pinned_statuses' | ||||||
|  |           hasMore={hasMore} | ||||||
|  |         /> | ||||||
|  |       </Column> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header'; | |||||||
| import { | import { | ||||||
|   refreshPublicTimeline, |   refreshPublicTimeline, | ||||||
|   expandPublicTimeline, |   expandPublicTimeline, | ||||||
|   updateTimeline, |  | ||||||
|   deleteFromTimelines, |  | ||||||
|   connectTimeline, |  | ||||||
|   disconnectTimeline, |  | ||||||
| } from '../../actions/timelines'; | } from '../../actions/timelines'; | ||||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import ColumnSettingsContainer from './containers/column_settings_container'; | import ColumnSettingsContainer from './containers/column_settings_container'; | ||||||
| import createStream from '../../stream'; | import { connectPublicStream } from '../../actions/streaming'; | ||||||
|  |  | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   title: { id: 'column.public', defaultMessage: 'Federated timeline' }, |   title: { id: 'column.public', defaultMessage: 'Federated timeline' }, | ||||||
| @@ -23,8 +19,6 @@ const messages = defineMessages({ | |||||||
|  |  | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, |   hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, | ||||||
|   streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), |  | ||||||
|   accessToken: state.getIn(['meta', 'access_token']), |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @connect(mapStateToProps) | @connect(mapStateToProps) | ||||||
| @@ -36,8 +30,6 @@ export default class PublicTimeline extends React.PureComponent { | |||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|     columnId: PropTypes.string, |     columnId: PropTypes.string, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|     streamingAPIBaseURL: PropTypes.string.isRequired, |  | ||||||
|     accessToken: PropTypes.string.isRequired, |  | ||||||
|     hasUnread: PropTypes.bool, |     hasUnread: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
| @@ -61,46 +53,16 @@ export default class PublicTimeline extends React.PureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     const { dispatch, streamingAPIBaseURL, accessToken } = this.props; |     const { dispatch } = this.props; | ||||||
|  |  | ||||||
|     dispatch(refreshPublicTimeline()); |     dispatch(refreshPublicTimeline()); | ||||||
|  |     this.disconnect = dispatch(connectPublicStream()); | ||||||
|     if (typeof this._subscription !== 'undefined') { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { |  | ||||||
|  |  | ||||||
|       connected () { |  | ||||||
|         dispatch(connectTimeline('public')); |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|       reconnected () { |  | ||||||
|         dispatch(connectTimeline('public')); |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|       disconnected () { |  | ||||||
|         dispatch(disconnectTimeline('public')); |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|       received (data) { |  | ||||||
|         switch(data.event) { |  | ||||||
|         case 'update': |  | ||||||
|           dispatch(updateTimeline('public', JSON.parse(data.payload))); |  | ||||||
|           break; |  | ||||||
|         case 'delete': |  | ||||||
|           dispatch(deleteFromTimelines(data.payload)); |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
|       }, |  | ||||||
|  |  | ||||||
|     }); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|     if (typeof this._subscription !== 'undefined') { |     if (this.disconnect) { | ||||||
|       this._subscription.close(); |       this.disconnect(); | ||||||
|       this._subscription = null; |       this.disconnect = null; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,7 +1,6 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import emojify from '../../../emoji'; |  | ||||||
| import Toggle from 'react-toggle'; | import Toggle from 'react-toggle'; | ||||||
|  |  | ||||||
| export default class StatusCheckBox extends React.PureComponent { | export default class StatusCheckBox extends React.PureComponent { | ||||||
| @@ -15,7 +14,7 @@ export default class StatusCheckBox extends React.PureComponent { | |||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { status, checked, onToggle, disabled } = this.props; |     const { status, checked, onToggle, disabled } = this.props; | ||||||
|     const content = { __html: emojify(status.get('content')) }; |     const content = { __html: status.get('contentHtml') }; | ||||||
|  |  | ||||||
|     if (status.get('reblog')) { |     if (status.get('reblog')) { | ||||||
|       return null; |       return null; | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								app/javascript/mastodon/features/standalone/compose/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/javascript/mastodon/features/standalone/compose/index.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import ComposeFormContainer from '../../compose/containers/compose_form_container'; | ||||||
|  | import NotificationsContainer from '../../ui/containers/notifications_container'; | ||||||
|  | import LoadingBarContainer from '../../ui/containers/loading_bar_container'; | ||||||
|  |  | ||||||
|  | export default class Compose extends React.PureComponent { | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     return ( | ||||||
|  |       <div> | ||||||
|  |         <ComposeFormContainer /> | ||||||
|  |         <NotificationsContainer /> | ||||||
|  |         <LoadingBarContainer className='loading-bar' /> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -14,6 +14,9 @@ const messages = defineMessages({ | |||||||
|   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, |   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, | ||||||
|   report: { id: 'status.report', defaultMessage: 'Report @{name}' }, |   report: { id: 'status.report', defaultMessage: 'Report @{name}' }, | ||||||
|   share: { id: 'status.share', defaultMessage: 'Share' }, |   share: { id: 'status.share', defaultMessage: 'Share' }, | ||||||
|  |   pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, | ||||||
|  |   unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, | ||||||
|  |   embed: { id: 'status.embed', defaultMessage: 'Embed' }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @injectIntl | @injectIntl | ||||||
| @@ -31,6 +34,8 @@ export default class ActionBar extends React.PureComponent { | |||||||
|     onDelete: PropTypes.func.isRequired, |     onDelete: PropTypes.func.isRequired, | ||||||
|     onMention: PropTypes.func.isRequired, |     onMention: PropTypes.func.isRequired, | ||||||
|     onReport: PropTypes.func, |     onReport: PropTypes.func, | ||||||
|  |     onPin: PropTypes.func, | ||||||
|  |     onEmbed: PropTypes.func, | ||||||
|     me: PropTypes.number.isRequired, |     me: PropTypes.number.isRequired, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|   }; |   }; | ||||||
| @@ -59,6 +64,10 @@ export default class ActionBar extends React.PureComponent { | |||||||
|     this.props.onReport(this.props.status); |     this.props.onReport(this.props.status); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   handlePinClick = () => { | ||||||
|  |     this.props.onPin(this.props.status); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   handleShare = () => { |   handleShare = () => { | ||||||
|     navigator.share({ |     navigator.share({ | ||||||
|       text: this.props.status.get('search_index'), |       text: this.props.status.get('search_index'), | ||||||
| @@ -66,12 +75,26 @@ export default class ActionBar extends React.PureComponent { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   handleEmbed = () => { | ||||||
|  |     this.props.onEmbed(this.props.status); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { status, me, intl } = this.props; |     const { status, me, intl } = this.props; | ||||||
|  |  | ||||||
|  |     const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); | ||||||
|  |  | ||||||
|     let menu = []; |     let menu = []; | ||||||
|  |  | ||||||
|  |     if (publicStatus) { | ||||||
|  |       menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     if (me === status.getIn(['account', 'id'])) { |     if (me === status.getIn(['account', 'id'])) { | ||||||
|  |       if (publicStatus) { | ||||||
|  |         menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); | ||||||
|  |       } | ||||||
|  |  | ||||||
|       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); |       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); | ||||||
|     } else { |     } else { | ||||||
|       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); |       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import punycode from 'punycode'; | import punycode from 'punycode'; | ||||||
|  | import classnames from 'classnames'; | ||||||
|  |  | ||||||
| const IDNA_PREFIX = 'xn--'; | const IDNA_PREFIX = 'xn--'; | ||||||
|  |  | ||||||
| @@ -32,7 +33,7 @@ export default class Card extends React.PureComponent { | |||||||
|     if (card.get('image')) { |     if (card.get('image')) { | ||||||
|       image = ( |       image = ( | ||||||
|         <div className='status-card__image'> |         <div className='status-card__image'> | ||||||
|           <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' /> |           <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' width={card.get('width')} height={card.get('height')} /> | ||||||
|         </div> |         </div> | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| @@ -41,8 +42,12 @@ export default class Card extends React.PureComponent { | |||||||
|       provider = decodeIDNA(getHostname(card.get('url'))); |       provider = decodeIDNA(getHostname(card.get('url'))); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     const className = classnames('status-card', { | ||||||
|  |       'horizontal': card.get('width') > card.get('height'), | ||||||
|  |     }); | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'> |       <a href={card.get('url')} className={className} target='_blank' rel='noopener'> | ||||||
|         {image} |         {image} | ||||||
|  |  | ||||||
|         <div className='status-card__content'> |         <div className='status-card__content'> | ||||||
|   | |||||||
| @@ -59,7 +59,7 @@ export default class DetailedStatus extends ImmutablePureComponent { | |||||||
|     return ( |     return ( | ||||||
|       <div className='detailed-status'> |       <div className='detailed-status'> | ||||||
|         <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> |         <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> | ||||||
|           <div className='detailed-status__display-avatar'><Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div> |           <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div> | ||||||
|           <DisplayName account={status.get('account')} /> |           <DisplayName account={status.get('account')} /> | ||||||
|         </a> |         </a> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -12,6 +12,8 @@ import { | |||||||
|   unfavourite, |   unfavourite, | ||||||
|   reblog, |   reblog, | ||||||
|   unreblog, |   unreblog, | ||||||
|  |   pin, | ||||||
|  |   unpin, | ||||||
| } from '../../actions/interactions'; | } from '../../actions/interactions'; | ||||||
| import { | import { | ||||||
|   replyCompose, |   replyCompose, | ||||||
| @@ -87,6 +89,14 @@ export default class Status extends ImmutablePureComponent { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   handlePin = (status) => { | ||||||
|  |     if (status.get('pinned')) { | ||||||
|  |       this.props.dispatch(unpin(status)); | ||||||
|  |     } else { | ||||||
|  |       this.props.dispatch(pin(status)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   handleReplyClick = (status) => { |   handleReplyClick = (status) => { | ||||||
|     this.props.dispatch(replyCompose(status, this.context.router.history)); |     this.props.dispatch(replyCompose(status, this.context.router.history)); | ||||||
|   } |   } | ||||||
| @@ -137,6 +147,10 @@ export default class Status extends ImmutablePureComponent { | |||||||
|     this.props.dispatch(initReport(status.get('account'), status)); |     this.props.dispatch(initReport(status.get('account'), status)); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   handleEmbed = (status) => { | ||||||
|  |     this.props.dispatch(openModal('EMBED', { url: status.get('url') })); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   renderChildren (list) { |   renderChildren (list) { | ||||||
|     return list.map(id => <StatusContainer key={id} id={id} />); |     return list.map(id => <StatusContainer key={id} id={id} />); | ||||||
|   } |   } | ||||||
| @@ -187,6 +201,8 @@ export default class Status extends ImmutablePureComponent { | |||||||
|               onDelete={this.handleDeleteClick} |               onDelete={this.handleDeleteClick} | ||||||
|               onMention={this.handleMentionClick} |               onMention={this.handleMentionClick} | ||||||
|               onReport={this.handleReport} |               onReport={this.handleReport} | ||||||
|  |               onPin={this.handlePin} | ||||||
|  |               onEmbed={this.handleEmbed} | ||||||
|             /> |             /> | ||||||
|  |  | ||||||
|             {descendants} |             {descendants} | ||||||
|   | |||||||
| @@ -46,7 +46,7 @@ export default class ActionsModal extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|           <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'> |           <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'> | ||||||
|             <div className='status__avatar'> |             <div className='status__avatar'> | ||||||
|               <Avatar src={this.props.status.getIn(['account', 'avatar'])} staticSrc={this.props.status.getIn(['account', 'avatar_static'])} size={48} /> |               <Avatar account={this.props.status.get('account')} size={48} /> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <DisplayName account={this.props.status.get('account')} /> |             <DisplayName account={this.props.status.get('account')} /> | ||||||
|   | |||||||
| @@ -62,7 +62,7 @@ export default class BoostModal extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|               <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> |               <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> | ||||||
|                 <div className='status__avatar'> |                 <div className='status__avatar'> | ||||||
|                   <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /> |                   <Avatar account={status.get('account')} size={48} /> | ||||||
|                 </div> |                 </div> | ||||||
|  |  | ||||||
|                 <DisplayName account={status.get('account')} /> |                 <DisplayName account={status.get('account')} /> | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ import React from 'react'; | |||||||
| import ColumnHeader from './column_header'; | import ColumnHeader from './column_header'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { debounce } from 'lodash'; | import { debounce } from 'lodash'; | ||||||
| import scrollTop from '../../../scroll'; | import { scrollTop } from '../../../scroll'; | ||||||
| import { isMobile } from '../../../is_mobile'; | import { isMobile } from '../../../is_mobile'; | ||||||
|  |  | ||||||
| export default class Column extends React.PureComponent { | export default class Column extends React.PureComponent { | ||||||
| @@ -25,6 +25,17 @@ export default class Column extends React.PureComponent { | |||||||
|     this._interruptScrollAnimation = scrollTop(scrollable); |     this._interruptScrollAnimation = scrollTop(scrollable); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   scrollTop () { | ||||||
|  |     const scrollable = this.node.querySelector('.scrollable'); | ||||||
|  |  | ||||||
|  |     if (!scrollable) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this._interruptScrollAnimation = scrollTop(scrollable); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|   handleScroll = debounce(() => { |   handleScroll = debounce(() => { | ||||||
|     if (typeof this._interruptScrollAnimation !== 'undefined') { |     if (typeof this._interruptScrollAnimation !== 'undefined') { | ||||||
|       this._interruptScrollAnimation(); |       this._interruptScrollAnimation(); | ||||||
|   | |||||||
| @@ -9,9 +9,13 @@ import { links, getIndex, getLink } from './tabs_bar'; | |||||||
|  |  | ||||||
| import BundleContainer from '../containers/bundle_container'; | import BundleContainer from '../containers/bundle_container'; | ||||||
| import ColumnLoading from './column_loading'; | import ColumnLoading from './column_loading'; | ||||||
|  | import DrawerLoading from './drawer_loading'; | ||||||
| import BundleColumnError from './bundle_column_error'; | import BundleColumnError from './bundle_column_error'; | ||||||
| import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components'; | import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components'; | ||||||
|  |  | ||||||
|  | import detectPassiveEvents from 'detect-passive-events'; | ||||||
|  | import { scrollRight } from '../../../scroll'; | ||||||
|  |  | ||||||
| const componentMap = { | const componentMap = { | ||||||
|   'COMPOSE': Compose, |   'COMPOSE': Compose, | ||||||
|   'HOME': HomeTimeline, |   'HOME': HomeTimeline, | ||||||
| @@ -22,7 +26,7 @@ const componentMap = { | |||||||
|   'FAVOURITES': FavouritedStatuses, |   'FAVOURITES': FavouritedStatuses, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @injectIntl | @component => injectIntl(component, { withRef: true }) | ||||||
| export default class ColumnsArea extends ImmutablePureComponent { | export default class ColumnsArea extends ImmutablePureComponent { | ||||||
|  |  | ||||||
|   static contextTypes = { |   static contextTypes = { | ||||||
| @@ -45,15 +49,39 @@ export default class ColumnsArea extends ImmutablePureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentDidMount() { |   componentDidMount() { | ||||||
|  |     if (!this.props.singleColumn) { | ||||||
|  |       this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false); | ||||||
|  |     } | ||||||
|     this.lastIndex = getIndex(this.context.router.history.location.pathname); |     this.lastIndex = getIndex(this.context.router.history.location.pathname); | ||||||
|     this.setState({ shouldAnimate: true }); |     this.setState({ shouldAnimate: true }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentDidUpdate() { |   componentWillUpdate(nextProps) { | ||||||
|  |     if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) { | ||||||
|  |       this.node.removeEventListener('wheel', this.handleWheel); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentDidUpdate(prevProps) { | ||||||
|  |     if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { | ||||||
|  |       this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false); | ||||||
|  |     } | ||||||
|     this.lastIndex = getIndex(this.context.router.history.location.pathname); |     this.lastIndex = getIndex(this.context.router.history.location.pathname); | ||||||
|     this.setState({ shouldAnimate: true }); |     this.setState({ shouldAnimate: true }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     if (!this.props.singleColumn) { | ||||||
|  |       this.node.removeEventListener('wheel', this.handleWheel); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleChildrenContentChange() { | ||||||
|  |     if (!this.props.singleColumn) { | ||||||
|  |       scrollRight(this.node, this.node.scrollWidth - window.innerWidth); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   handleSwipe = (index) => { |   handleSwipe = (index) => { | ||||||
|     this.pendingIndex = index; |     this.pendingIndex = index; | ||||||
|  |  | ||||||
| @@ -74,6 +102,18 @@ export default class ColumnsArea extends ImmutablePureComponent { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   handleWheel = () => { | ||||||
|  |     if (typeof this._interruptScrollAnimation !== 'function') { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this._interruptScrollAnimation(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setRef = (node) => { | ||||||
|  |     this.node = node; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   renderView = (link, index) => { |   renderView = (link, index) => { | ||||||
|     const columnIndex = getIndex(this.context.router.history.location.pathname); |     const columnIndex = getIndex(this.context.router.history.location.pathname); | ||||||
|     const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] }); |     const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] }); | ||||||
| @@ -90,8 +130,8 @@ export default class ColumnsArea extends ImmutablePureComponent { | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   renderLoading = () => { |   renderLoading = columnId => () => { | ||||||
|     return <ColumnLoading />; |     return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   renderError = (props) => { |   renderError = (props) => { | ||||||
| @@ -114,12 +154,12 @@ export default class ColumnsArea extends ImmutablePureComponent { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className='columns-area'> |       <div className='columns-area' ref={this.setRef}> | ||||||
|         {columns.map(column => { |         {columns.map(column => { | ||||||
|           const params = column.get('params', null) === null ? null : column.get('params').toJS(); |           const params = column.get('params', null) === null ? null : column.get('params').toJS(); | ||||||
|  |  | ||||||
|           return ( |           return ( | ||||||
|             <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading} error={this.renderError}> |             <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}> | ||||||
|               {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn />} |               {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn />} | ||||||
|             </BundleContainer> |             </BundleContainer> | ||||||
|           ); |           ); | ||||||
|   | |||||||
| @@ -0,0 +1,11 @@ | |||||||
|  | import React from 'react'; | ||||||
|  |  | ||||||
|  | const DrawerLoading = () => ( | ||||||
|  |   <div className='drawer'> | ||||||
|  |     <div className='drawer__pager'> | ||||||
|  |       <div className='drawer__inner' /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export default DrawerLoading; | ||||||
| @@ -0,0 +1,84 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
|  | import { FormattedMessage, injectIntl } from 'react-intl'; | ||||||
|  | import axios from 'axios'; | ||||||
|  |  | ||||||
|  | @injectIntl | ||||||
|  | export default class EmbedModal extends ImmutablePureComponent { | ||||||
|  |  | ||||||
|  |   static propTypes = { | ||||||
|  |     url: PropTypes.string.isRequired, | ||||||
|  |     onClose: PropTypes.func.isRequired, | ||||||
|  |     intl: PropTypes.object.isRequired, | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   state = { | ||||||
|  |     loading: false, | ||||||
|  |     oembed: null, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   componentDidMount () { | ||||||
|  |     const { url } = this.props; | ||||||
|  |  | ||||||
|  |     this.setState({ loading: true }); | ||||||
|  |  | ||||||
|  |     axios.post('/api/web/embed', { url }).then(res => { | ||||||
|  |       this.setState({ loading: false, oembed: res.data }); | ||||||
|  |  | ||||||
|  |       const iframeDocument = this.iframe.contentWindow.document; | ||||||
|  |  | ||||||
|  |       iframeDocument.open(); | ||||||
|  |       iframeDocument.write(res.data.html); | ||||||
|  |       iframeDocument.close(); | ||||||
|  |  | ||||||
|  |       iframeDocument.body.style.margin = 0; | ||||||
|  |       this.iframe.width  = iframeDocument.body.scrollWidth; | ||||||
|  |       this.iframe.height = iframeDocument.body.scrollHeight; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setIframeRef = c =>  { | ||||||
|  |     this.iframe = c; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleTextareaClick = (e) => { | ||||||
|  |     e.target.select(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     const { oembed } = this.state; | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <div className='modal-root__modal embed-modal'> | ||||||
|  |         <h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4> | ||||||
|  |  | ||||||
|  |         <div className='embed-modal__container'> | ||||||
|  |           <p className='hint'> | ||||||
|  |             <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' /> | ||||||
|  |           </p> | ||||||
|  |  | ||||||
|  |           <input | ||||||
|  |             type='text' | ||||||
|  |             className='embed-modal__html' | ||||||
|  |             readOnly | ||||||
|  |             value={oembed && oembed.html || ''} | ||||||
|  |             onClick={this.handleTextareaClick} | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <p className='hint'> | ||||||
|  |             <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' /> | ||||||
|  |           </p> | ||||||
|  |  | ||||||
|  |           <iframe | ||||||
|  |             className='embed-modal__iframe' | ||||||
|  |             frameBorder='0' | ||||||
|  |             ref={this.setIframeRef} | ||||||
|  |             title='preview' | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -5,24 +5,26 @@ import spring from 'react-motion/lib/spring'; | |||||||
| import BundleContainer from '../containers/bundle_container'; | import BundleContainer from '../containers/bundle_container'; | ||||||
| import BundleModalError from './bundle_modal_error'; | import BundleModalError from './bundle_modal_error'; | ||||||
| import ModalLoading from './modal_loading'; | import ModalLoading from './modal_loading'; | ||||||
| import ActionsModal from '../components/actions_modal'; | import ActionsModal from './actions_modal'; | ||||||
|  | import MediaModal from './media_modal'; | ||||||
|  | import VideoModal from './video_modal'; | ||||||
|  | import BoostModal from './boost_modal'; | ||||||
|  | import ConfirmationModal from './confirmation_modal'; | ||||||
| import { | import { | ||||||
|   MediaModal, |  | ||||||
|   OnboardingModal, |   OnboardingModal, | ||||||
|   VideoModal, |  | ||||||
|   BoostModal, |  | ||||||
|   ConfirmationModal, |  | ||||||
|   ReportModal, |   ReportModal, | ||||||
|  |   EmbedModal, | ||||||
| } from '../../../features/ui/util/async-components'; | } from '../../../features/ui/util/async-components'; | ||||||
|  |  | ||||||
| const MODAL_COMPONENTS = { | const MODAL_COMPONENTS = { | ||||||
|   'MEDIA': MediaModal, |   'MEDIA': () => Promise.resolve({ default: MediaModal }), | ||||||
|   'ONBOARDING': OnboardingModal, |   'ONBOARDING': OnboardingModal, | ||||||
|   'VIDEO': VideoModal, |   'VIDEO': () => Promise.resolve({ default: VideoModal }), | ||||||
|   'BOOST': BoostModal, |   'BOOST': () => Promise.resolve({ default: BoostModal }), | ||||||
|   'CONFIRM': ConfirmationModal, |   'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), | ||||||
|   'REPORT': ReportModal, |   'REPORT': ReportModal, | ||||||
|   'ACTIONS': () => Promise.resolve({ default: ActionsModal }), |   'ACTIONS': () => Promise.resolve({ default: ActionsModal }), | ||||||
|  |   'EMBED': EmbedModal, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export default class ModalRoot extends React.PureComponent { | export default class ModalRoot extends React.PureComponent { | ||||||
| @@ -80,8 +82,8 @@ export default class ModalRoot extends React.PureComponent { | |||||||
|     return { opacity: spring(0), scale: spring(0.98) }; |     return { opacity: spring(0), scale: spring(0.98) }; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   renderLoading = () => { |   renderLoading = modalId => () => { | ||||||
|     return <ModalLoading />; |     return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   renderError = (props) => { |   renderError = (props) => { | ||||||
| @@ -115,7 +117,7 @@ export default class ModalRoot extends React.PureComponent { | |||||||
|               <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}> |               <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}> | ||||||
|                 <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} /> |                 <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} /> | ||||||
|                 <div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> |                 <div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> | ||||||
|                   <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}> |                   <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> | ||||||
|                     {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />} |                     {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />} | ||||||
|                   </BundleContainer> |                   </BundleContainer> | ||||||
|                 </div> |                 </div> | ||||||
|   | |||||||
| @@ -30,7 +30,7 @@ const PageOne = ({ acct, domain }) => ( | |||||||
|     <div> |     <div> | ||||||
|       <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1> |       <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1> | ||||||
|       <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p> |       <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p> | ||||||
|       <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p> |       <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>@{acct}@{domain}</strong> }} /></p> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| ); | ); | ||||||
|   | |||||||
| @@ -12,13 +12,12 @@ export default class UploadArea extends React.PureComponent { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   handleKeyUp = (e) => { |   handleKeyUp = (e) => { | ||||||
|     e.preventDefault(); |  | ||||||
|     e.stopPropagation(); |  | ||||||
|  |  | ||||||
|     const keyCode = e.keyCode; |     const keyCode = e.keyCode; | ||||||
|     if (this.props.active) { |     if (this.props.active) { | ||||||
|       switch(keyCode) { |       switch(keyCode) { | ||||||
|       case 27: |       case 27: | ||||||
|  |         e.preventDefault(); | ||||||
|  |         e.stopPropagation(); | ||||||
|         this.props.onClose(); |         this.props.onClose(); | ||||||
|         break; |         break; | ||||||
|       } |       } | ||||||
|   | |||||||
| @@ -5,4 +5,4 @@ const mapStateToProps = state => ({ | |||||||
|   columns: state.getIn(['settings', 'columns']), |   columns: state.getIn(['settings', 'columns']), | ||||||
| }); | }); | ||||||
|  |  | ||||||
| export default connect(mapStateToProps)(ColumnsArea); | export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea); | ||||||
|   | |||||||
| @@ -1,17 +1,17 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import classNames from 'classnames'; |  | ||||||
| import Redirect from 'react-router-dom/Redirect'; |  | ||||||
| import NotificationsContainer from './containers/notifications_container'; | import NotificationsContainer from './containers/notifications_container'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import LoadingBarContainer from './containers/loading_bar_container'; | import LoadingBarContainer from './containers/loading_bar_container'; | ||||||
| import TabsBar from './components/tabs_bar'; | import TabsBar from './components/tabs_bar'; | ||||||
| import ModalContainer from './containers/modal_container'; | import ModalContainer from './containers/modal_container'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
|  | import { Redirect, withRouter } from 'react-router-dom'; | ||||||
| import { isMobile } from '../../is_mobile'; | import { isMobile } from '../../is_mobile'; | ||||||
| import { debounce } from 'lodash'; | import { debounce } from 'lodash'; | ||||||
| import { uploadCompose } from '../../actions/compose'; | import { uploadCompose } from '../../actions/compose'; | ||||||
| import { refreshHomeTimeline } from '../../actions/timelines'; | import { refreshHomeTimeline } from '../../actions/timelines'; | ||||||
| import { refreshNotifications } from '../../actions/notifications'; | import { refreshNotifications } from '../../actions/notifications'; | ||||||
|  | import { clearStatusesHeight } from '../../actions/statuses'; | ||||||
| import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | ||||||
| import UploadArea from './components/upload_area'; | import UploadArea from './components/upload_area'; | ||||||
| import ColumnsAreaContainer from './containers/columns_area_container'; | import ColumnsAreaContainer from './containers/columns_area_container'; | ||||||
| @@ -35,6 +35,7 @@ import { | |||||||
|   FavouritedStatuses, |   FavouritedStatuses, | ||||||
|   Blocks, |   Blocks, | ||||||
|   Mutes, |   Mutes, | ||||||
|  |   PinnedStatuses, | ||||||
| } from './util/async-components'; | } from './util/async-components'; | ||||||
|  |  | ||||||
| // Dummy import, to make sure that <Status /> ends up in the application bundle. | // Dummy import, to make sure that <Status /> ends up in the application bundle. | ||||||
| @@ -42,11 +43,11 @@ import { | |||||||
| import '../../components/status'; | import '../../components/status'; | ||||||
|  |  | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   systemFontUi: state.getIn(['meta', 'system_font_ui']), |  | ||||||
|   isComposing: state.getIn(['compose', 'is_composing']), |   isComposing: state.getIn(['compose', 'is_composing']), | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @connect(mapStateToProps) | @connect(mapStateToProps) | ||||||
|  | @withRouter | ||||||
| export default class UI extends React.PureComponent { | export default class UI extends React.PureComponent { | ||||||
|  |  | ||||||
|   static contextTypes = { |   static contextTypes = { | ||||||
| @@ -56,8 +57,8 @@ export default class UI extends React.PureComponent { | |||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     dispatch: PropTypes.func.isRequired, |     dispatch: PropTypes.func.isRequired, | ||||||
|     children: PropTypes.node, |     children: PropTypes.node, | ||||||
|     systemFontUi: PropTypes.bool, |  | ||||||
|     isComposing: PropTypes.bool, |     isComposing: PropTypes.bool, | ||||||
|  |     location: PropTypes.object, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   state = { |   state = { | ||||||
| @@ -66,6 +67,9 @@ export default class UI extends React.PureComponent { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   handleResize = debounce(() => { |   handleResize = debounce(() => { | ||||||
|  |     // The cached heights are no longer accurate, invalidate | ||||||
|  |     this.props.dispatch(clearStatusesHeight()); | ||||||
|  |  | ||||||
|     this.setState({ width: window.innerWidth }); |     this.setState({ width: window.innerWidth }); | ||||||
|   }, 500, { |   }, 500, { | ||||||
|     trailing: true, |     trailing: true, | ||||||
| @@ -131,7 +135,7 @@ export default class UI extends React.PureComponent { | |||||||
|     if (data.type === 'navigate') { |     if (data.type === 'navigate') { | ||||||
|       this.context.router.history.push(data.path); |       this.context.router.history.push(data.path); | ||||||
|     } else { |     } else { | ||||||
|       console.warn('Unknown message type:', data.type); // eslint-disable-line no-console |       console.warn('Unknown message type:', data.type); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -164,6 +168,12 @@ export default class UI extends React.PureComponent { | |||||||
|     return true; |     return true; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   componentDidUpdate (prevProps) { | ||||||
|  |     if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { | ||||||
|  |       this.columnsAreaNode.handleChildrenContentChange(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|     window.removeEventListener('resize', this.handleResize); |     window.removeEventListener('resize', this.handleResize); | ||||||
|     document.removeEventListener('dragenter', this.handleDragEnter); |     document.removeEventListener('dragenter', this.handleDragEnter); | ||||||
| @@ -177,18 +187,18 @@ export default class UI extends React.PureComponent { | |||||||
|     this.node = c; |     this.node = c; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   setColumnsAreaRef = (c) => { | ||||||
|  |     this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { width, draggingOver } = this.state; |     const { width, draggingOver } = this.state; | ||||||
|     const { children } = this.props; |     const { children } = this.props; | ||||||
|  |  | ||||||
|     const className = classNames('ui', { |  | ||||||
|       'system-font': this.props.systemFontUi, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className={className} ref={this.setRef}> |       <div className='ui' ref={this.setRef}> | ||||||
|         <TabsBar /> |         <TabsBar /> | ||||||
|         <ColumnsAreaContainer singleColumn={isMobile(width)}> |         <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}> | ||||||
|           <WrappedSwitch> |           <WrappedSwitch> | ||||||
|             <Redirect from='/' to='/getting-started' exact /> |             <Redirect from='/' to='/getting-started' exact /> | ||||||
|             <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> |             <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> | ||||||
| @@ -199,6 +209,7 @@ export default class UI extends React.PureComponent { | |||||||
|  |  | ||||||
|             <WrappedRoute path='/notifications' component={Notifications} content={children} /> |             <WrappedRoute path='/notifications' component={Notifications} content={children} /> | ||||||
|             <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> |             <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> | ||||||
|  |             <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> | ||||||
|  |  | ||||||
|             <WrappedRoute path='/statuses/new' component={Compose} content={children} /> |             <WrappedRoute path='/statuses/new' component={Compose} content={children} /> | ||||||
|             <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> |             <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> | ||||||
|   | |||||||
| @@ -34,6 +34,10 @@ export function GettingStarted () { | |||||||
|   return import(/* webpackChunkName: "features/getting_started" */'../../getting_started'); |   return import(/* webpackChunkName: "features/getting_started" */'../../getting_started'); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export function PinnedStatuses () { | ||||||
|  |   return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses'); | ||||||
|  | } | ||||||
|  |  | ||||||
| export function AccountTimeline () { | export function AccountTimeline () { | ||||||
|   return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline'); |   return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline'); | ||||||
| } | } | ||||||
| @@ -78,26 +82,10 @@ export function Mutes () { | |||||||
|   return import(/* webpackChunkName: "features/mutes" */'../../mutes'); |   return import(/* webpackChunkName: "features/mutes" */'../../mutes'); | ||||||
| } | } | ||||||
|  |  | ||||||
| export function MediaModal () { |  | ||||||
|   return import(/* webpackChunkName: "modals/media_modal" */'../components/media_modal'); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export function OnboardingModal () { | export function OnboardingModal () { | ||||||
|   return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal'); |   return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal'); | ||||||
| } | } | ||||||
|  |  | ||||||
| export function VideoModal () { |  | ||||||
|   return import(/* webpackChunkName: "modals/video_modal" */'../components/video_modal'); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export function BoostModal () { |  | ||||||
|   return import(/* webpackChunkName: "modals/boost_modal" */'../components/boost_modal'); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export function ConfirmationModal () { |  | ||||||
|   return import(/* webpackChunkName: "modals/confirmation_modal" */'../components/confirmation_modal'); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export function ReportModal () { | export function ReportModal () { | ||||||
|   return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); |   return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); | ||||||
| } | } | ||||||
| @@ -109,3 +97,7 @@ export function MediaGallery () { | |||||||
| export function VideoPlayer () { | export function VideoPlayer () { | ||||||
|   return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player'); |   return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player'); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export function EmbedModal () { | ||||||
|  |   return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal'); | ||||||
|  | } | ||||||
|   | |||||||
| @@ -47,7 +47,7 @@ | |||||||
|   "compose_form.lock_disclaimer.lock": "مقفل", |   "compose_form.lock_disclaimer.lock": "مقفل", | ||||||
|   "compose_form.placeholder": "فيمَ تفكّر؟", |   "compose_form.placeholder": "فيمَ تفكّر؟", | ||||||
|   "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", |   "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", | ||||||
|   "compose_form.publish": "بوّق !", |   "compose_form.publish": "بوّق", | ||||||
|   "compose_form.publish_loud": "{publish}!", |   "compose_form.publish_loud": "{publish}!", | ||||||
|   "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس", |   "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس", | ||||||
|   "compose_form.spoiler": "أخفِ النص واعرض تحذيرا", |   "compose_form.spoiler": "أخفِ النص واعرض تحذيرا", | ||||||
| @@ -63,6 +63,8 @@ | |||||||
|   "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟", |   "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟", | ||||||
|   "confirmations.unfollow.confirm": "Unfollow", |   "confirmations.unfollow.confirm": "Unfollow", | ||||||
|   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", |   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", | ||||||
|  |   "embed.instructions": "Embed this status on your website by copying the code below.", | ||||||
|  |   "embed.preview": "Here is what it will look like:", | ||||||
|   "emoji_button.activity": "الأنشطة", |   "emoji_button.activity": "الأنشطة", | ||||||
|   "emoji_button.flags": "الأعلام", |   "emoji_button.flags": "الأعلام", | ||||||
|   "emoji_button.food": "الطعام والشراب", |   "emoji_button.food": "الطعام والشراب", | ||||||
| @@ -162,12 +164,14 @@ | |||||||
|   "standalone.public_title": "A look inside...", |   "standalone.public_title": "A look inside...", | ||||||
|   "status.cannot_reblog": "تعذرت ترقية هذا المنشور", |   "status.cannot_reblog": "تعذرت ترقية هذا المنشور", | ||||||
|   "status.delete": "إحذف", |   "status.delete": "إحذف", | ||||||
|  |   "status.embed": "Embed", | ||||||
|   "status.favourite": "أضف إلى المفضلة", |   "status.favourite": "أضف إلى المفضلة", | ||||||
|   "status.load_more": "حمّل المزيد", |   "status.load_more": "حمّل المزيد", | ||||||
|   "status.media_hidden": "الصورة مستترة", |   "status.media_hidden": "الصورة مستترة", | ||||||
|   "status.mention": "أذكُر @{name}", |   "status.mention": "أذكُر @{name}", | ||||||
|   "status.mute_conversation": "Mute conversation", |   "status.mute_conversation": "Mute conversation", | ||||||
|   "status.open": "وسع هذه المشاركة", |   "status.open": "وسع هذه المشاركة", | ||||||
|  |   "status.pin": "Pin on profile", | ||||||
|   "status.reblog": "رَقِّي", |   "status.reblog": "رَقِّي", | ||||||
|   "status.reblogged_by": "{name} رقى", |   "status.reblogged_by": "{name} رقى", | ||||||
|   "status.reply": "ردّ", |   "status.reply": "ردّ", | ||||||
| @@ -179,6 +183,7 @@ | |||||||
|   "status.show_less": "إعرض أقلّ", |   "status.show_less": "إعرض أقلّ", | ||||||
|   "status.show_more": "أظهر المزيد", |   "status.show_more": "أظهر المزيد", | ||||||
|   "status.unmute_conversation": "Unmute conversation", |   "status.unmute_conversation": "Unmute conversation", | ||||||
|  |   "status.unpin": "Unpin from profile", | ||||||
|   "tabs_bar.compose": "تحرير", |   "tabs_bar.compose": "تحرير", | ||||||
|   "tabs_bar.federated_timeline": "الموحَّد", |   "tabs_bar.federated_timeline": "الموحَّد", | ||||||
|   "tabs_bar.home": "الرئيسية", |   "tabs_bar.home": "الرئيسية", | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user