Compare commits
	
		
			371 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 4c14ff659b | ||
|  | dd6f9a1b82 | ||
|  | 3f07f1b2b1 | ||
|  | 44245926f1 | ||
|  | 8811778b55 | ||
|  | 1eab53ee10 | ||
|  | 7be3131240 | ||
|  | 198a9a4fa4 | ||
|  | ec36df97c4 | ||
|  | c8969dca35 | ||
|  | 1e3b1d7211 | ||
|  | 0698c610a6 | ||
|  | 629fae8b3b | ||
|  | c30e6433de | ||
|  | cea5597722 | ||
|  | 48d77ea1eb | ||
|  | efec507230 | ||
|  | 54edb4b853 | ||
|  | 6c81f9d6e5 | ||
|  | 472df24579 | ||
|  | 0d1215e82f | ||
|  | e77cc032c2 | ||
|  | 67559361e8 | ||
|  | 4a73615193 | ||
|  | bdcc9e2ceb | ||
|  | 3816943e6b | ||
|  | b39d512ade | ||
|  | 04046a4983 | ||
|  | a4c500176b | ||
|  | 1aad015bbb | ||
|  | 94fba44eec | ||
|  | 721460a59b | ||
|  | 45b595cdca | ||
|  | aad3df6afc | ||
|  | 1023f52eaa | ||
|  | 596dab06e9 | ||
|  | 4f0597d579 | ||
|  | 2bbf987a0a | ||
|  | af00220d79 | ||
|  | 9239e4ce4d | ||
|  | 06f26e09b4 | ||
|  | 331263270b | ||
|  | 283a5ec1a4 | ||
|  | 550ff677da | ||
|  | da77f65c46 | ||
|  | 9e2ff3ef71 | ||
|  | b9d241c6f5 | ||
|  | 56af04dbb4 | ||
|  | 60944d5dca | ||
|  | 081f907f90 | ||
|  | f29918e707 | ||
|  | af10c9fbff | ||
|  | 8f8e677630 | ||
|  | 4931eac280 | ||
|  | 881856553e | ||
|  | 0a6b098668 | ||
|  | 0ef9d45d05 | ||
|  | a6a206ef85 | ||
|  | bbff144004 | ||
|  | 47d48fed8d | ||
|  | 3018043fc2 | ||
|  | c2bee07dbc | ||
|  | a345479de2 | ||
|  | 08f00df94b | ||
|  | ab71cf4593 | ||
|  | c450ddb613 | ||
|  | 15b886a6f0 | ||
|  | 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 | ||
|  | 559fd08845 | ||
|  | 202942a76f | ||
|  | c3e355388a | ||
|  | d4c4820c03 | ||
|  | e05606c8d0 | ||
|  | 161f72cce3 | ||
|  | 8ccb3b96ab | ||
|  | e9ee249fd5 | ||
|  | 4b6cd1dfdb | ||
|  | b9ec3b7e7c | ||
|  | 9b247c3d88 | ||
|  | c7cc806251 | ||
|  | 82b4cf4acb | ||
|  | 3e7a541e09 | ||
|  | 93aafa8549 | ||
|  | bb85043f46 | ||
|  | e1fcad34a9 | ||
|  | 155ba8fd3a | ||
|  | e44f03bc71 | ||
|  | 970297a138 | ||
|  | 29abc9438c | ||
|  | f91284d230 | ||
|  | feadf7553d | ||
|  | ea33cdc30b | ||
|  | 579e85f606 | ||
|  | ea144ba302 | ||
|  | 4f04981dde | ||
|  | 990cea471e | ||
|  | 0913351dcf | ||
|  | 57a794d8eb | ||
|  | a5e0cf2450 | ||
|  | a46ba4a8f5 | ||
|  | c71874b84c | ||
|  | 53b2b1b238 | ||
|  | 634b71ed1d | ||
|  | 3d378ed0b4 | ||
|  | 7e0c00a555 | ||
|  | f0bb2c6d1e | 
| @@ -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= | ||||||
| @@ -69,7 +69,7 @@ SMTP_FROM_ADDRESS=notifications@example.com | |||||||
| #SMTP_CA_FILE=/etc/ssl/certs/ca-certificates.crt | #SMTP_CA_FILE=/etc/ssl/certs/ca-certificates.crt | ||||||
| #SMTP_OPENSSL_VERIFY_MODE=peer | #SMTP_OPENSSL_VERIFY_MODE=peer | ||||||
| #SMTP_ENABLE_STARTTLS_AUTO=true | #SMTP_ENABLE_STARTTLS_AUTO=true | ||||||
|  | #SMTP_TLS=true | ||||||
|  |  | ||||||
| # Optional user upload path and URL (images, avatars). Default is :rails_root/public/system. If you set this variable, you are responsible for making your HTTP server (eg. nginx) serve these files. | # Optional user upload path and URL (images, avatars). Default is :rails_root/public/system. If you set this variable, you are responsible for making your HTTP server (eg. nginx) serve these files. | ||||||
| # PAPERCLIP_ROOT_PATH=/var/lib/mastodon/public-system | # PAPERCLIP_ROOT_PATH=/var/lib/mastodon/public-system | ||||||
| @@ -98,6 +98,23 @@ SMTP_FROM_ADDRESS=notifications@example.com | |||||||
| # S3_ENDPOINT= | # S3_ENDPOINT= | ||||||
| # S3_SIGNATURE_VERSION= | # S3_SIGNATURE_VERSION= | ||||||
|  |  | ||||||
|  | # Swift (optional) | ||||||
|  | # SWIFT_ENABLED=true | ||||||
|  | # SWIFT_USERNAME= | ||||||
|  | # For Keystone V3, the value for SWIFT_TENANT should be the project name | ||||||
|  | # SWIFT_TENANT= | ||||||
|  | # SWIFT_PASSWORD= | ||||||
|  | # Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid | ||||||
|  | # issues with token rate-limiting during high load. | ||||||
|  | # SWIFT_AUTH_URL= | ||||||
|  | # SWIFT_CONTAINER= | ||||||
|  | # SWIFT_OBJECT_URL= | ||||||
|  | # SWIFT_REGION= | ||||||
|  | # Defaults to 'default' | ||||||
|  | # SWIFT_DOMAIN_NAME= | ||||||
|  | # Defaults to 60 seconds. Set to 0 to disable | ||||||
|  | # SWIFT_CACHE_TTL= | ||||||
|  |  | ||||||
| # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front | # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front | ||||||
| # S3_CLOUDFRONT_HOST= | # S3_CLOUDFRONT_HOST= | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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
									
								
								.rubocop.yml
									
									
									
									
									
								
							
							
						
						| @@ -10,6 +10,7 @@ AllCops: | |||||||
|   - 'node_modules/**/*' |   - 'node_modules/**/*' | ||||||
|   - 'Vagrantfile' |   - 'Vagrantfile' | ||||||
|   - 'vendor/**/*' |   - 'vendor/**/*' | ||||||
|  |   - 'lib/json_ld/*' | ||||||
|  |  | ||||||
| Bundler/OrderedGems: | Bundler/OrderedGems: | ||||||
|   Enabled: false |   Enabled: false | ||||||
| @@ -27,6 +28,7 @@ Metrics/AbcSize: | |||||||
|   Max: 100 |   Max: 100 | ||||||
|  |  | ||||||
| Metrics/BlockLength: | Metrics/BlockLength: | ||||||
|  |   Max: 35 | ||||||
|   Exclude: |   Exclude: | ||||||
|     - 'lib/tasks/**/*' |     - 'lib/tasks/**/*' | ||||||
|  |  | ||||||
| @@ -35,10 +37,10 @@ Metrics/BlockNesting: | |||||||
|  |  | ||||||
| Metrics/ClassLength: | Metrics/ClassLength: | ||||||
|   CountComments: false |   CountComments: false | ||||||
|   Max: 200 |   Max: 300 | ||||||
|  |  | ||||||
| Metrics/CyclomaticComplexity: | Metrics/CyclomaticComplexity: | ||||||
|   Max: 15 |   Max: 25 | ||||||
|  |  | ||||||
| Metrics/LineLength: | Metrics/LineLength: | ||||||
|   AllowURI: true |   AllowURI: true | ||||||
| @@ -53,11 +55,11 @@ Metrics/ModuleLength: | |||||||
|   Max: 200 |   Max: 200 | ||||||
|  |  | ||||||
| Metrics/ParameterLists: | Metrics/ParameterLists: | ||||||
|   Max: 4 |   Max: 5 | ||||||
|   CountKeywordArgs: true |   CountKeywordArgs: true | ||||||
|  |  | ||||||
| Metrics/PerceivedComplexity: | Metrics/PerceivedComplexity: | ||||||
|   Max: 10 |   Max: 20 | ||||||
|  |  | ||||||
| Rails: | Rails: | ||||||
|   Enabled: true |   Enabled: true | ||||||
|   | |||||||
| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								Aptfile
									
									
									
									
									
								
							
							
						
						| @@ -1,7 +1,10 @@ | |||||||
| protobuf-compiler |  | ||||||
| libprotobuf-dev |  | ||||||
| ffmpeg | ffmpeg | ||||||
|  | libicu[0-9][0-9] | ||||||
|  | libicu-dev | ||||||
|  | libidn11 | ||||||
|  | libidn11-dev | ||||||
|  | libpq-dev | ||||||
|  | libprotobuf-dev | ||||||
| libxdamage1 | libxdamage1 | ||||||
| libxfixes3 | libxfixes3 | ||||||
| libicu-dev | protobuf-compiler | ||||||
| libidn11-dev |  | ||||||
|   | |||||||
							
								
								
									
										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
									
									
									
									
									
								
							
							
						
						| @@ -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 | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						| @@ -5,8 +5,8 @@ ruby '>= 2.3.0', '< 2.5.0' | |||||||
|  |  | ||||||
| gem 'pkg-config', '~> 1.2' | gem 'pkg-config', '~> 1.2' | ||||||
|  |  | ||||||
| gem 'puma', '~> 3.8' | gem 'puma', '~> 3.10' | ||||||
| gem 'rails', '~> 5.1.0' | gem 'rails', '~> 5.1.4' | ||||||
| gem 'uglifier', '~> 3.2' | gem 'uglifier', '~> 3.2' | ||||||
|  |  | ||||||
| gem 'hamlit-rails', '~> 0.2' | gem 'hamlit-rails', '~> 0.2' | ||||||
| @@ -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,8 +23,9 @@ 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 'cld3', '~> 3.1' | gem 'iso-639' | ||||||
|  | gem 'cld3', '~> 3.2.0' | ||||||
| gem 'devise', '~> 4.2' | gem 'devise', '~> 4.2' | ||||||
| gem 'devise-two-factor', '~> 3.0' | gem 'devise-two-factor', '~> 3.0' | ||||||
| gem 'doorkeeper', '~> 4.2' | gem 'doorkeeper', '~> 4.2' | ||||||
| @@ -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' | ||||||
|   | |||||||
							
								
								
									
										187
									
								
								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.4) | ||||||
|       actionpack (= 5.1.2) |       actionpack (= 5.1.4) | ||||||
|       nio4r (~> 2.0) |       nio4r (~> 2.0) | ||||||
|       websocket-driver (~> 0.6.1) |       websocket-driver (~> 0.6.1) | ||||||
|     actionmailer (5.1.2) |     actionmailer (5.1.4) | ||||||
|       actionpack (= 5.1.2) |       actionpack (= 5.1.4) | ||||||
|       actionview (= 5.1.2) |       actionview (= 5.1.4) | ||||||
|       activejob (= 5.1.2) |       activejob (= 5.1.4) | ||||||
|       mail (~> 2.5, >= 2.5.4) |       mail (~> 2.5, >= 2.5.4) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
|     actionpack (5.1.2) |     actionpack (5.1.4) | ||||||
|       actionview (= 5.1.2) |       actionview (= 5.1.4) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.4) | ||||||
|       rack (~> 2.0) |       rack (~> 2.0) | ||||||
|       rack-test (~> 0.6.3) |       rack-test (>= 0.6.3) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
|       rails-html-sanitizer (~> 1.0, >= 1.0.2) |       rails-html-sanitizer (~> 1.0, >= 1.0.2) | ||||||
|     actionview (5.1.2) |     actionview (5.1.4) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.4) | ||||||
|       builder (~> 3.1) |       builder (~> 3.1) | ||||||
|       erubi (~> 1.4) |       erubi (~> 1.4) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
| @@ -30,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.4) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.4) | ||||||
|       globalid (>= 0.3.6) |       globalid (>= 0.3.6) | ||||||
|     activemodel (5.1.2) |     activemodel (5.1.4) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.4) | ||||||
|     activerecord (5.1.2) |     activerecord (5.1.4) | ||||||
|       activemodel (= 5.1.2) |       activemodel (= 5.1.4) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.4) | ||||||
|       arel (~> 8.0) |       arel (~> 8.0) | ||||||
|     activesupport (5.1.2) |     activesupport (5.1.4) | ||||||
|       concurrent-ruby (~> 1.0, >= 1.0.2) |       concurrent-ruby (~> 1.0, >= 1.0.2) | ||||||
|       i18n (~> 0.7) |       i18n (~> 0.7) | ||||||
|       minitest (~> 5.1) |       minitest (~> 5.1) | ||||||
|       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,9 +108,9 @@ 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.2.0) | ||||||
|       ffi (>= 1.1.0, < 1.10.0) |       ffi (>= 1.1.0, < 1.10.0) | ||||||
|     climate_control (0.2.0) |     climate_control (0.2.0) | ||||||
|     cocaine (0.5.8) |     cocaine (0.5.8) | ||||||
| @@ -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,8 +322,8 @@ 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.10.0) | ||||||
|     pundit (1.1.0) |     pundit (1.1.0) | ||||||
|       activesupport (>= 3.0.0) |       activesupport (>= 3.0.0) | ||||||
|     rabl (0.13.1) |     rabl (0.13.1) | ||||||
| @@ -310,20 +334,20 @@ GEM | |||||||
|     rack-cors (0.4.1) |     rack-cors (0.4.1) | ||||||
|     rack-protection (2.0.0) |     rack-protection (2.0.0) | ||||||
|       rack |       rack | ||||||
|     rack-test (0.6.3) |     rack-test (0.7.0) | ||||||
|       rack (>= 1.0) |       rack (>= 1.0, < 3) | ||||||
|     rack-timeout (0.4.2) |     rack-timeout (0.4.2) | ||||||
|     rails (5.1.2) |     rails (5.1.4) | ||||||
|       actioncable (= 5.1.2) |       actioncable (= 5.1.4) | ||||||
|       actionmailer (= 5.1.2) |       actionmailer (= 5.1.4) | ||||||
|       actionpack (= 5.1.2) |       actionpack (= 5.1.4) | ||||||
|       actionview (= 5.1.2) |       actionview (= 5.1.4) | ||||||
|       activejob (= 5.1.2) |       activejob (= 5.1.4) | ||||||
|       activemodel (= 5.1.2) |       activemodel (= 5.1.4) | ||||||
|       activerecord (= 5.1.2) |       activerecord (= 5.1.4) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.4) | ||||||
|       bundler (>= 1.3.0, < 2.0) |       bundler (>= 1.3.0) | ||||||
|       railties (= 5.1.2) |       railties (= 5.1.4) | ||||||
|       sprockets-rails (>= 2.0.0) |       sprockets-rails (>= 2.0.0) | ||||||
|     rails-controller-testing (1.0.2) |     rails-controller-testing (1.0.2) | ||||||
|       actionpack (~> 5.x, >= 5.0.1) |       actionpack (~> 5.x, >= 5.0.1) | ||||||
| @@ -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.4) | ||||||
|       actionpack (= 5.1.2) |       actionpack (= 5.1.4) | ||||||
|       activesupport (= 5.1.2) |       activesupport (= 5.1.4) | ||||||
|       method_source |       method_source | ||||||
|       rake (>= 0.8.7) |       rake (>= 0.8.7) | ||||||
|       thor (>= 0.18.1, < 2.0) |       thor (>= 0.18.1, < 2.0) | ||||||
|     rainbow (2.2.2) |     rainbow (2.2.2) | ||||||
|       rake |       rake | ||||||
|     rake (12.0.0) |     rake (12.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) | ||||||
| @@ -443,22 +472,22 @@ GEM | |||||||
|     sprockets (3.7.1) |     sprockets (3.7.1) | ||||||
|       concurrent-ruby (~> 1.0) |       concurrent-ruby (~> 1.0) | ||||||
|       rack (> 1, < 3) |       rack (> 1, < 3) | ||||||
|     sprockets-rails (3.2.0) |     sprockets-rails (3.2.1) | ||||||
|       actionpack (>= 4.0) |       actionpack (>= 4.0) | ||||||
|       activesupport (>= 4.0) |       activesupport (>= 4.0) | ||||||
|       sprockets (>= 3.0.0) |       sprockets (>= 3.0.0) | ||||||
|     sshkit (1.13.1) |     sshkit (1.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,8 +540,8 @@ 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.2.0) | ||||||
|   climate_control (~> 0.2) |   climate_control (~> 0.2) | ||||||
|   devise (~> 4.2) |   devise (~> 4.2) | ||||||
|   devise-two-factor (~> 3.0) |   devise-two-factor (~> 3.0) | ||||||
| @@ -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) | ||||||
| @@ -550,16 +582,17 @@ DEPENDENCIES | |||||||
|   pghero (~> 1.7) |   pghero (~> 1.7) | ||||||
|   pkg-config (~> 1.2) |   pkg-config (~> 1.2) | ||||||
|   pry-rails (~> 0.3) |   pry-rails (~> 0.3) | ||||||
|   puma (~> 3.8) |   puma (~> 3.10) | ||||||
|   pundit (~> 1.1) |   pundit (~> 1.1) | ||||||
|   rabl (~> 0.13) |   rabl (~> 0.13) | ||||||
|   rack-attack (~> 5.0) |   rack-attack (~> 5.0) | ||||||
|   rack-cors (~> 0.4) |   rack-cors (~> 0.4) | ||||||
|   rack-timeout (~> 0.4) |   rack-timeout (~> 0.4) | ||||||
|   rails (~> 5.1.0) |   rails (~> 5.1.4) | ||||||
|   rails-controller-testing (~> 1.0) |   rails-controller-testing (~> 1.0) | ||||||
|   rails-i18n (~> 5.0) |   rails-i18n (~> 5.0) | ||||||
|   rails-settings-cached (~> 0.6) |   rails-settings-cached (~> 0.6) | ||||||
|  |   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 | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								app.json
									
									
									
									
									
								
							
							
						
						| @@ -2,7 +2,7 @@ | |||||||
|   "name": "Mastodon", |   "name": "Mastodon", | ||||||
|   "description": "A GNU Social-compatible microblogging server", |   "description": "A GNU Social-compatible microblogging server", | ||||||
|   "repository": "https://github.com/tootsuite/mastodon", |   "repository": "https://github.com/tootsuite/mastodon", | ||||||
|   "logo": "https://github.com/tootsuite/mastodon/raw/master/app/javascript/images/logo.svg", |   "logo": "https://github.com/tootsuite.png", | ||||||
|   "env": { |   "env": { | ||||||
|     "HEROKU": { |     "HEROKU": { | ||||||
|       "description": "Leave this as true", |       "description": "Leave this as true", | ||||||
|   | |||||||
| @@ -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 = [] | ||||||
|         @statuses = cache_collection(@statuses, Status) |  | ||||||
|  |         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) | ||||||
|  |         @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
									
								
							
							
						
						| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -14,8 +14,12 @@ module Admin | |||||||
|  |  | ||||||
|     private |     private | ||||||
|  |  | ||||||
|  |     def filtered_instances | ||||||
|  |       InstanceFilter.new(filter_params).results | ||||||
|  |     end | ||||||
|  |  | ||||||
|     def paginated_instances |     def paginated_instances | ||||||
|       Account.remote.by_domain_accounts.page(params[:page]) |       filtered_instances.page(params[:page]) | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     helper_method :paginated_instances |     helper_method :paginated_instances | ||||||
| @@ -27,5 +31,11 @@ module Admin | |||||||
|     def subscribeable_accounts |     def subscribeable_accounts | ||||||
|       Account.with_followers.remote.where(domain: params[:by_domain]) |       Account.with_followers.remote.where(domain: params[:by_domain]) | ||||||
|     end |     end | ||||||
|  |  | ||||||
|  |     def filter_params | ||||||
|  |       params.permit( | ||||||
|  |         :domain_name | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -13,6 +13,8 @@ module Admin | |||||||
|       closed_registrations_message |       closed_registrations_message | ||||||
|       open_deletion |       open_deletion | ||||||
|       timeline_preview |       timeline_preview | ||||||
|  |       bootstrap_timeline_accounts | ||||||
|  |       thumbnail | ||||||
|     ).freeze |     ).freeze | ||||||
|  |  | ||||||
|     BOOLEAN_SETTINGS = %w( |     BOOLEAN_SETTINGS = %w( | ||||||
| @@ -21,14 +23,23 @@ module Admin | |||||||
|       timeline_preview |       timeline_preview | ||||||
|     ).freeze |     ).freeze | ||||||
|  |  | ||||||
|  |     UPLOAD_SETTINGS = %w( | ||||||
|  |       thumbnail | ||||||
|  |     ).freeze | ||||||
|  |  | ||||||
|     def edit |     def edit | ||||||
|       @admin_settings = Form::AdminSettings.new |       @admin_settings = Form::AdminSettings.new | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     def update |     def update | ||||||
|       settings_params.each do |key, value| |       settings_params.each do |key, value| | ||||||
|         setting = Setting.where(var: key).first_or_initialize(var: key) |         if UPLOAD_SETTINGS.include?(key) | ||||||
|         setting.update(value: value_for_update(key, value)) |           upload = SiteUpload.where(var: key).first_or_initialize(var: key) | ||||||
|  |           upload.update(file: value) | ||||||
|  |         else | ||||||
|  |           setting = Setting.where(var: key).first_or_initialize(var: key) | ||||||
|  |           setting.update(value: value_for_update(key, value)) | ||||||
|  |         end | ||||||
|       end |       end | ||||||
|  |  | ||||||
|       flash[:notice] = I18n.t('generic.changes_saved_msg') |       flash[:notice] = I18n.t('generic.changes_saved_msg') | ||||||
|   | |||||||
| @@ -9,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,10 +62,11 @@ 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 | ||||||
|   | |||||||
| @@ -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,7 +14,10 @@ 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) | ||||||
|     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships |  | ||||||
|  |     options = @account.locked? ? {} : { following_map: { @account.id => true }, requested_map: { @account.id => false } } | ||||||
|  |  | ||||||
|  |     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def block |   def block | ||||||
| @@ -48,7 +51,7 @@ class Api::V1::AccountsController < Api::BaseController | |||||||
|     @account = Account.find(params[:id]) |     @account = Account.find(params[:id]) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def relationships |   def relationships(options = {}) | ||||||
|     AccountRelationshipsPresenter.new([@account.id], current_user.account_id) |     AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -10,6 +10,12 @@ class Api::V1::FollowsController < Api::BaseController | |||||||
|     raise ActiveRecord::RecordNotFound if follow_params[:uri].blank? |     raise ActiveRecord::RecordNotFound if follow_params[:uri].blank? | ||||||
|  |  | ||||||
|     @account = FollowService.new.call(current_user.account, target_uri).try(:target_account) |     @account = FollowService.new.call(current_user.account, target_uri).try(:target_account) | ||||||
|  |  | ||||||
|  |     if @account.nil? | ||||||
|  |       username, domain = target_uri.split('@') | ||||||
|  |       @account         = Account.find_remote!(username, domain) | ||||||
|  |     end | ||||||
|  |  | ||||||
|     render json: @account, serializer: REST::AccountSerializer |     render json: @account, serializer: REST::AccountSerializer | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										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
									
								
							
							
						
						| @@ -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 | ||||||
|   | |||||||
| @@ -11,7 +11,30 @@ class HomeController < ApplicationController | |||||||
|   private |   private | ||||||
|  |  | ||||||
|   def authenticate_user! |   def authenticate_user! | ||||||
|     redirect_to(single_user_mode? ? account_path(Account.first) : about_path) unless user_signed_in? |     return if user_signed_in? | ||||||
|  |  | ||||||
|  |     matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/) | ||||||
|  |  | ||||||
|  |     if matches | ||||||
|  |       case matches[1] | ||||||
|  |       when 'statuses' | ||||||
|  |         status = Status.find_by(id: matches[2]) | ||||||
|  |  | ||||||
|  |         if status && (status.public_visibility? || status.unlisted_visibility?) | ||||||
|  |           redirect_to(ActivityPub::TagManager.instance.url_for(status)) | ||||||
|  |           return | ||||||
|  |         end | ||||||
|  |       when 'accounts' | ||||||
|  |         account = Account.find_by(id: matches[2]) | ||||||
|  |  | ||||||
|  |         if account | ||||||
|  |           redirect_to(ActivityPub::TagManager.instance.url_for(account)) | ||||||
|  |           return | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     redirect_to(default_redirect_path) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def set_initial_state_json |   def set_initial_state_json | ||||||
| @@ -28,4 +51,14 @@ class HomeController < ApplicationController | |||||||
|       admin: Account.find_local(Setting.site_contact_username), |       admin: Account.find_local(Setting.site_contact_username), | ||||||
|     } |     } | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def default_redirect_path | ||||||
|  |     if request.path.start_with?('/web') | ||||||
|  |       new_user_session_path | ||||||
|  |     elsif single_user_mode? | ||||||
|  |       short_account_path(Account.first) | ||||||
|  |     else | ||||||
|  |       about_path | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										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 | ||||||
							
								
								
									
										40
									
								
								app/controllers/media_proxy_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,40 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class MediaProxyController < ApplicationController | ||||||
|  |   include RoutingHelper | ||||||
|  |  | ||||||
|  |   def show | ||||||
|  |     RedisLock.acquire(lock_options) do |lock| | ||||||
|  |       if lock.acquired? | ||||||
|  |         @media_attachment = MediaAttachment.remote.find(params[:id]) | ||||||
|  |         redownload! if @media_attachment.needs_redownload? && !reject_media? | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     redirect_to full_asset_url(@media_attachment.file.url(version)) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def redownload! | ||||||
|  |     @media_attachment.file_remote_url = @media_attachment.remote_url | ||||||
|  |     @media_attachment.touch(:created_at) | ||||||
|  |     @media_attachment.save! | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def version | ||||||
|  |     if request.path.ends_with?('/small') | ||||||
|  |       :small | ||||||
|  |     else | ||||||
|  |       :original | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def lock_options | ||||||
|  |     { redis: Redis.current, key: "media_download:#{params[:id]}" } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def reject_media? | ||||||
|  |     DomainBlock.find_by(domain: @media_attachment.account.domain)&.reject_media? | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -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
									
								
							
							
						
						| @@ -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
									
								
							
							
						
						| @@ -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 | ||||||
| @@ -38,4 +42,8 @@ module ApplicationHelper | |||||||
|  |  | ||||||
|     content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) |     content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def opengraph(property, content) | ||||||
|  |     tag(:meta, content: content, property: property) | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										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 | ||||||
|  |     LanguageDetector.instance.language_names.select(&HUMAN_LOCALES.method(:key?)) | ||||||
|  |   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 +1 @@ | |||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="61.076954mm" height="65.47831mm" viewBox="0 0 216.4144 232.00976"><path d="M211.80734 139.0875c-3.18125 16.36625-28.4925 34.2775-57.5625 37.74875-15.15875 1.80875-30.08375 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.39125 27.9425 21.11625.7225 39.91875-5.20625 39.91875-5.20625l.8675 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23234 213.82 1.40609 165.31125.20859 116.09125c-.365-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67234 3.45375 78.20359.2425 107.86484 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.975 14.7525 32.975 65.0825 0 0 .41375 37.13375-4.59875 62.915" fill="#3088d4"/><path d="M177.50984 80.077v60.94125h-24.14375v-59.15c0-12.46875-5.24625-18.7975-15.74-18.7975-11.6025 0-17.4175 7.5075-17.4175 22.3525v32.37625H96.20734V85.42325c0-14.845-5.81625-22.3525-17.41875-22.3525-10.49375 0-15.74 6.32875-15.74 18.7975v59.15H38.90484V80.077c0-12.455 3.17125-22.3525 9.54125-29.675 6.56875-7.3225 15.17125-11.07625 25.85-11.07625 12.355 0 21.71125 4.74875 27.8975 14.2475l6.01375 10.08125 6.015-10.08125c6.185-9.49875 15.54125-14.2475 27.8975-14.2475 10.6775 0 19.28 3.75375 25.85 11.07625 6.36875 7.3225 9.54 17.22 9.54 29.675" fill="#fff"/></svg> | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976"><path d="M211.80734 139.0875c-3.18125 16.36625-28.4925 34.2775-57.5625 37.74875-15.15875 1.80875-30.08375 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.39125 27.9425 21.11625.7225 39.91875-5.20625 39.91875-5.20625l.8675 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23234 213.82 1.40609 165.31125.20859 116.09125c-.365-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67234 3.45375 78.20359.2425 107.86484 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.975 14.7525 32.975 65.0825 0 0 .41375 37.13375-4.59875 62.915" fill="#3088d4"/><path d="M177.50984 80.077v60.94125h-24.14375v-59.15c0-12.46875-5.24625-18.7975-15.74-18.7975-11.6025 0-17.4175 7.5075-17.4175 22.3525v32.37625H96.20734V85.42325c0-14.845-5.81625-22.3525-17.41875-22.3525-10.49375 0-15.74 6.32875-15.74 18.7975v59.15H38.90484V80.077c0-12.455 3.17125-22.3525 9.54125-29.675 6.56875-7.3225 15.17125-11.07625 25.85-11.07625 12.355 0 21.71125 4.74875 27.8975 14.2475l6.01375 10.08125 6.015-10.08125c6.185-9.49875 15.54125-14.2475 27.8975-14.2475 10.6775 0 19.28 3.75375 25.85 11.07625 6.36875 7.3225 9.54 17.22 9.54 29.675" fill="#fff"/></svg> | ||||||
|   | |||||||
| Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB | 
| @@ -1 +1 @@ | |||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="61.077141mm" height="65.47831mm" viewBox="0 0 216.41507 232.00976"><path d="M211.80683 139.0875c-3.1825 16.36625-28.4925 34.2775-57.5625 37.74875-15.16 1.80875-30.0825 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.3925 27.9425 21.115.7225 39.91625-5.20625 39.91625-5.20625l.86875 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23183 213.82 1.40558 165.31125.20808 116.09125c-.36375-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67058 3.45375 78.20308.2425 107.86433 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.97625 14.7525 32.97625 65.0825 0 0 .4125 37.13375-4.6 62.915" fill="#3088d4"/><path d="M65.68743 96.45938c0 9.01375-7.3075 16.32125-16.3225 16.32125-9.01375 0-16.32-7.3075-16.32-16.32125 0-9.01375 7.30625-16.3225 16.32-16.3225 9.015 0 16.3225 7.30875 16.3225 16.3225M124.52893 96.45938c0 9.01375-7.30875 16.32125-16.3225 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.3225 7.30875 16.3225 16.3225M183.36933 96.45938c0 9.01375-7.3075 16.32125-16.32125 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.32125 7.30875 16.32125 16.3225" fill="#fff"/></svg> | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.41507 232.00976"><path d="M211.80683 139.0875c-3.1825 16.36625-28.4925 34.2775-57.5625 37.74875-15.16 1.80875-30.0825 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.3925 27.9425 21.115.7225 39.91625-5.20625 39.91625-5.20625l.86875 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23183 213.82 1.40558 165.31125.20808 116.09125c-.36375-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67058 3.45375 78.20308.2425 107.86433 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.97625 14.7525 32.97625 65.0825 0 0 .4125 37.13375-4.6 62.915" fill="#3088d4"/><path d="M65.68743 96.45938c0 9.01375-7.3075 16.32125-16.3225 16.32125-9.01375 0-16.32-7.3075-16.32-16.32125 0-9.01375 7.30625-16.3225 16.32-16.3225 9.015 0 16.3225 7.30875 16.3225 16.3225M124.52893 96.45938c0 9.01375-7.30875 16.32125-16.3225 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.3225 7.30875 16.3225 16.3225M183.36933 96.45938c0 9.01375-7.3075 16.32125-16.32125 16.32125-9.01375 0-16.32125-7.3075-16.32125-16.32125 0-9.01375 7.3075-16.3225 16.32125-16.3225 9.01375 0 16.32125 7.30875 16.32125 16.3225" fill="#fff"/></svg> | ||||||
|   | |||||||
| Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB | 
| Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.5 KiB | 
| Before Width: | Height: | Size: 25 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/javascript/images/preview.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 285 KiB | 
| @@ -1,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)); | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								app/javascript/mastodon/actions/height_cache.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,17 @@ | |||||||
|  | export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET'; | ||||||
|  | export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR'; | ||||||
|  |  | ||||||
|  | export function setHeight (key, id, height) { | ||||||
|  |   return { | ||||||
|  |     type: HEIGHT_CACHE_SET, | ||||||
|  |     key, | ||||||
|  |     id, | ||||||
|  |     height, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function clearHeight () { | ||||||
|  |   return { | ||||||
|  |     type: HEIGHT_CACHE_CLEAR, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -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
									
								
							
							
						
						| @@ -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, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
							
								
								
									
										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 () { | ||||||
|   | |||||||
| @@ -9,16 +9,19 @@ export default class ColumnBackButton extends React.PureComponent { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   handleClick = () => { |   handleClick = () => { | ||||||
|     if (window.history && window.history.length === 1) this.context.router.history.push('/'); |     if (window.history && window.history.length === 1) { | ||||||
|     else this.context.router.history.goBack(); |       this.context.router.history.push('/'); | ||||||
|  |     } else { | ||||||
|  |       this.context.router.history.goBack(); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     return ( |     return ( | ||||||
|       <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button'> |       <button onClick={this.handleClick} className='column-back-button'> | ||||||
|         <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> |         <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> | ||||||
|         <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> |         <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> | ||||||
|       </div> |       </button> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -134,7 +134,7 @@ export default class DropdownMenu extends React.PureComponent { | |||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}> |       <Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}> | ||||||
|         <DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-pressed={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}> |         <DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-expanded={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}> | ||||||
|           <i className={iconClassname} aria-hidden /> |           <i className={iconClassname} aria-hidden /> | ||||||
|         </DropdownTrigger> |         </DropdownTrigger> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ export default class IconButton extends React.PureComponent { | |||||||
|     size: PropTypes.number, |     size: PropTypes.number, | ||||||
|     active: PropTypes.bool, |     active: PropTypes.bool, | ||||||
|     pressed: PropTypes.bool, |     pressed: PropTypes.bool, | ||||||
|  |     expanded: PropTypes.bool, | ||||||
|     style: PropTypes.object, |     style: PropTypes.object, | ||||||
|     activeStyle: PropTypes.object, |     activeStyle: PropTypes.object, | ||||||
|     disabled: PropTypes.bool, |     disabled: PropTypes.bool, | ||||||
| @@ -77,6 +78,7 @@ export default class IconButton extends React.PureComponent { | |||||||
|           <button |           <button | ||||||
|             aria-label={this.props.title} |             aria-label={this.props.title} | ||||||
|             aria-pressed={this.props.pressed} |             aria-pressed={this.props.pressed} | ||||||
|  |             aria-expanded={this.props.expanded} | ||||||
|             title={this.props.title} |             title={this.props.title} | ||||||
|             className={classes.join(' ')} |             className={classes.join(' ')} | ||||||
|             onClick={this.handleClick} |             onClick={this.handleClick} | ||||||
|   | |||||||
| @@ -0,0 +1,123 @@ | |||||||
|  | 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.isRequired, | ||||||
|  |     id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||||||
|  |     index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||||||
|  |     listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||||||
|  |     saveHeightKey: PropTypes.string, | ||||||
|  |     cachedHeight: PropTypes.number, | ||||||
|  |     onHeightChange: PropTypes.func, | ||||||
|  |     children: PropTypes.node, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   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 () { | ||||||
|  |     const { intersectionObserverWrapper, id } = this.props; | ||||||
|  |  | ||||||
|  |     intersectionObserverWrapper.observe( | ||||||
|  |       id, | ||||||
|  |       this.node, | ||||||
|  |       this.handleIntersection | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     this.componentMounted = true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     const { intersectionObserverWrapper, id } = this.props; | ||||||
|  |     intersectionObserverWrapper.unobserve(id, this.node); | ||||||
|  |  | ||||||
|  |     this.componentMounted = false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleIntersection = (entry) => { | ||||||
|  |     const { onHeightChange, saveHeightKey, id } = this.props; | ||||||
|  |  | ||||||
|  |     if (this.node && this.node.children.length !== 0) { | ||||||
|  |       // save the height of the fully-rendered element | ||||||
|  |       this.height = getRectFromEntry(entry).height; | ||||||
|  |  | ||||||
|  |       if (onHeightChange && saveHeightKey) { | ||||||
|  |         onHeightChange(saveHeightKey, id, 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, cachedHeight } = this.props; | ||||||
|  |     const { isIntersecting, isHidden } = this.state; | ||||||
|  |  | ||||||
|  |     if (!isIntersecting && (isHidden || cachedHeight)) { | ||||||
|  |       return ( | ||||||
|  |         <article | ||||||
|  |           ref={this.handleRef} | ||||||
|  |           aria-posinset={index} | ||||||
|  |           aria-setsize={listLength} | ||||||
|  |           style={{ height: `${this.height || cachedHeight}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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -17,7 +17,7 @@ export default class LoadMore extends React.PureComponent { | |||||||
|     const { visible } = this.props; |     const { visible } = this.props; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <button className='load-more' disabled={!visible} style={{ opacity: visible ? 1 : 0 }} onClick={this.props.onClick}> |       <button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}> | ||||||
|         <FormattedMessage id='status.load_more' defaultMessage='Load more' /> |         <FormattedMessage id='status.load_more' defaultMessage='Load more' /> | ||||||
|       </button> |       </button> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -119,8 +119,8 @@ class Item extends React.PureComponent { | |||||||
|  |  | ||||||
|       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; |       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; | ||||||
|  |  | ||||||
|       const srcSet = hasSize && `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`; |       const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; | ||||||
|       const sizes = hasSize && `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`; |       const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; | ||||||
|  |  | ||||||
|       thumbnail = ( |       thumbnail = ( | ||||||
|         <a |         <a | ||||||
| @@ -212,10 +212,10 @@ export default class MediaGallery extends React.PureComponent { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       children = ( |       children = ( | ||||||
|         <div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}> |         <button className='media-spoiler' onClick={this.handleOpen}> | ||||||
|           <span className='media-spoiler__warning'>{warning}</span> |           <span className='media-spoiler__warning'>{warning}</span> | ||||||
|           <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> |           <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> | ||||||
|         </div> |         </button> | ||||||
|       ); |       ); | ||||||
|     } else { |     } else { | ||||||
|       const size = media.take(4).size; |       const size = media.take(4).size; | ||||||
|   | |||||||
							
								
								
									
										215
									
								
								app/javascript/mastodon/components/scrollable_list.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,215 @@ | |||||||
|  | import React, { PureComponent } from 'react'; | ||||||
|  | import { ScrollContainer } from 'react-router-scroll'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; | ||||||
|  | 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 contextTypes = { | ||||||
|  |     router: PropTypes.object, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   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     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; | ||||||
|  |     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) => ( | ||||||
|  |               <IntersectionObserverArticleContainer | ||||||
|  |                 key={child.key} | ||||||
|  |                 id={child.key} | ||||||
|  |                 index={index} | ||||||
|  |                 listLength={childrenCount} | ||||||
|  |                 intersectionObserverWrapper={this.intersectionObserverWrapper} | ||||||
|  |                 saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} | ||||||
|  |               > | ||||||
|  |                 {child} | ||||||
|  |               </IntersectionObserverArticleContainer> | ||||||
|  |             ))} | ||||||
|  |  | ||||||
|  |             {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, Video } 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; | ||||||
| @@ -166,46 +88,42 @@ export default class Status extends ImmutablePureComponent { | |||||||
|     return <div className='media-spoiler-video' style={{ height: '110px' }} />; |     return <div className='media-spoiler-video' style={{ height: '110px' }} />; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   handleOpenVideo = startTime => { | ||||||
|  |     this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     let media = null; |     let media = null; | ||||||
|     let statusAvatar; |     let statusAvatar; | ||||||
|  |  | ||||||
|     // 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, ...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> | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -213,9 +131,18 @@ export default class Status extends ImmutablePureComponent { | |||||||
|       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { |       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { | ||||||
|  |  | ||||||
|       } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { |       } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { | ||||||
|  |         const video = status.getIn(['media_attachments', 0]); | ||||||
|  |  | ||||||
|         media = ( |         media = ( | ||||||
|           <Bundle fetchComponent={VideoPlayer} loading={this.renderLoadingVideoPlayer} > |           <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > | ||||||
|             {Component => <Component media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />} |             {Component => <Component | ||||||
|  |               preview={video.get('preview_url')} | ||||||
|  |               src={video.get('url')} | ||||||
|  |               width={239} | ||||||
|  |               height={110} | ||||||
|  |               sensitive={status.get('sensitive')} | ||||||
|  |               onOpenVideo={this.handleOpenVideo} | ||||||
|  |             />} | ||||||
|           </Bundle> |           </Bundle> | ||||||
|         ); |         ); | ||||||
|       } else { |       } else { | ||||||
| @@ -228,13 +155,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='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 +179,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,8 +174,8 @@ 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')} 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')} 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} | ||||||
|  |  | ||||||
|         <div className='status__action-bar-dropdown'> |         <div className='status__action-bar-dropdown'> | ||||||
|   | |||||||
| @@ -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, | ||||||
| @@ -146,7 +144,7 @@ export default class StatusContent extends React.PureComponent { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       return ( |       return ( | ||||||
|         <div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> |         <div className={classNames} ref={this.setRef} tabIndex='0' aria-label={status.get('search_index')} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> | ||||||
|           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> |           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> | ||||||
|             <span dangerouslySetInnerHTML={spoilerContent} /> |             <span dangerouslySetInnerHTML={spoilerContent} /> | ||||||
|             {' '} |             {' '} | ||||||
| @@ -155,13 +153,15 @@ export default class StatusContent extends React.PureComponent { | |||||||
|  |  | ||||||
|           {mentionsPlaceholder} |           {mentionsPlaceholder} | ||||||
|  |  | ||||||
|           <div className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> |           <div tabIndex={!hidden && 0} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> | ||||||
|         </div> |         </div> | ||||||
|       ); |       ); | ||||||
|     } else if (this.props.onClick) { |     } else if (this.props.onClick) { | ||||||
|       return ( |       return ( | ||||||
|         <div |         <div | ||||||
|           ref={this.setRef} |           ref={this.setRef} | ||||||
|  |           tabIndex='0' | ||||||
|  |           aria-label={status.get('search_index')} | ||||||
|           className={classNames} |           className={classNames} | ||||||
|           style={directionStyle} |           style={directionStyle} | ||||||
|           onMouseDown={this.handleMouseDown} |           onMouseDown={this.handleMouseDown} | ||||||
| @@ -172,6 +172,8 @@ export default class StatusContent extends React.PureComponent { | |||||||
|     } else { |     } else { | ||||||
|       return ( |       return ( | ||||||
|         <div |         <div | ||||||
|  |           tabIndex='0' | ||||||
|  |           aria-label={status.get('search_index')} | ||||||
|           ref={this.setRef} |           ref={this.setRef} | ||||||
|           className='status__content' |           className='status__content' | ||||||
|           style={directionStyle} |           style={directionStyle} | ||||||
|   | |||||||
| @@ -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.nativeEvent.path[0].nodeName === 'ARTICLE' && e.nativeEvent.path[0].nextElementSibling; |  | ||||||
|         case 'PageUp': |  | ||||||
|           return e.nativeEvent.path[0].nodeName === 'ARTICLE' && e.nativeEvent.path[0].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) { |     return ( | ||||||
|       scrollableArea = ( |       <ScrollableList {...other}> | ||||||
|         <div className='scrollable' ref={this.setRef}> |         {scrollableContent} | ||||||
|           <div role='feed' className='status-list' onKeyDown={this.handleKeyDown}> |       </ScrollableList> | ||||||
|             {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 ( |  | ||||||
|         <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> |  | ||||||
|           {scrollableArea} |  | ||||||
|         </ScrollContainer> |  | ||||||
|       ); |  | ||||||
|     } 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> }} />, | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								app/javascript/mastodon/containers/card_container.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import Card from '../features/status/components/card'; | ||||||
|  | import { fromJS } from 'immutable'; | ||||||
|  |  | ||||||
|  | export default class CardContainer extends React.PureComponent { | ||||||
|  |  | ||||||
|  |   static propTypes = { | ||||||
|  |     locale: PropTypes.string, | ||||||
|  |     card: PropTypes.array.isRequired, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     const { card, ...props } = this.props; | ||||||
|  |     return <Card card={fromJS(card)} {...props} />; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -0,0 +1,17 @@ | |||||||
|  | import { connect } from 'react-redux'; | ||||||
|  | import IntersectionObserverArticle from '../components/intersection_observer_article'; | ||||||
|  | import { setHeight } from '../actions/height_cache'; | ||||||
|  |  | ||||||
|  | const makeMapStateToProps = (state, props) => ({ | ||||||
|  |   cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const mapDispatchToProps = (dispatch) => ({ | ||||||
|  |  | ||||||
|  |   onHeightChange (key, id, height) { | ||||||
|  |     dispatch(setHeight(key, id, height)); | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle); | ||||||
| @@ -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; |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,34 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import { IntlProvider, addLocaleData } from 'react-intl'; | ||||||
|  | import { getLocale } from '../locales'; | ||||||
|  | import MediaGallery from '../components/media_gallery'; | ||||||
|  | import { fromJS } from 'immutable'; | ||||||
|  |  | ||||||
|  | const { localeData, messages } = getLocale(); | ||||||
|  | addLocaleData(localeData); | ||||||
|  |  | ||||||
|  | export default class MediaGalleryContainer extends React.PureComponent { | ||||||
|  |  | ||||||
|  |   static propTypes = { | ||||||
|  |     locale: PropTypes.string.isRequired, | ||||||
|  |     media: PropTypes.array.isRequired, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   handleOpenMedia = () => {} | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     const { locale, media, ...props } = this.props; | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <IntlProvider locale={locale} messages={messages}> | ||||||
|  |         <MediaGallery | ||||||
|  |           {...props} | ||||||
|  |           media={fromJS(media)} | ||||||
|  |           onOpenMedia={this.handleOpenMedia} | ||||||
|  |         /> | ||||||
|  |       </IntlProvider> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -11,6 +11,8 @@ import { | |||||||
|   favourite, |   favourite, | ||||||
|   unreblog, |   unreblog, | ||||||
|   unfavourite, |   unfavourite, | ||||||
|  |   pin, | ||||||
|  |   unpin, | ||||||
| } from '../actions/interactions'; | } from '../actions/interactions'; | ||||||
| import { | import { | ||||||
|   blockAccount, |   blockAccount, | ||||||
| @@ -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'))); | ||||||
|   | |||||||
							
								
								
									
										26
									
								
								app/javascript/mastodon/containers/video_container.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,26 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import { IntlProvider, addLocaleData } from 'react-intl'; | ||||||
|  | import { getLocale } from '../locales'; | ||||||
|  | import Video from '../features/video'; | ||||||
|  |  | ||||||
|  | const { localeData, messages } = getLocale(); | ||||||
|  | addLocaleData(localeData); | ||||||
|  |  | ||||||
|  | export default class VideoContainer extends React.PureComponent { | ||||||
|  |  | ||||||
|  |   static propTypes = { | ||||||
|  |     locale: PropTypes.string.isRequired, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     const { locale, ...props } = this.props; | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <IntlProvider locale={locale} messages={messages}> | ||||||
|  |         <Video {...props} /> | ||||||
|  |       </IntlProvider> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -3,32 +3,28 @@ import Trie from 'substring-trie'; | |||||||
|  |  | ||||||
| const trie = new Trie(Object.keys(unicodeMapping)); | const trie = new Trie(Object.keys(unicodeMapping)); | ||||||
|  |  | ||||||
| function emojify(str) { | const emojify = str => { | ||||||
|   // This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.) |   let rtn = ''; | ||||||
|   // and replacing valid unicode strings |   for (;;) { | ||||||
|   // that _aren't_ within tags with an <img> version. |     let match, i = 0; | ||||||
|   // The goal is to be the same as an emojione.regUnicode replacement, but faster. |     while (i < str.length && str[i] !== '<' && !(match = trie.search(str.slice(i)))) { | ||||||
|   let i = -1; |       i += str.codePointAt(i) < 65536 ? 1 : 2; | ||||||
|   let insideTag = false; |     } | ||||||
|   let match; |     if (i === str.length) | ||||||
|   while (++i < str.length) { |       break; | ||||||
|     const char = str.charAt(i); |     else if (str[i] === '<') { | ||||||
|     if (insideTag && char === '>') { |       let tagend = str.indexOf('>', i + 1) + 1; | ||||||
|       insideTag = false; |       if (!tagend) | ||||||
|     } else if (char === '<') { |         break; | ||||||
|       insideTag = true; |       rtn += str.slice(0, tagend); | ||||||
|     } else if (!insideTag && (match = trie.search(str.substring(i)))) { |       str = str.slice(tagend); | ||||||
|       const unicodeStr = match; |     } else { | ||||||
|       if (unicodeStr in unicodeMapping) { |       const [filename, shortCode] = unicodeMapping[match]; | ||||||
|         const [filename, shortCode] = unicodeMapping[unicodeStr]; |       rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`; | ||||||
|         const alt      = unicodeStr; |       str = str.slice(i + match.length); | ||||||
|         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 str; |   return rtn + 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> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -13,12 +13,12 @@ export default class CharacterCounter extends React.PureComponent { | |||||||
|     if (diff < 0) { |     if (diff < 0) { | ||||||
|       return <span className='character-counter character-counter--over'>{diff}</span>; |       return <span className='character-counter character-counter--over'>{diff}</span>; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return <span className='character-counter'>{diff}</span>; |     return <span className='character-counter'>{diff}</span>; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const diff = this.props.max - length(this.props.text); |     const diff = this.props.max - length(this.props.text); | ||||||
|  |  | ||||||
|     return this.checkRemainingText(diff); |     return this.checkRemainingText(diff); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -18,6 +18,7 @@ import WarningContainer from '../containers/warning_container'; | |||||||
| import { isMobile } from '../../../is_mobile'; | import { isMobile } from '../../../is_mobile'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import { length } from 'stringz'; | import { length } from 'stringz'; | ||||||
|  | import { countableText } from '../util/counter'; | ||||||
|  |  | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, |   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, | ||||||
| @@ -145,9 +146,9 @@ export default class ComposeForm extends ImmutablePureComponent { | |||||||
|   render () { |   render () { | ||||||
|     const { intl, onPaste, showSearch } = this.props; |     const { intl, onPaste, showSearch } = this.props; | ||||||
|     const disabled = this.props.is_submitting; |     const disabled = this.props.is_submitting; | ||||||
|     const text = [this.props.spoiler_text, this.props.text].join(''); |     const text     = [this.props.spoiler_text, countableText(this.props.text)].join(''); | ||||||
|  |  | ||||||
|     let publishText    = ''; |     let publishText = ''; | ||||||
|  |  | ||||||
|     if (this.props.privacy === 'private' || this.props.privacy === 'direct') { |     if (this.props.privacy === 'private' || this.props.privacy === 'direct') { | ||||||
|       publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; |       publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; | ||||||
| @@ -203,7 +204,7 @@ export default class ComposeForm extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|           <div className='compose-form__publish'> |           <div className='compose-form__publish'> | ||||||
|             <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> |             <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> | ||||||
|             <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !==0 && text.trim().length === 0)} block /></div> |             <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0)} block /></div> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|   | |||||||
| @@ -124,7 +124,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { | |||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <Dropdown ref={this.setRef} className='emoji-picker__dropdown' active={active && !loading} onShow={this.onShowDropdown} onHide={this.onHideDropdown}> |       <Dropdown ref={this.setRef} className='emoji-picker__dropdown' active={active && !loading} onShow={this.onShowDropdown} onHide={this.onHideDropdown}> | ||||||
|         <DropdownTrigger className='emoji-button' title={title} aria-label={title} aria-pressed={active} role='button' onKeyDown={this.onToggle} tabIndex={0} > |         <DropdownTrigger className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onKeyDown={this.onToggle} tabIndex={0} > | ||||||
|           <img |           <img | ||||||
|             className={`emojione ${active && loading ? 'pulse-loading' : ''}`} |             className={`emojione ${active && loading ? 'pulse-loading' : ''}`} | ||||||
|             alt='🙂' |             alt='🙂' | ||||||
|   | |||||||
| @@ -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'> | ||||||
|   | |||||||
| @@ -109,7 +109,7 @@ export default class PrivacyDropdown extends React.PureComponent { | |||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> |       <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> | ||||||
|         <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} pressed={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> |         <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} expanded={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> | ||||||
|         <div className='privacy-dropdown__dropdown'> |         <div className='privacy-dropdown__dropdown'> | ||||||
|           {open && this.options.map(item => |           {open && this.options.map(item => | ||||||
|             <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> |             <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ const messages = defineMessages({ | |||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   visible: state.getIn(['compose', 'media_attachments']).size > 0, |   visible: state.getIn(['compose', 'media_attachments']).size > 0, | ||||||
|   active: state.getIn(['compose', 'sensitive']), |   active: state.getIn(['compose', 'sensitive']), | ||||||
|  |   disabled: state.getIn(['compose', 'spoiler']), | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const mapDispatchToProps = dispatch => ({ | const mapDispatchToProps = dispatch => ({ | ||||||
| @@ -30,12 +31,13 @@ class SensitiveButton extends React.PureComponent { | |||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     visible: PropTypes.bool, |     visible: PropTypes.bool, | ||||||
|     active: PropTypes.bool, |     active: PropTypes.bool, | ||||||
|  |     disabled: PropTypes.bool, | ||||||
|     onClick: PropTypes.func.isRequired, |     onClick: PropTypes.func.isRequired, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { visible, active, onClick, intl } = this.props; |     const { visible, active, disabled, onClick, intl } = this.props; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> |       <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> | ||||||
| @@ -53,6 +55,7 @@ class SensitiveButton extends React.PureComponent { | |||||||
|                 onClick={onClick} |                 onClick={onClick} | ||||||
|                 size={18} |                 size={18} | ||||||
|                 active={active} |                 active={active} | ||||||
|  |                 disabled={disabled} | ||||||
|                 style={{ lineHeight: null, height: null }} |                 style={{ lineHeight: null, height: null }} | ||||||
|                 inverted |                 inverted | ||||||
|               /> |               /> | ||||||
|   | |||||||
							
								
								
									
										9
									
								
								app/javascript/mastodon/features/compose/util/counter.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | |||||||
|  | import { urlRegex } from './url_regex'; | ||||||
|  |  | ||||||
|  | const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx'; | ||||||
|  |  | ||||||
|  | export function countableText(inputText) { | ||||||
|  |   return inputText | ||||||
|  |     .replace(urlRegex, urlPlaceholder) | ||||||
|  |     .replace(/(?:^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+)/ig, '@$2'); | ||||||
|  | }; | ||||||
							
								
								
									
										196
									
								
								app/javascript/mastodon/features/compose/util/url_regex.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,196 @@ | |||||||
|  | const regexen = {}; | ||||||
|  |  | ||||||
|  | const regexSupplant = function(regex, flags) { | ||||||
|  |   flags = flags || ''; | ||||||
|  |   if (typeof regex !== 'string') { | ||||||
|  |     if (regex.global && flags.indexOf('g') < 0) { | ||||||
|  |       flags += 'g'; | ||||||
|  |     } | ||||||
|  |     if (regex.ignoreCase && flags.indexOf('i') < 0) { | ||||||
|  |       flags += 'i'; | ||||||
|  |     } | ||||||
|  |     if (regex.multiline && flags.indexOf('m') < 0) { | ||||||
|  |       flags += 'm'; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     regex = regex.source; | ||||||
|  |   } | ||||||
|  |   return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) { | ||||||
|  |     var newRegex = regexen[name] || ''; | ||||||
|  |     if (typeof newRegex !== 'string') { | ||||||
|  |       newRegex = newRegex.source; | ||||||
|  |     } | ||||||
|  |     return newRegex; | ||||||
|  |   }), flags); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const stringSupplant = function(str, values) { | ||||||
|  |   return str.replace(/#\{(\w+)\}/g, function(match, name) { | ||||||
|  |     return values[name] || ''; | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const urlRegex = (function() { | ||||||
|  |   regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/; | ||||||
|  |   regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/; | ||||||
|  |   regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/; | ||||||
|  |   regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/); | ||||||
|  |   regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen); | ||||||
|  |   regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/); | ||||||
|  |   regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/); | ||||||
|  |   regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/); | ||||||
|  |   regexen.validGTLD = regexSupplant(RegExp( | ||||||
|  |   '(?:(?:' + | ||||||
|  |     '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' + | ||||||
|  |     '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' + | ||||||
|  |     'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' + | ||||||
|  |     'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' + | ||||||
|  |     'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' + | ||||||
|  |     'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' + | ||||||
|  |     'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' + | ||||||
|  |     'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' + | ||||||
|  |     'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' + | ||||||
|  |     'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' + | ||||||
|  |     'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' + | ||||||
|  |     'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' + | ||||||
|  |     'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' + | ||||||
|  |     'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' + | ||||||
|  |     'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' + | ||||||
|  |     'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' + | ||||||
|  |     'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' + | ||||||
|  |     'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' + | ||||||
|  |     'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' + | ||||||
|  |     'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' + | ||||||
|  |     'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' + | ||||||
|  |     'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' + | ||||||
|  |     'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' + | ||||||
|  |     'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' + | ||||||
|  |     'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' + | ||||||
|  |     'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' + | ||||||
|  |     'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' + | ||||||
|  |     'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' + | ||||||
|  |     'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' + | ||||||
|  |     'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' + | ||||||
|  |     'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' + | ||||||
|  |     'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' + | ||||||
|  |     'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' + | ||||||
|  |     'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' + | ||||||
|  |     'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' + | ||||||
|  |     'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' + | ||||||
|  |     'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' + | ||||||
|  |     'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' + | ||||||
|  |     'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' + | ||||||
|  |     'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' + | ||||||
|  |     'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' + | ||||||
|  |     'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' + | ||||||
|  |     'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' + | ||||||
|  |     'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' + | ||||||
|  |     'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' + | ||||||
|  |     'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' + | ||||||
|  |     'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' + | ||||||
|  |     'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' + | ||||||
|  |     'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' + | ||||||
|  |     'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' + | ||||||
|  |     'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' + | ||||||
|  |     'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' + | ||||||
|  |     'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' + | ||||||
|  |     'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' + | ||||||
|  |     'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' + | ||||||
|  |     'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' + | ||||||
|  |     'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' + | ||||||
|  |     'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' + | ||||||
|  |     'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' + | ||||||
|  |     'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' + | ||||||
|  |     'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' + | ||||||
|  |     'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' + | ||||||
|  |     'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' + | ||||||
|  |     'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' + | ||||||
|  |     'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' + | ||||||
|  |     'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' + | ||||||
|  |     'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' + | ||||||
|  |     'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' + | ||||||
|  |     'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' + | ||||||
|  |     'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' + | ||||||
|  |     'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' + | ||||||
|  |     'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' + | ||||||
|  |     'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' + | ||||||
|  |     'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' + | ||||||
|  |     'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' + | ||||||
|  |     'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' + | ||||||
|  |     'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' + | ||||||
|  |     'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' + | ||||||
|  |     'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' + | ||||||
|  |     'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' + | ||||||
|  |     'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' + | ||||||
|  |     'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' + | ||||||
|  |     'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' + | ||||||
|  |     'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' + | ||||||
|  |     'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' + | ||||||
|  |     'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' + | ||||||
|  |     'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' + | ||||||
|  |     'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' + | ||||||
|  |   ')(?=[^0-9a-zA-Z@]|$))')); | ||||||
|  |   regexen.validCCTLD = regexSupplant(RegExp( | ||||||
|  |   '(?:(?:' + | ||||||
|  |       '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' + | ||||||
|  |       'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' + | ||||||
|  |       'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' + | ||||||
|  |       'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' + | ||||||
|  |       'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' + | ||||||
|  |       're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' + | ||||||
|  |       'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' + | ||||||
|  |       'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' + | ||||||
|  |       'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' + | ||||||
|  |       'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' + | ||||||
|  |       'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' + | ||||||
|  |   ')(?=[^0-9a-zA-Z@]|$))')); | ||||||
|  |   regexen.validPunycode = /(?:xn--[0-9a-z]+)/; | ||||||
|  |   regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/; | ||||||
|  |   regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/); | ||||||
|  |   regexen.validPortNumber = /[0-9]+/; | ||||||
|  |   regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/; | ||||||
|  |   regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}\(\)\?]/i); | ||||||
|  |   // Allow URL paths to contain up to two nested levels of balanced parens | ||||||
|  |   //  1. Used in Wikipedia URLs like /Primer_(film) | ||||||
|  |   //  2. Used in IIS sessions like /S(dfd346)/ | ||||||
|  |   //  3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/ | ||||||
|  |   regexen.validUrlBalancedParens = regexSupplant( | ||||||
|  |     '\\('                                   + | ||||||
|  |       '(?:'                                 + | ||||||
|  |         '#{validGeneralUrlPathChars}+'      + | ||||||
|  |         '|'                                 + | ||||||
|  |         // allow one nested level of balanced parentheses | ||||||
|  |         '(?:'                               + | ||||||
|  |           '#{validGeneralUrlPathChars}*'    + | ||||||
|  |           '\\('                             + | ||||||
|  |             '#{validGeneralUrlPathChars}+'  + | ||||||
|  |           '\\)'                             + | ||||||
|  |           '#{validGeneralUrlPathChars}*'    + | ||||||
|  |         ')'                                 + | ||||||
|  |       ')'                                   + | ||||||
|  |     '\\)' | ||||||
|  |   , 'i'); | ||||||
|  |   // Valid end-of-path chracters (so /foo. does not gobble the period). | ||||||
|  |   // 1. Allow =&# for empty URL parameters and other URL-join artifacts | ||||||
|  |   regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}_~&\|@]|(?:#{validUrlBalancedParens})/i); | ||||||
|  |   // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/ | ||||||
|  |   regexen.validUrlPath = regexSupplant('(?:' + | ||||||
|  |     '(?:' + | ||||||
|  |       '#{validGeneralUrlPathChars}*' + | ||||||
|  |         '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' + | ||||||
|  |         '#{validUrlPathEndingChars}'+ | ||||||
|  |       ')|(?:@#{validGeneralUrlPathChars}+\/)'+ | ||||||
|  |     ')', 'i'); | ||||||
|  |   regexen.validUrlQueryChars = /[a-z0-9!?\*'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i; | ||||||
|  |   regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i; | ||||||
|  |   regexen.validUrl = regexSupplant( | ||||||
|  |     '('                                                          + // $1 URL | ||||||
|  |       '(https?:\\/\\/)'                                          + // $2 Protocol | ||||||
|  |       '(#{validDomain})'                                         + // $3 Domain(s) | ||||||
|  |       '(?::(#{validPortNumber}))?'                               + // $4 Port number (optional) | ||||||
|  |       '(\\/#{validUrlPath}*)?'                                   + // $5 URL Path | ||||||
|  |       '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $6 Query String | ||||||
|  |     ')' | ||||||
|  |   , 'gi'); | ||||||
|  |   return regexen.validUrl; | ||||||
|  | }()); | ||||||
| @@ -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> | ||||||
|   | |||||||