Compare commits
	
		
			433 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 637ea3bb5b | ||
|  | 363d0d3a44 | ||
|  | 6e54719474 | ||
|  | f3003417c5 | ||
|  | 33ea042dec | ||
|  | 8b22a63ab0 | ||
|  | 05686cc99d | ||
|  | 484208ce12 | ||
|  | 3bc8924940 | ||
|  | a02de9e012 | ||
|  | 2d395324e1 | ||
|  | e6c9756fa9 | ||
|  | 5050719fac | ||
|  | 989553c69a | ||
|  | 0e0c6b1b4b | ||
|  | 554c2fd8af | ||
|  | a2b600428c | ||
|  | df1a9c5ab5 | ||
|  | 8980aa804f | ||
|  | 34118169ac | ||
|  | 4fd7aebd5e | ||
|  | bc89995f65 | ||
|  | 7cc71748ce | ||
|  | aec70b44fc | ||
|  | 6f490b4bfe | ||
|  | 03975dbde4 | ||
|  | f72936b4e6 | ||
|  | 3c530d95f6 | ||
|  | 1e7b3bf625 | ||
|  | bf0ee1a25c | ||
|  | fa0be3f834 | ||
|  | 981e20b03a | ||
|  | d5b767c374 | ||
|  | 93b54b8d4b | ||
|  | e7ab9bf8b4 | ||
|  | 894da3dcca | ||
|  | 3e2f793948 | ||
|  | 8eb7d30a6c | ||
|  | 7fe1428cc4 | ||
|  | b3b4b5a2eb | ||
|  | 8125fdc19f | ||
|  | ae716a12e1 | ||
|  | f63a40e7c2 | ||
|  | 1bdd694a0a | ||
|  | 2eab41cd1a | ||
|  | c6f76db2e1 | ||
|  | 2c704ca9c6 | ||
|  | eb96aa86a4 | ||
|  | c1a2707ecf | ||
|  | c35132a738 | ||
|  | a1c54220e8 | ||
|  | df7dbc41ae | ||
|  | b8db386e05 | ||
|  | 48f7a58799 | ||
|  | 388d093beb | ||
|  | 95fe20b78a | ||
|  | 3283868e28 | ||
|  | dc91fd482a | ||
|  | b8bae96647 | ||
|  | d37305c628 | ||
|  | ad917cda10 | ||
|  | f398ad1994 | ||
|  | 3cfcc7a50e | ||
|  | cb7ba23cd8 | ||
|  | 691e9112f3 | ||
|  | 385df2c5a0 | ||
|  | 35ec1c91e3 | ||
|  | 9d84b6e606 | ||
|  | bf0f7e8846 | ||
|  | 4817744b87 | ||
|  | 8e9911962d | ||
|  | 3ebe03b729 | ||
|  | 6bc07d3de3 | ||
|  | fbc6375fde | ||
|  | 0352c40e99 | ||
|  | e5d8166a12 | ||
|  | 07ea625cb2 | ||
|  | 27101f1beb | ||
|  | 1823cf435a | ||
|  | 6a50329a9c | ||
|  | b17e571bc2 | ||
|  | 476e79b8e3 | ||
|  | 19d3317a69 | ||
|  | fe6941e28e | ||
|  | 38600b2792 | ||
|  | b0407ece42 | ||
|  | 9b3d8ee346 | ||
|  | b3d7ad958f | ||
|  | 552d22bec9 | ||
|  | 7c33da45f0 | ||
|  | 9815be2a44 | ||
|  | bebaa6eced | ||
|  | 616f53eea8 | ||
|  | 61d3ecc805 | ||
|  | 4bb3e4eeba | ||
|  | 784c7510d7 | ||
|  | 6c54d2e583 | ||
|  | 96c942e8ab | ||
|  | aafe55af81 | ||
|  | fd49d5603a | ||
|  | 1c6fc0e4ce | ||
|  | 92e7815d1d | ||
|  | 9d97054fe6 | ||
|  | cc796298c9 | ||
|  | 7fd66cf2fe | ||
|  | 6e9e0c14e6 | ||
|  | 0aa810f9c8 | ||
|  | 3888a12c79 | ||
|  | cfa68907ae | ||
|  | 488584bfc1 | ||
|  | 0717d9b3e6 | ||
|  | 6e4046fc3f | ||
|  | f0c939c431 | ||
|  | ebadfe0ab7 | ||
|  | 94f15338c3 | ||
|  | db33a53ee8 | ||
|  | 7de6d269d2 | ||
|  | 684001d729 | ||
|  | 292f3cd7e0 | ||
|  | a3d4f1bd93 | ||
|  | 633426b261 | ||
|  | f486ef2666 | ||
|  | d2dee6ea43 | ||
|  | 967e70663f | ||
|  | b7e65a004f | ||
|  | 3a3475450e | ||
|  | 057db0ecd0 | ||
|  | 11436358b4 | ||
|  | 0e0a9e716c | ||
|  | 45682f876d | ||
|  | 4413d81d7f | ||
|  | 5a2c7bd4ce | ||
|  | 5c8ca024ef | ||
|  | d8b2f89d33 | ||
|  | d5f490b1a2 | ||
|  | c75ca0525b | ||
|  | 6f2d88dd28 | ||
|  | daa59dd454 | ||
|  | 72d939b69f | ||
|  | 97b3d0cd56 | ||
|  | fd7f0732fe | ||
|  | eb5ac23434 | ||
|  | a5143df303 | ||
|  | 2aca22b8ea | ||
|  | 7db0f8dcb2 | ||
|  | 49cc0eb3e7 | ||
|  | b9c76e2edb | ||
|  | 2559d9166c | ||
|  | 32e8a87830 | ||
|  | 636acb5712 | ||
|  | b406e3cc4c | ||
|  | 43577e9f59 | ||
|  | ecfa1c3f3b | ||
|  | b3af3f9f8c | ||
|  | d5091387c6 | ||
|  | 178f718a9b | ||
|  | 0e1b0f2747 | ||
|  | 468523f4ad | ||
|  | 2076c557c9 | ||
|  | d40c9140e8 | ||
|  | 632178d754 | ||
|  | 291feba6f1 | ||
|  | 63f0979799 | ||
|  | ec13cfa4f9 | ||
|  | cdd5ef691b | ||
|  | c743b5e1fd | ||
|  | dfaa219f88 | ||
|  | e6543d5fc4 | ||
|  | 813c5f2f52 | ||
|  | 82d9ade7a6 | ||
|  | 875d943c18 | ||
|  | 334a446313 | ||
|  | ecacb15cd5 | ||
|  | eb6ec3d068 | ||
|  | f303a954e6 | ||
|  | 395a57d03d | ||
|  | 0f699a4280 | ||
|  | 5e5f36c216 | ||
|  | a767ef85fa | ||
|  | 0db47196fb | ||
|  | c30e492587 | ||
|  | 97c02c3389 | ||
|  | 4453c9a9f5 | ||
|  | b9c612b561 | ||
|  | d841af4e80 | ||
|  | 01d6aa0397 | ||
|  | c567c87453 | ||
|  | 47ecd652d3 | ||
|  | 04fa4eb7f9 | ||
|  | cdad7977fc | ||
|  | 0b3f1ec62a | ||
|  | b110cc542f | ||
|  | cdacac8c6c | ||
|  | eb605141ff | ||
|  | 1e1d788757 | ||
|  | 1df453aff6 | ||
|  | f7c909e290 | ||
|  | 7481ae1bcb | ||
|  | cb3b0c1a0f | ||
|  | ca0e8be20c | ||
|  | 83ffc4dc07 | ||
|  | d6fe0954e3 | ||
|  | ebb8c89207 | ||
|  | 0060f98847 | ||
|  | 1a72813b53 | ||
|  | c3f9c74719 | ||
|  | 35a8cafa35 | ||
|  | f4ca116ea8 | ||
|  | 5b45c1646a | ||
|  | 887cd94e96 | ||
|  | d2f56d1cbc | ||
|  | d0b4709b2a | ||
|  | 6e0659c838 | ||
|  | 3406e30526 | ||
|  | 76f360c625 | ||
|  | a3202f61af | ||
|  | 4ec1771165 | ||
|  | 3d9b8847d2 | ||
|  | b9f59ebcc6 | ||
|  | e648ef0bfb | ||
|  | db3ed498b0 | ||
|  | 901fc48aae | ||
|  | 3caf0ba923 | ||
|  | 6e83b5e719 | ||
|  | b32a1d5754 | ||
|  | 9d53a38a44 | ||
|  | e528114c53 | ||
|  | cf7fbf2c56 | ||
|  | 91e5b0dfdb | ||
|  | 3b60832214 | ||
|  | 259181c41a | ||
|  | 510df0ac55 | ||
|  | 98936bfcdf | ||
|  | 5c82c2b75f | ||
|  | 0fea700c7b | ||
|  | 2c8e3fbbfb | ||
|  | b982d549f4 | ||
|  | 909a6d4661 | ||
|  | 3f3de38075 | ||
|  | c267acfcf7 | ||
|  | ab625c57ce | ||
|  | e756c4f5ec | ||
|  | a0bbeafb04 | ||
|  | 2f079573ed | ||
|  | b2820c3913 | ||
|  | adc38078dd | ||
|  | dae0af1fd2 | ||
|  | 5b8d0ad71b | ||
|  | 233258c61b | ||
|  | 9c8aad612e | ||
|  | 6dfeb64326 | ||
|  | 427beb4177 | ||
|  | 838f255fc2 | ||
|  | 1e02ba111a | ||
|  | 66126f3021 | ||
|  | 293972f716 | ||
|  | 9c8e602163 | ||
|  | 846cd4e838 | ||
|  | 0de82dd316 | ||
|  | dcfc9b2204 | ||
|  | b01ab55ed8 | ||
|  | dd4ef69839 | ||
|  | d4f80824f7 | ||
|  | 034fab39ab | ||
|  | 0df6442636 | ||
|  | 245816ab27 | ||
|  | 63819c848d | ||
|  | a9abe89093 | ||
|  | 798b0fc5af | ||
|  | 8fcfcddc8f | ||
|  | d68df88d4e | ||
|  | c8580eb806 | ||
|  | f41e70ca38 | ||
|  | 7b8ecde32d | ||
|  | 8cb4561145 | ||
|  | 1607bb445a | ||
|  | 33c39784e4 | ||
|  | 669fe9ee06 | ||
|  | b35406b700 | ||
|  | 8e33fc29d7 | ||
|  | 1de6c52545 | ||
|  | 34fa305a00 | ||
|  | bb4d005a83 | ||
|  | df1ce2350c | ||
|  | e5c65b3067 | ||
|  | 7d16bb379d | ||
|  | 0401a24558 | ||
|  | 4aea3f88a6 | ||
|  | 41e6c8b151 | ||
|  | 813e650729 | ||
|  | 1664e52cbb | ||
|  | dce869dfc7 | ||
|  | 09a94b575e | ||
|  | d43944143a | ||
|  | 81cec35dbf | ||
|  | c155d843f4 | ||
|  | 3d640dc8ac | ||
|  | 6db034a866 | ||
|  | 17bf3363ac | ||
|  | dcf1139ebd | ||
|  | 9619b7f727 | ||
|  | 66be6475b6 | ||
|  | 9e0985d9e4 | ||
|  | cf14f4945a | ||
|  | 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 | 
| @@ -1,5 +1,6 @@ | |||||||
| # Service dependencies | # Service dependencies | ||||||
| # You may set REDIS_URL instead for more advanced options | # You may set REDIS_URL instead for more advanced options | ||||||
|  | # You may also set REDIS_NAMESPACE to share Redis between multiple Mastodon servers | ||||||
| REDIS_HOST=redis | REDIS_HOST=redis | ||||||
| REDIS_PORT=6379 | REDIS_PORT=6379 | ||||||
| # You may set DATABASE_URL instead for more advanced options | # You may set DATABASE_URL instead for more advanced options | ||||||
| @@ -26,7 +27,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 +37,7 @@ OTP_SECRET= | |||||||
| # You should only generate this once per instance. If you later decide to change it, all push subscription will | # You should only generate this once per instance. If you later decide to change it, all push subscription will | ||||||
| # be invalidated, requiring the users to access the website again to resubscribe. | # be invalidated, requiring the users to access the website again to resubscribe. | ||||||
| # | # | ||||||
| # Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) | # Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) | ||||||
| # | # | ||||||
| # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html | # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html | ||||||
| VAPID_PRIVATE_KEY= | VAPID_PRIVATE_KEY= | ||||||
| @@ -98,6 +99,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= | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,12 +5,14 @@ env: | |||||||
|   browser: true |   browser: true | ||||||
|   node: true |   node: true | ||||||
|   es6: true |   es6: true | ||||||
|  |   jest: true | ||||||
|  |  | ||||||
| parser: babel-eslint | parser: babel-eslint | ||||||
|  |  | ||||||
| plugins: | plugins: | ||||||
| - react | - react | ||||||
| - jsx-a11y | - jsx-a11y | ||||||
|  | - import | ||||||
|  |  | ||||||
| parserOptions: | parserOptions: | ||||||
|   sourceType: module |   sourceType: module | ||||||
| @@ -21,8 +23,14 @@ parserOptions: | |||||||
|     modules: true |     modules: true | ||||||
|     spread: true |     spread: true | ||||||
|  |  | ||||||
| rules: | settings: | ||||||
|  |   import/extensions: | ||||||
|  |   - .js | ||||||
|  |   import/ignore: | ||||||
|  |   - node_modules | ||||||
|  |   - \\.(css|scss|json)$ | ||||||
|  |  | ||||||
|  | rules: | ||||||
|   brace-style: warn |   brace-style: warn | ||||||
|   comma-dangle: |   comma-dangle: | ||||||
|   - error |   - error | ||||||
| @@ -125,3 +133,17 @@ rules: | |||||||
|   jsx-a11y/role-supports-aria-props: off |   jsx-a11y/role-supports-aria-props: off | ||||||
|   jsx-a11y/scope: warn |   jsx-a11y/scope: warn | ||||||
|   jsx-a11y/tabindex-no-positive: warn |   jsx-a11y/tabindex-no-positive: warn | ||||||
|  |  | ||||||
|  |   import/extensions: | ||||||
|  |   - error | ||||||
|  |   - always | ||||||
|  |   - js: never | ||||||
|  |   import/newline-after-import: error | ||||||
|  |   import/no-extraneous-dependencies: | ||||||
|  |   - error | ||||||
|  |   - devDependencies: | ||||||
|  |     - "config/webpack/**" | ||||||
|  |     - "app/javascript/mastodon/test_setup.js" | ||||||
|  |     - "app/javascript/**/__tests__/**" | ||||||
|  |   import/no-unresolved: error | ||||||
|  |   import/no-webpack-loader-syntax: error | ||||||
|   | |||||||
| @@ -1 +1 @@ | |||||||
| 2.4.1 | 2.4.2 | ||||||
|   | |||||||
| @@ -26,18 +26,16 @@ addons: | |||||||
|   postgresql: 9.4 |   postgresql: 9.4 | ||||||
|   apt: |   apt: | ||||||
|     sources: |     sources: | ||||||
|     - ubuntu-toolchain-r-test |  | ||||||
|     - trusty-media |     - trusty-media | ||||||
|     packages: |     packages: | ||||||
|     - ffmpeg |     - ffmpeg | ||||||
|     - g++-6 |  | ||||||
|     - libprotobuf-dev |     - libprotobuf-dev | ||||||
|     - protobuf-compiler |     - protobuf-compiler | ||||||
|     - libicu-dev |     - libicu-dev | ||||||
|  |  | ||||||
| rvm: | rvm: | ||||||
|   - 2.3.4 |   - 2.3.4 | ||||||
|   - 2.4.1 |   - 2.4.2 | ||||||
|  |  | ||||||
| services: | services: | ||||||
|   - redis-server |   - redis-server | ||||||
| @@ -55,5 +53,5 @@ before_script: | |||||||
|  |  | ||||||
| script: | script: | ||||||
|   - travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec |   - travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec | ||||||
|   - npm test |   - yarn test | ||||||
|   - bundle exec i18n-tasks unused |   - bundle exec i18n-tasks unused | ||||||
|   | |||||||
							
								
								
									
										46
									
								
								.yarnclean
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,46 @@ | |||||||
|  | # test directories | ||||||
|  | __tests__ | ||||||
|  | test | ||||||
|  | tests | ||||||
|  | powered-test | ||||||
|  |  | ||||||
|  | # asset directories | ||||||
|  | docs | ||||||
|  | doc | ||||||
|  | website | ||||||
|  | images | ||||||
|  | # assets | ||||||
|  |  | ||||||
|  | # examples | ||||||
|  | example | ||||||
|  | examples | ||||||
|  |  | ||||||
|  | # code coverage directories | ||||||
|  | coverage | ||||||
|  | .nyc_output | ||||||
|  |  | ||||||
|  | # build scripts | ||||||
|  | Makefile | ||||||
|  | Gulpfile.js | ||||||
|  | Gruntfile.js | ||||||
|  |  | ||||||
|  | # configs | ||||||
|  | .tern-project | ||||||
|  | .gitattributes | ||||||
|  | .editorconfig | ||||||
|  | .*ignore | ||||||
|  | .eslintrc | ||||||
|  | .jshintrc | ||||||
|  | .flowconfig | ||||||
|  | .documentup.json | ||||||
|  | .yarn-metadata.json | ||||||
|  | .*.yml | ||||||
|  | *.yml | ||||||
|  |  | ||||||
|  | # misc | ||||||
|  | *.gz | ||||||
|  | *.md | ||||||
|  |  | ||||||
|  | # for specific ignore | ||||||
|  | !.svgo.yml | ||||||
|  |  | ||||||
							
								
								
									
										1
									
								
								Aptfile
									
									
									
									
									
								
							
							
						
						| @@ -1,4 +1,5 @@ | |||||||
| ffmpeg | ffmpeg | ||||||
|  | libicu[0-9][0-9] | ||||||
| libicu-dev | libicu-dev | ||||||
| libidn11 | libidn11 | ||||||
| libidn11-dev | libidn11-dev | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								CODEOWNERS
									
									
									
									
									
								
							
							
						
						| @@ -8,8 +8,25 @@ | |||||||
| # /config/locales/*.fr.yml @żelipapą | # /config/locales/*.fr.yml @żelipapą | ||||||
| # /config/locales/fr.yml @żelipapą | # /config/locales/fr.yml @żelipapą | ||||||
|  |  | ||||||
|  | # Polish | ||||||
| /app/javascript/mastodon/locales/pl.json @m4sk1n | /app/javascript/mastodon/locales/pl.json @m4sk1n | ||||||
| /app/views/user_mailer/*.pl.html.erb @m4sk1n | /app/views/user_mailer/*.pl.html.erb @m4sk1n | ||||||
| /app/views/user_mailer/*.pl.text.erb @m4sk1n | /app/views/user_mailer/*.pl.text.erb @m4sk1n | ||||||
| /config/locales/*.pl.yml @m4sk1n | /config/locales/*.pl.yml @m4sk1n | ||||||
| /config/locales/pl.yml @m4sk1n | /config/locales/pl.yml @m4sk1n | ||||||
|  |  | ||||||
|  | # French | ||||||
|  | /app/javascript/mastodon/locales/fr.json @aldarone | ||||||
|  | /app/javascript/mastodon/locales/whitelist_fr.json @aldarone | ||||||
|  | /app/views/user_mailer/*.fr.html.erb @aldarone | ||||||
|  | /app/views/user_mailer/*.fr.text.erb @aldarone | ||||||
|  | /config/locales/*.fr.yml @aldarone | ||||||
|  | /config/locales/fr.yml @aldarone | ||||||
|  |  | ||||||
|  | # Dutch | ||||||
|  | /app/javascript/mastodon/locales/nl.json @jeroenpraat | ||||||
|  | /app/javascript/mastodon/locales/whitelist_nl.json @jeroenpraat | ||||||
|  | /app/views/user_mailer/*.nl.html.erb @jeroenpraat | ||||||
|  | /app/views/user_mailer/*.nl.text.erb @jeroenpraat | ||||||
|  | /config/locales/*.nl.yml @jeroenpraat | ||||||
|  | /config/locales/nl.yml @jeroenpraat | ||||||
|   | |||||||
							
								
								
									
										21
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						| @@ -1,4 +1,4 @@ | |||||||
| FROM ruby:2.4.1-alpine3.6 | FROM ruby:2.4.2-alpine3.6 | ||||||
|  |  | ||||||
| LABEL maintainer="https://github.com/tootsuite/mastodon" \ | LABEL maintainer="https://github.com/tootsuite/mastodon" \ | ||||||
|       description="A GNU Social-compatible microblogging server" |       description="A GNU Social-compatible microblogging server" | ||||||
| @@ -7,6 +7,8 @@ ENV UID=991 GID=991 \ | |||||||
|     RAILS_SERVE_STATIC_FILES=true \ |     RAILS_SERVE_STATIC_FILES=true \ | ||||||
|     RAILS_ENV=production NODE_ENV=production |     RAILS_ENV=production NODE_ENV=production | ||||||
|  |  | ||||||
|  | ARG YARN_VERSION=1.1.0 | ||||||
|  | ARG YARN_DOWNLOAD_SHA256=171c1f9ee93c488c0d774ac6e9c72649047c3f896277d88d0f805266519430f3 | ||||||
| ARG LIBICONV_VERSION=1.15 | ARG LIBICONV_VERSION=1.15 | ||||||
| ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 | ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 | ||||||
|  |  | ||||||
| @@ -19,6 +21,7 @@ RUN apk -U upgrade \ | |||||||
|     build-base \ |     build-base \ | ||||||
|     icu-dev \ |     icu-dev \ | ||||||
|     libidn-dev \ |     libidn-dev \ | ||||||
|  |     libressl \ | ||||||
|     libtool \ |     libtool \ | ||||||
|     postgresql-dev \ |     postgresql-dev \ | ||||||
|     protobuf-dev \ |     protobuf-dev \ | ||||||
| @@ -32,16 +35,21 @@ RUN apk -U upgrade \ | |||||||
|     imagemagick \ |     imagemagick \ | ||||||
|     libidn \ |     libidn \ | ||||||
|     libpq \ |     libpq \ | ||||||
|     nodejs-npm \ |  | ||||||
|     nodejs \ |     nodejs \ | ||||||
|  |     nodejs-npm \ | ||||||
|     protobuf \ |     protobuf \ | ||||||
|     su-exec \ |     su-exec \ | ||||||
|     tini \ |     tini \ | ||||||
|     yarn \ |  | ||||||
|  && update-ca-certificates \ |  && update-ca-certificates \ | ||||||
|  |  && mkdir -p /tmp/src /opt \ | ||||||
|  |  && wget -O yarn.tar.gz "https://github.com/yarnpkg/yarn/releases/download/v$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \ | ||||||
|  |  && echo "$YARN_DOWNLOAD_SHA256 *yarn.tar.gz" | sha256sum -c - \ | ||||||
|  |  && tar -xzf yarn.tar.gz -C /tmp/src \ | ||||||
|  |  && rm yarn.tar.gz \ | ||||||
|  |  && mv /tmp/src/yarn-v$YARN_VERSION /opt/yarn \ | ||||||
|  |  && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \ | ||||||
|  && wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \ |  && wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \ | ||||||
|  && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \ |  && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \ | ||||||
|  && mkdir -p /tmp/src \ |  | ||||||
|  && tar -xzf libiconv.tar.gz -C /tmp/src \ |  && tar -xzf libiconv.tar.gz -C /tmp/src \ | ||||||
|  && rm libiconv.tar.gz \ |  && rm libiconv.tar.gz \ | ||||||
|  && cd /tmp/src/libiconv-$LIBICONV_VERSION \ |  && cd /tmp/src/libiconv-$LIBICONV_VERSION \ | ||||||
| @@ -52,11 +60,12 @@ RUN apk -U upgrade \ | |||||||
|  && cd /mastodon \ |  && 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 .yarnclean /mastodon/ | ||||||
|  |  | ||||||
| RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \ | RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \ | ||||||
|  && bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \ |  && bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \ | ||||||
|  && yarn --ignore-optional --pure-lockfile |  && yarn --pure-lockfile \ | ||||||
|  |  && yarn cache clean | ||||||
|  |  | ||||||
| COPY . /mastodon | COPY . /mastodon | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								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' | ||||||
|  |  | ||||||
| @@ -23,7 +24,8 @@ gem 'addressable', '~> 2.5' | |||||||
| gem 'bootsnap' | gem 'bootsnap' | ||||||
| gem 'browser' | gem 'browser' | ||||||
| gem 'charlock_holmes', '~> 0.7.5' | 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' | ||||||
| @@ -40,6 +42,7 @@ gem 'kaminari', '~> 1.0' | |||||||
| gem 'link_header', '~> 0.0' | gem 'link_header', '~> 0.0' | ||||||
| gem 'mime-types', '~> 3.1' | gem 'mime-types', '~> 3.1' | ||||||
| gem 'nokogiri', '~> 1.7' | gem 'nokogiri', '~> 1.7' | ||||||
|  | gem 'nsa', '~> 0.2' | ||||||
| gem 'oj', '~> 3.0' | gem 'oj', '~> 3.0' | ||||||
| gem 'ostatus2', '~> 2.0' | gem 'ostatus2', '~> 2.0' | ||||||
| gem 'ox', '~> 2.5' | gem 'ox', '~> 2.5' | ||||||
| @@ -62,10 +65,10 @@ gem 'sidekiq-bulk', '~>0.1.1' | |||||||
| gem 'simple-navigation', '~> 4.0' | gem 'simple-navigation', '~> 4.0' | ||||||
| gem 'simple_form', '~> 3.4' | gem 'simple_form', '~> 3.4' | ||||||
| gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' | gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' | ||||||
| gem 'statsd-instrument', '~> 2.1' | gem 'strong_migrations' | ||||||
| gem 'twitter-text', '~> 1.14' | gem 'twitter-text', '~> 1.14' | ||||||
| gem 'tzinfo-data', '~> 1.2017' | gem 'tzinfo-data', '~> 1.2017' | ||||||
| gem 'webpacker', '~> 2.0' | gem 'webpacker', '~> 3.0' | ||||||
| gem 'webpush' | gem 'webpush' | ||||||
|  |  | ||||||
| gem 'json-ld-preloaded', '~> 2.2.1' | gem 'json-ld-preloaded', '~> 2.2.1' | ||||||
| @@ -100,8 +103,8 @@ group :development do | |||||||
|   gem 'letter_opener', '~> 1.4' |   gem 'letter_opener', '~> 1.4' | ||||||
|   gem 'letter_opener_web', '~> 1.3' |   gem 'letter_opener_web', '~> 1.3' | ||||||
|   gem 'rubocop', require: false |   gem 'rubocop', require: false | ||||||
|   gem 'brakeman', '~> 3.6', require: false |   gem 'brakeman', '~> 4.0', require: false | ||||||
|   gem 'bundler-audit', '~> 0.5', require: false |   gem 'bundler-audit', '~> 0.6', require: false | ||||||
|   gem 'scss_lint', '~> 0.53', require: false |   gem 'scss_lint', '~> 0.53', require: false | ||||||
|  |  | ||||||
|   gem 'capistrano', '~> 3.8' |   gem 'capistrano', '~> 3.8' | ||||||
|   | |||||||
							
								
								
									
										217
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						| @@ -1,25 +1,25 @@ | |||||||
| GEM | GEM | ||||||
|   remote: https://rubygems.org/ |   remote: https://rubygems.org/ | ||||||
|   specs: |   specs: | ||||||
|     actioncable (5.1.3) |     actioncable (5.1.4) | ||||||
|       actionpack (= 5.1.3) |       actionpack (= 5.1.4) | ||||||
|       nio4r (~> 2.0) |       nio4r (~> 2.0) | ||||||
|       websocket-driver (~> 0.6.1) |       websocket-driver (~> 0.6.1) | ||||||
|     actionmailer (5.1.3) |     actionmailer (5.1.4) | ||||||
|       actionpack (= 5.1.3) |       actionpack (= 5.1.4) | ||||||
|       actionview (= 5.1.3) |       actionview (= 5.1.4) | ||||||
|       activejob (= 5.1.3) |       activejob (= 5.1.4) | ||||||
|       mail (~> 2.5, >= 2.5.4) |       mail (~> 2.5, >= 2.5.4) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
|     actionpack (5.1.3) |     actionpack (5.1.4) | ||||||
|       actionview (= 5.1.3) |       actionview (= 5.1.4) | ||||||
|       activesupport (= 5.1.3) |       activesupport (= 5.1.4) | ||||||
|       rack (~> 2.0) |       rack (~> 2.0) | ||||||
|       rack-test (~> 0.6.3) |       rack-test (>= 0.6.3) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
|       rails-html-sanitizer (~> 1.0, >= 1.0.2) |       rails-html-sanitizer (~> 1.0, >= 1.0.2) | ||||||
|     actionview (5.1.3) |     actionview (5.1.4) | ||||||
|       activesupport (= 5.1.3) |       activesupport (= 5.1.4) | ||||||
|       builder (~> 3.1) |       builder (~> 3.1) | ||||||
|       erubi (~> 1.4) |       erubi (~> 1.4) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
| @@ -30,16 +30,16 @@ GEM | |||||||
|       case_transform (>= 0.2) |       case_transform (>= 0.2) | ||||||
|       jsonapi-renderer (>= 0.1.1.beta1, < 0.2) |       jsonapi-renderer (>= 0.1.1.beta1, < 0.2) | ||||||
|     active_record_query_trace (1.5.4) |     active_record_query_trace (1.5.4) | ||||||
|     activejob (5.1.3) |     activejob (5.1.4) | ||||||
|       activesupport (= 5.1.3) |       activesupport (= 5.1.4) | ||||||
|       globalid (>= 0.3.6) |       globalid (>= 0.3.6) | ||||||
|     activemodel (5.1.3) |     activemodel (5.1.4) | ||||||
|       activesupport (= 5.1.3) |       activesupport (= 5.1.4) | ||||||
|     activerecord (5.1.3) |     activerecord (5.1.4) | ||||||
|       activemodel (= 5.1.3) |       activemodel (= 5.1.4) | ||||||
|       activesupport (= 5.1.3) |       activesupport (= 5.1.4) | ||||||
|       arel (~> 8.0) |       arel (~> 8.0) | ||||||
|     activesupport (5.1.3) |     activesupport (5.1.4) | ||||||
|       concurrent-ruby (~> 1.0, >= 1.0.2) |       concurrent-ruby (~> 1.0, >= 1.0.2) | ||||||
|       i18n (~> 0.7) |       i18n (~> 0.7) | ||||||
|       minitest (~> 5.1) |       minitest (~> 5.1) | ||||||
| @@ -57,33 +57,33 @@ GEM | |||||||
|       encryptor (~> 3.0.0) |       encryptor (~> 3.0.0) | ||||||
|     av (0.9.0) |     av (0.9.0) | ||||||
|       cocaine (~> 0.5.3) |       cocaine (~> 0.5.3) | ||||||
|     aws-sdk (2.10.21) |     aws-sdk (2.10.46) | ||||||
|       aws-sdk-resources (= 2.10.21) |       aws-sdk-resources (= 2.10.46) | ||||||
|     aws-sdk-core (2.10.21) |     aws-sdk-core (2.10.46) | ||||||
|       aws-sigv4 (~> 1.0) |       aws-sigv4 (~> 1.0) | ||||||
|       jmespath (~> 1.0) |       jmespath (~> 1.0) | ||||||
|     aws-sdk-resources (2.10.21) |     aws-sdk-resources (2.10.46) | ||||||
|       aws-sdk-core (= 2.10.21) |       aws-sdk-core (= 2.10.46) | ||||||
|     aws-sigv4 (1.0.1) |     aws-sigv4 (1.0.2) | ||||||
|     bcrypt (3.1.11) |     bcrypt (3.1.11) | ||||||
|     better_errors (2.1.1) |     better_errors (2.3.0) | ||||||
|       coderay (>= 1.0.0) |       coderay (>= 1.0.0) | ||||||
|       erubis (>= 2.6.6) |       erubi (>= 1.0.0) | ||||||
|       rack (>= 0.9.0) |       rack (>= 0.9.0) | ||||||
|     binding_of_caller (0.7.2) |     binding_of_caller (0.7.2) | ||||||
|       debug_inspector (>= 0.0.1) |       debug_inspector (>= 0.0.1) | ||||||
|     bootsnap (1.1.2) |     bootsnap (1.1.3) | ||||||
|       msgpack (~> 1.0) |       msgpack (~> 1.0) | ||||||
|     brakeman (3.7.2) |     brakeman (4.0.1) | ||||||
|     browser (2.4.0) |     browser (2.5.1) | ||||||
|     builder (3.2.3) |     builder (3.2.3) | ||||||
|     bullet (5.5.1) |     bullet (5.6.1) | ||||||
|       activesupport (>= 3.0.0) |       activesupport (>= 3.0.0) | ||||||
|       uniform_notifier (~> 1.10.0) |       uniform_notifier (~> 1.10.0) | ||||||
|     bundler-audit (0.6.0) |     bundler-audit (0.6.0) | ||||||
|       bundler (~> 1.2) |       bundler (~> 1.2) | ||||||
|       thor (~> 0.18) |       thor (~> 0.18) | ||||||
|     capistrano (3.8.2) |     capistrano (3.9.1) | ||||||
|       airbrussh (>= 1.0.0) |       airbrussh (>= 1.0.0) | ||||||
|       i18n |       i18n | ||||||
|       rake (>= 10.0.0) |       rake (>= 10.0.0) | ||||||
| @@ -99,9 +99,9 @@ GEM | |||||||
|       sshkit (~> 1.3) |       sshkit (~> 1.3) | ||||||
|     capistrano-yarn (2.0.2) |     capistrano-yarn (2.0.2) | ||||||
|       capistrano (~> 3.0) |       capistrano (~> 3.0) | ||||||
|     capybara (2.14.4) |     capybara (2.15.1) | ||||||
|       addressable |       addressable | ||||||
|       mime-types (>= 1.16) |       mini_mime (>= 0.1.3) | ||||||
|       nokogiri (>= 1.3.3) |       nokogiri (>= 1.3.3) | ||||||
|       rack (>= 1.0.0) |       rack (>= 1.0.0) | ||||||
|       rack-test (>= 0.5.4) |       rack-test (>= 0.5.4) | ||||||
| @@ -110,12 +110,12 @@ GEM | |||||||
|       activesupport |       activesupport | ||||||
|     charlock_holmes (0.7.5) |     charlock_holmes (0.7.5) | ||||||
|     chunky_png (1.3.8) |     chunky_png (1.3.8) | ||||||
|     cld3 (3.1.3) |     cld3 (3.2.0) | ||||||
|       ffi (>= 1.1.0, < 1.10.0) |       ffi (>= 1.1.0, < 1.10.0) | ||||||
|     climate_control (0.2.0) |     climate_control (0.2.0) | ||||||
|     cocaine (0.5.8) |     cocaine (0.5.8) | ||||||
|       climate_control (>= 0.0.3, < 1.0) |       climate_control (>= 0.0.3, < 1.0) | ||||||
|     coderay (1.1.1) |     coderay (1.1.2) | ||||||
|     colorize (0.8.1) |     colorize (0.8.1) | ||||||
|     concurrent-ruby (1.0.5) |     concurrent-ruby (1.0.5) | ||||||
|     connection_pool (2.2.1) |     connection_pool (2.2.1) | ||||||
| @@ -151,15 +151,27 @@ GEM | |||||||
|       thread_safe |       thread_safe | ||||||
|     encryptor (3.0.0) |     encryptor (3.0.0) | ||||||
|     erubi (1.6.1) |     erubi (1.6.1) | ||||||
|     erubis (2.7.0) |  | ||||||
|     et-orbi (1.0.5) |     et-orbi (1.0.5) | ||||||
|       tzinfo |       tzinfo | ||||||
|  |     excon (0.59.0) | ||||||
|     execjs (2.7.0) |     execjs (2.7.0) | ||||||
|     fabrication (2.16.2) |     fabrication (2.16.3) | ||||||
|     faker (1.7.3) |     faker (1.8.4) | ||||||
|       i18n (~> 0.5) |       i18n (~> 0.5) | ||||||
|     fast_blank (1.0.0) |     fast_blank (1.0.0) | ||||||
|     ffi (1.9.18) |     ffi (1.9.18) | ||||||
|  |     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) | ||||||
| @@ -181,7 +193,7 @@ GEM | |||||||
|       railties (>= 4.0.1) |       railties (>= 4.0.1) | ||||||
|     hamster (3.0.0) |     hamster (3.0.0) | ||||||
|       concurrent-ruby (~> 1.0) |       concurrent-ruby (~> 1.0) | ||||||
|     hashdiff (0.3.5) |     hashdiff (0.3.7) | ||||||
|     highline (1.7.8) |     highline (1.7.8) | ||||||
|     hiredis (0.6.1) |     hiredis (0.6.1) | ||||||
|     hkdf (0.3.0) |     hkdf (0.3.0) | ||||||
| @@ -200,23 +212,25 @@ GEM | |||||||
|       colorize |       colorize | ||||||
|       rack |       rack | ||||||
|     i18n (0.8.6) |     i18n (0.8.6) | ||||||
|     i18n-tasks (0.9.16) |     i18n-tasks (0.9.18) | ||||||
|       activesupport (>= 4.0.2) |       activesupport (>= 4.0.2) | ||||||
|       ast (>= 2.1.0) |       ast (>= 2.1.0) | ||||||
|       easy_translate (>= 0.5.0) |       easy_translate (>= 0.5.0) | ||||||
|       erubis |       erubi | ||||||
|       highline (>= 1.7.3) |       highline (>= 1.7.3) | ||||||
|       i18n |       i18n | ||||||
|       parser (>= 2.2.3.0) |       parser (>= 2.2.3.0) | ||||||
|       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) | ||||||
|     json-ld (2.1.5) |     json-ld (2.1.5) | ||||||
|       multi_json (~> 1.12) |       multi_json (~> 1.12) | ||||||
|       rdf (~> 2.2) |       rdf (~> 2.2) | ||||||
|     json-ld-preloaded (2.2.1) |     json-ld-preloaded (2.2.2) | ||||||
|       json-ld (~> 2.1, >= 2.1.5) |       json-ld (~> 2.1, >= 2.1.5) | ||||||
|       multi_json (~> 1.11) |       multi_json (~> 1.11) | ||||||
|       rdf (~> 2.2) |       rdf (~> 2.2) | ||||||
| @@ -243,10 +257,11 @@ GEM | |||||||
|       letter_opener (~> 1.0) |       letter_opener (~> 1.0) | ||||||
|       railties (>= 3.2) |       railties (>= 3.2) | ||||||
|     link_header (0.0.8) |     link_header (0.0.8) | ||||||
|     lograge (0.5.1) |     lograge (0.6.0) | ||||||
|       actionpack (>= 4, < 5.2) |       actionpack (>= 4, < 5.2) | ||||||
|       activesupport (>= 4, < 5.2) |       activesupport (>= 4, < 5.2) | ||||||
|       railties (>= 4, < 5.2) |       railties (>= 4, < 5.2) | ||||||
|  |       request_store (~> 1.0) | ||||||
|     loofah (2.0.3) |     loofah (2.0.3) | ||||||
|       nokogiri (>= 1.5.9) |       nokogiri (>= 1.5.9) | ||||||
|     mail (2.6.6) |     mail (2.6.6) | ||||||
| @@ -261,27 +276,33 @@ GEM | |||||||
|       mime-types-data (~> 3.2015) |       mime-types-data (~> 3.2015) | ||||||
|     mime-types-data (3.2016.0521) |     mime-types-data (3.2016.0521) | ||||||
|     mimemagic (0.3.2) |     mimemagic (0.3.2) | ||||||
|  |     mini_mime (0.1.4) | ||||||
|     mini_portile2 (2.2.0) |     mini_portile2 (2.2.0) | ||||||
|     minitest (5.10.3) |     minitest (5.10.3) | ||||||
|     msgpack (1.1.0) |     msgpack (1.1.0) | ||||||
|     multi_json (1.12.1) |     multi_json (1.12.2) | ||||||
|     net-scp (1.2.1) |     net-scp (1.2.1) | ||||||
|       net-ssh (>= 2.6.5) |       net-ssh (>= 2.6.5) | ||||||
|     net-ssh (4.1.0) |     net-ssh (4.2.0) | ||||||
|     nio4r (2.1.0) |     nio4r (2.1.0) | ||||||
|     nokogiri (1.8.0) |     nokogiri (1.8.0) | ||||||
|       mini_portile2 (~> 2.2.0) |       mini_portile2 (~> 2.2.0) | ||||||
|     nokogumbo (1.4.13) |     nokogumbo (1.4.13) | ||||||
|       nokogiri |       nokogiri | ||||||
|     oj (3.3.4) |     nsa (0.2.4) | ||||||
|     openssl (2.0.4) |       activesupport (>= 4.2, < 6) | ||||||
|  |       concurrent-ruby (~> 1.0.0) | ||||||
|  |       sidekiq (>= 3.5.0) | ||||||
|  |       statsd-ruby (~> 1.2.0) | ||||||
|  |     oj (3.3.5) | ||||||
|  |     openssl (2.0.5) | ||||||
|     orm_adapter (0.5.0) |     orm_adapter (0.5.0) | ||||||
|     ostatus2 (2.0.1) |     ostatus2 (2.0.1) | ||||||
|       addressable (~> 2.4) |       addressable (~> 2.4) | ||||||
|       http (~> 2.0) |       http (~> 2.0) | ||||||
|       nokogiri (~> 1.6) |       nokogiri (~> 1.6) | ||||||
|       openssl (~> 2.0) |       openssl (~> 2.0) | ||||||
|     ox (2.5.0) |     ox (2.6.0) | ||||||
|     paperclip (5.1.0) |     paperclip (5.1.0) | ||||||
|       activemodel (>= 4.2.0) |       activemodel (>= 4.2.0) | ||||||
|       activesupport (>= 4.2.0) |       activesupport (>= 4.2.0) | ||||||
| @@ -291,15 +312,15 @@ GEM | |||||||
|     paperclip-av-transcoder (0.6.4) |     paperclip-av-transcoder (0.6.4) | ||||||
|       av (~> 0.9.0) |       av (~> 0.9.0) | ||||||
|       paperclip (>= 2.5.2) |       paperclip (>= 2.5.2) | ||||||
|     parallel (1.11.2) |     parallel (1.12.0) | ||||||
|     parallel_tests (2.14.2) |     parallel_tests (2.15.0) | ||||||
|       parallel |       parallel | ||||||
|     parser (2.4.0.0) |     parser (2.4.0.0) | ||||||
|       ast (~> 2.2) |       ast (~> 2.2) | ||||||
|     pg (0.21.0) |     pg (0.21.0) | ||||||
|     pghero (1.7.0) |     pghero (1.7.0) | ||||||
|       activerecord |       activerecord | ||||||
|     pkg-config (1.2.4) |     pkg-config (1.2.7) | ||||||
|     powerpack (0.1.1) |     powerpack (0.1.1) | ||||||
|     pry (0.10.4) |     pry (0.10.4) | ||||||
|       coderay (~> 1.1.0) |       coderay (~> 1.1.0) | ||||||
| @@ -308,7 +329,7 @@ GEM | |||||||
|     pry-rails (0.3.6) |     pry-rails (0.3.6) | ||||||
|       pry (>= 0.10.4) |       pry (>= 0.10.4) | ||||||
|     public_suffix (3.0.0) |     public_suffix (3.0.0) | ||||||
|     puma (3.9.1) |     puma (3.10.0) | ||||||
|     pundit (1.1.0) |     pundit (1.1.0) | ||||||
|       activesupport (>= 3.0.0) |       activesupport (>= 3.0.0) | ||||||
|     rabl (0.13.1) |     rabl (0.13.1) | ||||||
| @@ -319,20 +340,22 @@ GEM | |||||||
|     rack-cors (0.4.1) |     rack-cors (0.4.1) | ||||||
|     rack-protection (2.0.0) |     rack-protection (2.0.0) | ||||||
|       rack |       rack | ||||||
|     rack-test (0.6.3) |     rack-proxy (0.6.2) | ||||||
|       rack (>= 1.0) |       rack | ||||||
|  |     rack-test (0.7.0) | ||||||
|  |       rack (>= 1.0, < 3) | ||||||
|     rack-timeout (0.4.2) |     rack-timeout (0.4.2) | ||||||
|     rails (5.1.3) |     rails (5.1.4) | ||||||
|       actioncable (= 5.1.3) |       actioncable (= 5.1.4) | ||||||
|       actionmailer (= 5.1.3) |       actionmailer (= 5.1.4) | ||||||
|       actionpack (= 5.1.3) |       actionpack (= 5.1.4) | ||||||
|       actionview (= 5.1.3) |       actionview (= 5.1.4) | ||||||
|       activejob (= 5.1.3) |       activejob (= 5.1.4) | ||||||
|       activemodel (= 5.1.3) |       activemodel (= 5.1.4) | ||||||
|       activerecord (= 5.1.3) |       activerecord (= 5.1.4) | ||||||
|       activesupport (= 5.1.3) |       activesupport (= 5.1.4) | ||||||
|       bundler (>= 1.3.0) |       bundler (>= 1.3.0) | ||||||
|       railties (= 5.1.3) |       railties (= 5.1.4) | ||||||
|       sprockets-rails (>= 2.0.0) |       sprockets-rails (>= 2.0.0) | ||||||
|     rails-controller-testing (1.0.2) |     rails-controller-testing (1.0.2) | ||||||
|       actionpack (~> 5.x, >= 5.0.1) |       actionpack (~> 5.x, >= 5.0.1) | ||||||
| @@ -348,16 +371,16 @@ GEM | |||||||
|       railties (~> 5.0) |       railties (~> 5.0) | ||||||
|     rails-settings-cached (0.6.6) |     rails-settings-cached (0.6.6) | ||||||
|       rails (>= 4.2.0) |       rails (>= 4.2.0) | ||||||
|     railties (5.1.3) |     railties (5.1.4) | ||||||
|       actionpack (= 5.1.3) |       actionpack (= 5.1.4) | ||||||
|       activesupport (= 5.1.3) |       activesupport (= 5.1.4) | ||||||
|       method_source |       method_source | ||||||
|       rake (>= 0.8.7) |       rake (>= 0.8.7) | ||||||
|       thor (>= 0.18.1, < 2.0) |       thor (>= 0.18.1, < 2.0) | ||||||
|     rainbow (2.2.2) |     rainbow (2.2.2) | ||||||
|       rake |       rake | ||||||
|     rake (12.0.0) |     rake (12.1.0) | ||||||
|     rdf (2.2.8) |     rdf (2.2.9) | ||||||
|       hamster (~> 3.0) |       hamster (~> 3.0) | ||||||
|       link_header (~> 0.0, >= 0.0.8) |       link_header (~> 0.0, >= 0.0.8) | ||||||
|     rdf-normalize (0.3.2) |     rdf-normalize (0.3.2) | ||||||
| @@ -381,6 +404,7 @@ GEM | |||||||
|       redis-store (>= 1.2, < 2) |       redis-store (>= 1.2, < 2) | ||||||
|     redis-store (1.3.0) |     redis-store (1.3.0) | ||||||
|       redis (>= 2.2) |       redis (>= 2.2) | ||||||
|  |     request_store (1.3.2) | ||||||
|     responders (2.4.0) |     responders (2.4.0) | ||||||
|       actionpack (>= 4.2.0, < 5.3) |       actionpack (>= 4.2.0, < 5.3) | ||||||
|       railties (>= 4.2.0, < 5.3) |       railties (>= 4.2.0, < 5.3) | ||||||
| @@ -395,7 +419,7 @@ GEM | |||||||
|     rspec-mocks (3.6.0) |     rspec-mocks (3.6.0) | ||||||
|       diff-lcs (>= 1.2.0, < 2.0) |       diff-lcs (>= 1.2.0, < 2.0) | ||||||
|       rspec-support (~> 3.6.0) |       rspec-support (~> 3.6.0) | ||||||
|     rspec-rails (3.6.0) |     rspec-rails (3.6.1) | ||||||
|       actionpack (>= 3.0) |       actionpack (>= 3.0) | ||||||
|       activesupport (>= 3.0) |       activesupport (>= 3.0) | ||||||
|       railties (>= 3.0) |       railties (>= 3.0) | ||||||
| @@ -407,15 +431,15 @@ GEM | |||||||
|       rspec-core (~> 3.0, >= 3.0.0) |       rspec-core (~> 3.0, >= 3.0.0) | ||||||
|       sidekiq (>= 2.4.0) |       sidekiq (>= 2.4.0) | ||||||
|     rspec-support (3.6.0) |     rspec-support (3.6.0) | ||||||
|     rubocop (0.49.1) |     rubocop (0.50.0) | ||||||
|       parallel (~> 1.10) |       parallel (~> 1.10) | ||||||
|       parser (>= 2.3.3.1, < 3.0) |       parser (>= 2.3.3.1, < 3.0) | ||||||
|       powerpack (~> 0.1) |       powerpack (~> 0.1) | ||||||
|       rainbow (>= 1.99.1, < 3.0) |       rainbow (>= 2.2.2, < 3.0) | ||||||
|       ruby-progressbar (~> 1.7) |       ruby-progressbar (~> 1.7) | ||||||
|       unicode-display_width (~> 1.0, >= 1.0.1) |       unicode-display_width (~> 1.0, >= 1.0.1) | ||||||
|     ruby-oembed (0.12.0) |     ruby-oembed (0.12.0) | ||||||
|     ruby-progressbar (1.8.1) |     ruby-progressbar (1.8.3) | ||||||
|     rufus-scheduler (3.4.2) |     rufus-scheduler (3.4.2) | ||||||
|       et-orbi (~> 1.0) |       et-orbi (~> 1.0) | ||||||
|     safe_yaml (1.0.4) |     safe_yaml (1.0.4) | ||||||
| @@ -423,7 +447,7 @@ GEM | |||||||
|       crass (~> 1.0.2) |       crass (~> 1.0.2) | ||||||
|       nokogiri (>= 1.4.4) |       nokogiri (>= 1.4.4) | ||||||
|       nokogumbo (~> 1.4.1) |       nokogumbo (~> 1.4.1) | ||||||
|     sass (3.4.24) |     sass (3.4.25) | ||||||
|     scss_lint (0.54.0) |     scss_lint (0.54.0) | ||||||
|       rake (>= 0.9, < 13) |       rake (>= 0.9, < 13) | ||||||
|       sass (~> 3.4.20) |       sass (~> 3.4.20) | ||||||
| @@ -435,12 +459,12 @@ GEM | |||||||
|     sidekiq-bulk (0.1.1) |     sidekiq-bulk (0.1.1) | ||||||
|       activesupport |       activesupport | ||||||
|       sidekiq |       sidekiq | ||||||
|     sidekiq-scheduler (2.1.8) |     sidekiq-scheduler (2.1.9) | ||||||
|       redis (~> 3) |       redis (~> 3) | ||||||
|       rufus-scheduler (~> 3.2) |       rufus-scheduler (~> 3.2) | ||||||
|       sidekiq (>= 3) |       sidekiq (>= 3) | ||||||
|       tilt (>= 1.4.0) |       tilt (>= 1.4.0) | ||||||
|     sidekiq-unique-jobs (5.0.9) |     sidekiq-unique-jobs (5.0.10) | ||||||
|       sidekiq (>= 4.0, <= 6.0) |       sidekiq (>= 4.0, <= 6.0) | ||||||
|       thor (~> 0) |       thor (~> 0) | ||||||
|     simple-navigation (4.0.5) |     simple-navigation (4.0.5) | ||||||
| @@ -448,23 +472,25 @@ GEM | |||||||
|     simple_form (3.5.0) |     simple_form (3.5.0) | ||||||
|       actionpack (> 4, < 5.2) |       actionpack (> 4, < 5.2) | ||||||
|       activemodel (> 4, < 5.2) |       activemodel (> 4, < 5.2) | ||||||
|     simplecov (0.14.1) |     simplecov (0.15.1) | ||||||
|       docile (~> 1.1.0) |       docile (~> 1.1.0) | ||||||
|       json (>= 1.8, < 3) |       json (>= 1.8, < 3) | ||||||
|       simplecov-html (~> 0.10.0) |       simplecov-html (~> 0.10.0) | ||||||
|     simplecov-html (0.10.1) |     simplecov-html (0.10.2) | ||||||
|     slop (3.6.0) |     slop (3.6.0) | ||||||
|     sprockets (3.7.1) |     sprockets (3.7.1) | ||||||
|       concurrent-ruby (~> 1.0) |       concurrent-ruby (~> 1.0) | ||||||
|       rack (> 1, < 3) |       rack (> 1, < 3) | ||||||
|     sprockets-rails (3.2.0) |     sprockets-rails (3.2.1) | ||||||
|       actionpack (>= 4.0) |       actionpack (>= 4.0) | ||||||
|       activesupport (>= 4.0) |       activesupport (>= 4.0) | ||||||
|       sprockets (>= 3.0.0) |       sprockets (>= 3.0.0) | ||||||
|     sshkit (1.13.1) |     sshkit (1.14.0) | ||||||
|       net-scp (>= 1.1.2) |       net-scp (>= 1.1.2) | ||||||
|       net-ssh (>= 2.8.0) |       net-ssh (>= 2.8.0) | ||||||
|     statsd-instrument (2.1.4) |     statsd-ruby (1.2.1) | ||||||
|  |     strong_migrations (0.1.9) | ||||||
|  |       activerecord (>= 3.2.0) | ||||||
|     temple (0.8.0) |     temple (0.8.0) | ||||||
|     terminal-table (1.8.0) |     terminal-table (1.8.0) | ||||||
|       unicode-display_width (~> 1.1, >= 1.1.1) |       unicode-display_width (~> 1.1, >= 1.1.1) | ||||||
| @@ -487,13 +513,13 @@ GEM | |||||||
|     uniform_notifier (1.10.0) |     uniform_notifier (1.10.0) | ||||||
|     warden (1.2.7) |     warden (1.2.7) | ||||||
|       rack (>= 1.0) |       rack (>= 1.0) | ||||||
|     webmock (3.0.1) |     webmock (3.1.0) | ||||||
|       addressable (>= 2.3.6) |       addressable (>= 2.3.6) | ||||||
|       crack (>= 0.3.2) |       crack (>= 0.3.2) | ||||||
|       hashdiff |       hashdiff | ||||||
|     webpacker (2.0) |     webpacker (3.0.1) | ||||||
|       activesupport (>= 4.2) |       activesupport (>= 4.2) | ||||||
|       multi_json (~> 1.2) |       rack-proxy (>= 0.6.1) | ||||||
|       railties (>= 4.2) |       railties (>= 4.2) | ||||||
|     webpush (0.3.2) |     webpush (0.3.2) | ||||||
|       hkdf (~> 0.2) |       hkdf (~> 0.2) | ||||||
| @@ -516,17 +542,17 @@ DEPENDENCIES | |||||||
|   better_errors (~> 2.1) |   better_errors (~> 2.1) | ||||||
|   binding_of_caller (~> 0.7) |   binding_of_caller (~> 0.7) | ||||||
|   bootsnap |   bootsnap | ||||||
|   brakeman (~> 3.6) |   brakeman (~> 4.0) | ||||||
|   browser |   browser | ||||||
|   bullet (~> 5.5) |   bullet (~> 5.5) | ||||||
|   bundler-audit (~> 0.5) |   bundler-audit (~> 0.6) | ||||||
|   capistrano (~> 3.8) |   capistrano (~> 3.8) | ||||||
|   capistrano-rails (~> 1.2) |   capistrano-rails (~> 1.2) | ||||||
|   capistrano-rbenv (~> 2.1) |   capistrano-rbenv (~> 2.1) | ||||||
|   capistrano-yarn (~> 2.0) |   capistrano-yarn (~> 2.0) | ||||||
|   capybara (~> 2.14) |   capybara (~> 2.14) | ||||||
|   charlock_holmes (~> 0.7.5) |   charlock_holmes (~> 0.7.5) | ||||||
|   cld3 (~> 3.1) |   cld3 (~> 3.2.0) | ||||||
|   climate_control (~> 0.2) |   climate_control (~> 0.2) | ||||||
|   devise (~> 4.2) |   devise (~> 4.2) | ||||||
|   devise-two-factor (~> 3.0) |   devise-two-factor (~> 3.0) | ||||||
| @@ -535,6 +561,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) | ||||||
| @@ -545,6 +572,7 @@ 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) |   json-ld-preloaded (~> 2.2.1) | ||||||
|   kaminari (~> 1.0) |   kaminari (~> 1.0) | ||||||
|   letter_opener (~> 1.4) |   letter_opener (~> 1.4) | ||||||
| @@ -555,6 +583,7 @@ DEPENDENCIES | |||||||
|   microformats (~> 4.0) |   microformats (~> 4.0) | ||||||
|   mime-types (~> 3.1) |   mime-types (~> 3.1) | ||||||
|   nokogiri (~> 1.7) |   nokogiri (~> 1.7) | ||||||
|  |   nsa (~> 0.2) | ||||||
|   oj (~> 3.0) |   oj (~> 3.0) | ||||||
|   ostatus2 (~> 2.0) |   ostatus2 (~> 2.0) | ||||||
|   ox (~> 2.5) |   ox (~> 2.5) | ||||||
| @@ -565,13 +594,13 @@ DEPENDENCIES | |||||||
|   pghero (~> 1.7) |   pghero (~> 1.7) | ||||||
|   pkg-config (~> 1.2) |   pkg-config (~> 1.2) | ||||||
|   pry-rails (~> 0.3) |   pry-rails (~> 0.3) | ||||||
|   puma (~> 3.8) |   puma (~> 3.10) | ||||||
|   pundit (~> 1.1) |   pundit (~> 1.1) | ||||||
|   rabl (~> 0.13) |   rabl (~> 0.13) | ||||||
|   rack-attack (~> 5.0) |   rack-attack (~> 5.0) | ||||||
|   rack-cors (~> 0.4) |   rack-cors (~> 0.4) | ||||||
|   rack-timeout (~> 0.4) |   rack-timeout (~> 0.4) | ||||||
|   rails (~> 5.1.0) |   rails (~> 5.1.4) | ||||||
|   rails-controller-testing (~> 1.0) |   rails-controller-testing (~> 1.0) | ||||||
|   rails-i18n (~> 5.0) |   rails-i18n (~> 5.0) | ||||||
|   rails-settings-cached (~> 0.6) |   rails-settings-cached (~> 0.6) | ||||||
| @@ -594,16 +623,16 @@ DEPENDENCIES | |||||||
|   simple_form (~> 3.4) |   simple_form (~> 3.4) | ||||||
|   simplecov (~> 0.14) |   simplecov (~> 0.14) | ||||||
|   sprockets-rails (~> 3.2) |   sprockets-rails (~> 3.2) | ||||||
|   statsd-instrument (~> 2.1) |   strong_migrations | ||||||
|   twitter-text (~> 1.14) |   twitter-text (~> 1.14) | ||||||
|   tzinfo-data (~> 1.2017) |   tzinfo-data (~> 1.2017) | ||||||
|   uglifier (~> 3.2) |   uglifier (~> 3.2) | ||||||
|   webmock (~> 3.0) |   webmock (~> 3.0) | ||||||
|   webpacker (~> 2.0) |   webpacker (~> 3.0) | ||||||
|   webpush |   webpush | ||||||
|  |  | ||||||
| RUBY VERSION | RUBY VERSION | ||||||
|    ruby 2.4.1p111 |    ruby 2.4.2p198 | ||||||
|  |  | ||||||
| BUNDLED WITH | BUNDLED WITH | ||||||
|    1.15.4 |    1.15.4 | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| web: PORT=3000 bundle exec puma -C config/puma.rb | web: PORT=3000 bundle exec puma -C config/puma.rb | ||||||
| sidekiq: PORT=3000 bundle exec sidekiq | sidekiq: PORT=3000 bundle exec sidekiq | ||||||
| stream: PORT=4000 yarn run start | stream: PORT=4000 yarn run start | ||||||
| webpack: ./bin/webpack-dev-server --host 0.0.0.0 | webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0 | ||||||
|   | |||||||
							
								
								
									
										67
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -7,46 +7,62 @@ | |||||||
| [travis]: https://travis-ci.org/tootsuite/mastodon | [travis]: https://travis-ci.org/tootsuite/mastodon | ||||||
| [code_climate]: https://codeclimate.com/github/tootsuite/mastodon | [code_climate]: https://codeclimate.com/github/tootsuite/mastodon | ||||||
|  |  | ||||||
| Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. | Mastodon is a **free, open-source social network server** based on **open web protocols** like ActivityPub and OStatus. The social focus of the project is a viable decentralized alternative to commercial social media silos that returns the control of the content distribution channels to the people. The technical focus of the project is a good user interface, a clean REST API for 3rd party apps and robust anti-abuse tools. | ||||||
|  |  | ||||||
| An alternative implementation of the GNU social project. Based on [ActivityStreams](https://en.wikipedia.org/wiki/Activity_Streams_(format)), [Webfinger](https://en.wikipedia.org/wiki/WebFinger), [WebSub](https://en.wikipedia.org/wiki/WebSub) and [Salmon](https://en.wikipedia.org/wiki/Salmon_(protocol)). | Click on the screenshot below to watch a demo of the UI: | ||||||
|  |  | ||||||
| Click on the screenshot to watch a demo of the UI: |  | ||||||
|  |  | ||||||
| [][youtube_demo] | [][youtube_demo] | ||||||
|  |  | ||||||
| [youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU | [youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU | ||||||
|  |  | ||||||
| The project focus is a clean REST API and a good user interface. Ruby on Rails is used for the back-end, while React.js and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided. | **Ruby on Rails** is used for the back-end, while **React.js** and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided. | ||||||
|  |  | ||||||
| If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd` | If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd` | ||||||
|  |  | ||||||
| [patreon]: https://www.patreon.com/user?u=619786 | [patreon]: https://www.patreon.com/user?u=619786 | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
| ## Resources | ## Resources | ||||||
|  |  | ||||||
| - [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md) |  | ||||||
| - [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com) |  | ||||||
| - [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) |  | ||||||
| - [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md) | - [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md) | ||||||
|  | - [Use this tool to find Twitter friends on Mastodon](https://bridge.joinmastodon.org) | ||||||
|  | - [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) | ||||||
|  | - [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md) | ||||||
| - [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md) | - [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md) | ||||||
|  | - [List of sponsors](https://joinmastodon.org/sponsors) | ||||||
|  |  | ||||||
| ## Features | ## Features | ||||||
|  |  | ||||||
| - **Fully interoperable with GNU social and any OStatus platform** | **No vendor lock-in: Fully interoperable with any conforming platform** | ||||||
|   Whatever implements Atom feeds, ActivityStreams, Salmon, WebSub and Webfinger is part of the network |  | ||||||
| - **Real-time timeline updates** | It doesn't have to be Mastodon, whatever implements ActivityPub or OStatus is part of the social network! | ||||||
|   See the updates of people you're following appear in real-time in the UI via WebSockets |  | ||||||
| - **Federated thread resolving** | **Real-time timeline updates** | ||||||
|   If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI |  | ||||||
| - **Media attachments like images and WebM** | See the updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well! | ||||||
|   Upload and view images and WebM videos attached to the updates |  | ||||||
| - **OAuth2 and a straightforward REST API** | **Federated thread resolving** | ||||||
|   Mastodon acts as an OAuth2 provider so 3rd party apps can use the API, which is RESTful and simple |  | ||||||
| - **Background processing for long-running tasks** | If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI | ||||||
|   Mastodon tries to be as fast and responsive as possible, so all long-running tasks that can be delegated to background processing, are |  | ||||||
| - **Deployable via Docker** | **Media attachments like images and short videos** | ||||||
|   You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy |  | ||||||
|  | Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos are looped - like vines! | ||||||
|  |  | ||||||
|  | **OAuth2 and a straightforward REST API** | ||||||
|  |  | ||||||
|  | Mastodon acts as an OAuth2 provider so 3rd party apps can use the API | ||||||
|  |  | ||||||
|  | **Fast response times** | ||||||
|  |  | ||||||
|  | Mastodon tries to be as fast and responsive as possible, so all long-running tasks are delegated to background processing | ||||||
|  |  | ||||||
|  | **Deployable via Docker** | ||||||
|  |  | ||||||
|  | You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
| ## Development | ## Development | ||||||
|  |  | ||||||
| @@ -62,9 +78,8 @@ You can open issues for bugs you've found or features you think are missing. You | |||||||
|  |  | ||||||
| **IRC channel**: #mastodon on irc.freenode.net | **IRC channel**: #mastodon on irc.freenode.net | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
| ## Extra credits | ## Extra credits | ||||||
|  |  | ||||||
| - The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis | The elephant friend illustrations are created by [Dopatwo](https://mastodon.social/@dopatwo) | ||||||
| - The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo) |  | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -14,7 +14,7 @@ class AccountsController < ApplicationController | |||||||
|           return |           return | ||||||
|         end |         end | ||||||
|  |  | ||||||
|         @pinned_statuses = cache_collection(@account.pinned_statuses, Status) unless media_requested? |         @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        = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id]) | ||||||
|         @statuses        = cache_collection(@statuses, Status) |         @statuses        = cache_collection(@statuses, Status) | ||||||
|         @next_url        = next_url unless @statuses.empty? |         @next_url        = next_url unless @statuses.empty? | ||||||
| @@ -22,17 +22,24 @@ class AccountsController < ApplicationController | |||||||
|  |  | ||||||
|       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, content_type: 'application/activity+json' |         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 |   def filtered_statuses | ||||||
|     default_statuses.tap do |statuses| |     default_statuses.tap do |statuses| | ||||||
|       statuses.merge!(only_media_scope) if media_requested? |       statuses.merge!(only_media_scope) if media_requested? | ||||||
|   | |||||||
| @@ -9,9 +9,9 @@ class ActivityPub::InboxesController < Api::BaseController | |||||||
|     if signed_request_account |     if signed_request_account | ||||||
|       upgrade_account |       upgrade_account | ||||||
|       process_payload |       process_payload | ||||||
|       head 201 |  | ||||||
|     else |  | ||||||
|       head 202 |       head 202 | ||||||
|  |     else | ||||||
|  |       [signature_verification_failure_reason, 401] | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -32,6 +32,7 @@ class ActivityPub::InboxesController < Api::BaseController | |||||||
|     end |     end | ||||||
|  |  | ||||||
|     Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? |     Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? | ||||||
|  |     DeliveryFailureTracker.track_inverse_success!(signed_request_account) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def process_payload |   def process_payload | ||||||
|   | |||||||
							
								
								
									
										31
									
								
								app/controllers/admin/account_moderation_notes_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,31 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class Admin::AccountModerationNotesController < Admin::BaseController | ||||||
|  |   def create | ||||||
|  |     @account_moderation_note = current_account.account_moderation_notes.new(resource_params) | ||||||
|  |     if @account_moderation_note.save | ||||||
|  |       @target_account = @account_moderation_note.target_account | ||||||
|  |       redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.created_msg') | ||||||
|  |     else | ||||||
|  |       @account = @account_moderation_note.target_account | ||||||
|  |       @moderation_notes = @account.targeted_moderation_notes.latest | ||||||
|  |       render template: 'admin/accounts/show' | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def destroy | ||||||
|  |     @account_moderation_note = AccountModerationNote.find(params[:id]) | ||||||
|  |     @target_account = @account_moderation_note.target_account | ||||||
|  |     @account_moderation_note.destroy | ||||||
|  |     redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg') | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def resource_params | ||||||
|  |     params.require(:account_moderation_note).permit( | ||||||
|  |       :content, | ||||||
|  |       :target_account_id | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -9,7 +9,10 @@ module Admin | |||||||
|       @accounts = filtered_accounts.page(params[:page]) |       @accounts = filtered_accounts.page(params[:page]) | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     def show; end |     def show | ||||||
|  |       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) | ||||||
|  |       @moderation_notes = @account.targeted_moderation_notes.latest | ||||||
|  |     end | ||||||
|  |  | ||||||
|     def subscribe |     def subscribe | ||||||
|       Pubsubhubbub::SubscribeWorker.perform_async(@account.id) |       Pubsubhubbub::SubscribeWorker.perform_async(@account.id) | ||||||
|   | |||||||
							
								
								
									
										73
									
								
								app/controllers/admin/custom_emojis_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,73 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | module Admin | ||||||
|  |   class CustomEmojisController < BaseController | ||||||
|  |     before_action :set_custom_emoji, except: [:index, :new, :create] | ||||||
|  |  | ||||||
|  |     def index | ||||||
|  |       @custom_emojis = filtered_custom_emojis.page(params[:page]) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def new | ||||||
|  |       @custom_emoji = CustomEmoji.new | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def create | ||||||
|  |       @custom_emoji = CustomEmoji.new(resource_params) | ||||||
|  |  | ||||||
|  |       if @custom_emoji.save | ||||||
|  |         redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg') | ||||||
|  |       else | ||||||
|  |         render :new | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def destroy | ||||||
|  |       @custom_emoji.destroy | ||||||
|  |       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def copy | ||||||
|  |       emoji = CustomEmoji.new(domain: nil, shortcode: @custom_emoji.shortcode, image: @custom_emoji.image) | ||||||
|  |  | ||||||
|  |       if emoji.save | ||||||
|  |         flash[:notice] = I18n.t('admin.custom_emojis.copied_msg') | ||||||
|  |       else | ||||||
|  |         flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg') | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       redirect_to admin_custom_emojis_path(params[:page]) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def enable | ||||||
|  |       @custom_emoji.update!(disabled: false) | ||||||
|  |       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def disable | ||||||
|  |       @custom_emoji.update!(disabled: true) | ||||||
|  |       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     private | ||||||
|  |  | ||||||
|  |     def set_custom_emoji | ||||||
|  |       @custom_emoji = CustomEmoji.find(params[:id]) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def resource_params | ||||||
|  |       params.require(:custom_emoji).permit(:shortcode, :image) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def filtered_custom_emojis | ||||||
|  |       CustomEmojiFilter.new(filter_params).results | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def filter_params | ||||||
|  |       params.permit( | ||||||
|  |         :local, | ||||||
|  |         :remote | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										40
									
								
								app/controllers/admin/email_domain_blocks_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,40 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | module Admin | ||||||
|  |   class EmailDomainBlocksController < BaseController | ||||||
|  |     before_action :set_email_domain_block, only: [:show, :destroy] | ||||||
|  |  | ||||||
|  |     def index | ||||||
|  |       @email_domain_blocks = EmailDomainBlock.page(params[:page]) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def new | ||||||
|  |       @email_domain_block = EmailDomainBlock.new | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def create | ||||||
|  |       @email_domain_block = EmailDomainBlock.new(resource_params) | ||||||
|  |  | ||||||
|  |       if @email_domain_block.save | ||||||
|  |         redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg') | ||||||
|  |       else | ||||||
|  |         render :new | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def destroy | ||||||
|  |       @email_domain_block.destroy | ||||||
|  |       redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     private | ||||||
|  |  | ||||||
|  |     def set_email_domain_block | ||||||
|  |       @email_domain_block = EmailDomainBlock.find(params[:id]) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def resource_params | ||||||
|  |       params.require(:email_domain_block).permit(:domain) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -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,15 +23,24 @@ module Admin | |||||||
|       timeline_preview |       timeline_preview | ||||||
|     ).freeze |     ).freeze | ||||||
|  |  | ||||||
|  |     UPLOAD_SETTINGS = %w( | ||||||
|  |       thumbnail | ||||||
|  |     ).freeze | ||||||
|  |  | ||||||
|     def edit |     def edit | ||||||
|       @admin_settings = Form::AdminSettings.new |       @admin_settings = Form::AdminSettings.new | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     def update |     def update | ||||||
|       settings_params.each do |key, value| |       settings_params.each do |key, value| | ||||||
|  |         if UPLOAD_SETTINGS.include?(key) | ||||||
|  |           upload = SiteUpload.where(var: key).first_or_initialize(var: key) | ||||||
|  |           upload.update(file: value) | ||||||
|  |         else | ||||||
|           setting = Setting.where(var: key).first_or_initialize(var: key) |           setting = Setting.where(var: key).first_or_initialize(var: key) | ||||||
|           setting.update(value: value_for_update(key, value)) |           setting.update(value: value_for_update(key, value)) | ||||||
|         end |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|       flash[:notice] = I18n.t('generic.changes_saved_msg') |       flash[:notice] = I18n.t('generic.changes_saved_msg') | ||||||
|       redirect_to edit_admin_settings_path |       redirect_to edit_admin_settings_path | ||||||
|   | |||||||
| @@ -7,9 +7,11 @@ class Api::SalmonController < Api::BaseController | |||||||
|   def update |   def update | ||||||
|     if verify_payload? |     if verify_payload? | ||||||
|       process_salmon |       process_salmon | ||||||
|       head 201 |  | ||||||
|     else |  | ||||||
|       head 202 |       head 202 | ||||||
|  |     elsif payload.present? | ||||||
|  |       [signature_verification_failure_reason, 401] | ||||||
|  |     else | ||||||
|  |       head 400 | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,7 +7,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController | |||||||
|   respond_to :json |   respond_to :json | ||||||
|  |  | ||||||
|   def index |   def index | ||||||
|     @accounts = Account.where(id: account_ids).select('id') |     accounts = Account.where(id: account_ids).select('id') | ||||||
|  |     # .where doesn't guarantee that our results are in the same order | ||||||
|  |     # we requested them, so return the "right" order to the requestor. | ||||||
|  |     @accounts = accounts.index_by(&:id).values_at(*account_ids) | ||||||
|     render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships |     render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships | ||||||
|   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 | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								app/controllers/api/v1/apps/credentials_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class Api::V1::Apps::CredentialsController < Api::BaseController | ||||||
|  |   before_action -> { doorkeeper_authorize! :read } | ||||||
|  |  | ||||||
|  |   respond_to :json | ||||||
|  |  | ||||||
|  |   def show | ||||||
|  |     render json: doorkeeper_token.application, serializer: REST::StatusSerializer::ApplicationSerializer | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -1,8 +1,6 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
|  |  | ||||||
| class Api::V1::AppsController < Api::BaseController | class Api::V1::AppsController < Api::BaseController | ||||||
|   respond_to :json |  | ||||||
|  |  | ||||||
|   def create |   def create | ||||||
|     @app = Doorkeeper::Application.create!(application_options) |     @app = Doorkeeper::Application.create!(application_options) | ||||||
|     render json: @app, serializer: REST::ApplicationSerializer |     render json: @app, serializer: REST::ApplicationSerializer | ||||||
|   | |||||||
| @@ -15,15 +15,13 @@ class Api::V1::BlocksController < Api::BaseController | |||||||
|   private |   private | ||||||
|  |  | ||||||
|   def load_accounts |   def load_accounts | ||||||
|     default_accounts.merge(paginated_blocks).to_a |     paginated_blocks.map(&:target_account) | ||||||
|   end |  | ||||||
|  |  | ||||||
|   def default_accounts |  | ||||||
|     Account.includes(:blocked_by).references(:blocked_by) |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def paginated_blocks |   def paginated_blocks | ||||||
|     Block.where(account: current_account).paginate_by_max_id( |     @paginated_blocks ||= Block.eager_load(:target_account) | ||||||
|  |                                .where(account: current_account) | ||||||
|  |                                .paginate_by_max_id( | ||||||
|                                  limit_param(DEFAULT_ACCOUNTS_LIMIT), |                                  limit_param(DEFAULT_ACCOUNTS_LIMIT), | ||||||
|                                  params[:max_id], |                                  params[:max_id], | ||||||
|                                  params[:since_id] |                                  params[:since_id] | ||||||
| @@ -41,21 +39,21 @@ class Api::V1::BlocksController < Api::BaseController | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def prev_path |   def prev_path | ||||||
|     unless @accounts.empty? |     unless paginated_blocks.empty? | ||||||
|       api_v1_blocks_url pagination_params(since_id: pagination_since_id) |       api_v1_blocks_url pagination_params(since_id: pagination_since_id) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def pagination_max_id |   def pagination_max_id | ||||||
|     @accounts.last.blocked_by_ids.last |     paginated_blocks.last.id | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def pagination_since_id |   def pagination_since_id | ||||||
|     @accounts.first.blocked_by_ids.first |     paginated_blocks.first.id | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def records_continue? |   def records_continue? | ||||||
|     @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) |     paginated_blocks.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def pagination_params(core_params) |   def pagination_params(core_params) | ||||||
|   | |||||||
							
								
								
									
										9
									
								
								app/controllers/api/v1/custom_emojis_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class Api::V1::CustomEmojisController < Api::BaseController | ||||||
|  |   respond_to :json | ||||||
|  |  | ||||||
|  |   def index | ||||||
|  |     render json: CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -10,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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController | |||||||
|   respond_to :json |   respond_to :json | ||||||
|  |  | ||||||
|   def create |   def create | ||||||
|     @media = current_account.media_attachments.create!(file: media_params[:file]) |     @media = current_account.media_attachments.create!(media_params) | ||||||
|     render json: @media, serializer: REST::MediaAttachmentSerializer |     render json: @media, serializer: REST::MediaAttachmentSerializer | ||||||
|   rescue Paperclip::Errors::NotIdentifiedByImageMagickError |   rescue Paperclip::Errors::NotIdentifiedByImageMagickError | ||||||
|     render json: file_type_error, status: 422 |     render json: file_type_error, status: 422 | ||||||
| @@ -18,10 +18,16 @@ class Api::V1::MediaController < Api::BaseController | |||||||
|     render json: processing_error, status: 500 |     render json: processing_error, status: 500 | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def update | ||||||
|  |     @media = current_account.media_attachments.where(status_id: nil).find(params[:id]) | ||||||
|  |     @media.update!(media_params) | ||||||
|  |     render json: @media, serializer: REST::MediaAttachmentSerializer | ||||||
|  |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|   def media_params |   def media_params | ||||||
|     params.permit(:file) |     params.permit(:file, :description) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def file_type_error |   def file_type_error | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base | |||||||
|  |  | ||||||
|   helper_method :current_account |   helper_method :current_account | ||||||
|   helper_method :current_session |   helper_method :current_session | ||||||
|  |   helper_method :current_theme | ||||||
|   helper_method :single_user_mode? |   helper_method :single_user_mode? | ||||||
|  |  | ||||||
|   rescue_from ActionController::RoutingError, with: :not_found |   rescue_from ActionController::RoutingError, with: :not_found | ||||||
| @@ -77,6 +78,11 @@ class ApplicationController < ActionController::Base | |||||||
|     @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id']) |     @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id']) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def current_theme | ||||||
|  |     return Setting.default_settings['theme'] unless Themes.instance.names.include? current_user&.setting_theme | ||||||
|  |     current_user.setting_theme | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def cache_collection(raw, klass) |   def cache_collection(raw, klass) | ||||||
|     return raw unless klass.respond_to?(:with_includes) |     return raw unless klass.respond_to?(:with_includes) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController | |||||||
|   before_action :check_enabled_registrations, only: [:new, :create] |   before_action :check_enabled_registrations, only: [:new, :create] | ||||||
|   before_action :configure_sign_up_params, only: [:create] |   before_action :configure_sign_up_params, only: [:create] | ||||||
|   before_action :set_sessions, only: [:edit, :update] |   before_action :set_sessions, only: [:edit, :update] | ||||||
|  |   before_action :set_instance_presenter, only: [:new, :create, :update] | ||||||
|  |  | ||||||
|   def destroy |   def destroy | ||||||
|     not_found |     not_found | ||||||
| @@ -39,6 +40,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController | |||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  |   def set_instance_presenter | ||||||
|  |     @instance_presenter = InstancePresenter.new | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def determine_layout |   def determine_layout | ||||||
|     %w(edit update).include?(action_name) ? 'admin' : 'auth' |     %w(edit update).include?(action_name) ? 'admin' : 'auth' | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ class Auth::SessionsController < Devise::SessionsController | |||||||
|   skip_before_action :require_no_authentication, only: [:create] |   skip_before_action :require_no_authentication, only: [:create] | ||||||
|   skip_before_action :check_suspension, only: [:destroy] |   skip_before_action :check_suspension, only: [:destroy] | ||||||
|   prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] |   prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] | ||||||
|  |   before_action :set_instance_presenter, only: [:new] | ||||||
|  |  | ||||||
|   def create |   def create | ||||||
|     super do |resource| |     super do |resource| | ||||||
| @@ -84,6 +85,10 @@ class Auth::SessionsController < Devise::SessionsController | |||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  |   def set_instance_presenter | ||||||
|  |     @instance_presenter = InstancePresenter.new | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def home_paths(resource) |   def home_paths(resource) | ||||||
|     paths = [about_path] |     paths = [about_path] | ||||||
|     if single_user_mode? && resource.is_a?(User) |     if single_user_mode? && resource.is_a?(User) | ||||||
|   | |||||||
| @@ -9,10 +9,15 @@ module SignatureVerification | |||||||
|     request.headers['Signature'].present? |     request.headers['Signature'].present? | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def signature_verification_failure_reason | ||||||
|  |     return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason) | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def signed_request_account |   def signed_request_account | ||||||
|     return @signed_request_account if defined?(@signed_request_account) |     return @signed_request_account if defined?(@signed_request_account) | ||||||
|  |  | ||||||
|     unless signed_request? |     unless signed_request? | ||||||
|  |       @signature_verification_failure_reason = 'Request not signed' | ||||||
|       @signed_request_account = nil |       @signed_request_account = nil | ||||||
|       return |       return | ||||||
|     end |     end | ||||||
| @@ -27,6 +32,7 @@ module SignatureVerification | |||||||
|     end |     end | ||||||
|  |  | ||||||
|     if incompatible_signature?(signature_params) |     if incompatible_signature?(signature_params) | ||||||
|  |       @signature_verification_failure_reason = 'Incompatible request signature' | ||||||
|       @signed_request_account = nil |       @signed_request_account = nil | ||||||
|       return |       return | ||||||
|     end |     end | ||||||
| @@ -34,6 +40,7 @@ module SignatureVerification | |||||||
|     account = account_from_key_id(signature_params['keyId']) |     account = account_from_key_id(signature_params['keyId']) | ||||||
|  |  | ||||||
|     if account.nil? |     if account.nil? | ||||||
|  |       @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" | ||||||
|       @signed_request_account = nil |       @signed_request_account = nil | ||||||
|       return |       return | ||||||
|     end |     end | ||||||
| @@ -41,10 +48,21 @@ module SignatureVerification | |||||||
|     signature             = Base64.decode64(signature_params['signature']) |     signature             = Base64.decode64(signature_params['signature']) | ||||||
|     compare_signed_string = build_signed_string(signature_params['headers']) |     compare_signed_string = build_signed_string(signature_params['headers']) | ||||||
|  |  | ||||||
|  |     if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) | ||||||
|  |       @signed_request_account = account | ||||||
|  |       @signed_request_account | ||||||
|  |     elsif account.possibly_stale? | ||||||
|  |       account = account.refresh! | ||||||
|  |  | ||||||
|       if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) |       if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) | ||||||
|         @signed_request_account = account |         @signed_request_account = account | ||||||
|         @signed_request_account |         @signed_request_account | ||||||
|       else |       else | ||||||
|  |         @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" | ||||||
|  |         @signed_request_account = nil | ||||||
|  |       end | ||||||
|  |     else | ||||||
|  |       @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" | ||||||
|       @signed_request_account = nil |       @signed_request_account = nil | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| @@ -99,7 +117,7 @@ module SignatureVerification | |||||||
|       ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) |       ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) | ||||||
|     elsif !ActivityPub::TagManager.instance.local_uri?(key_id) |     elsif !ActivityPub::TagManager.instance.local_uri?(key_id) | ||||||
|       account   = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) |       account   = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) | ||||||
|       account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) |       account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) | ||||||
|       account |       account | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -7,12 +7,14 @@ module UserTrackingConcern | |||||||
|   UPDATE_SIGN_IN_HOURS = 24 |   UPDATE_SIGN_IN_HOURS = 24 | ||||||
|  |  | ||||||
|   included do |   included do | ||||||
|     before_action :set_user_activity, if: %i(user_signed_in? user_needs_sign_in_update?) |     before_action :set_user_activity | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|   def set_user_activity |   def set_user_activity | ||||||
|  |     return unless user_needs_sign_in_update? | ||||||
|  |  | ||||||
|     # Mark as signed-in today |     # Mark as signed-in today | ||||||
|     current_user.update_tracked_fields!(request) |     current_user.update_tracked_fields!(request) | ||||||
|  |  | ||||||
| @@ -21,7 +23,7 @@ module UserTrackingConcern | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def user_needs_sign_in_update? |   def user_needs_sign_in_update? | ||||||
|     current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_HOURS.hours.ago |     user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_HOURS.hours.ago) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def user_needs_feed_update? |   def user_needs_feed_update? | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								app/controllers/emojis_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class EmojisController < ApplicationController | ||||||
|  |   before_action :set_emoji | ||||||
|  |  | ||||||
|  |   def show | ||||||
|  |     respond_to do |format| | ||||||
|  |       format.json do | ||||||
|  |         render json: @emoji, | ||||||
|  |                serializer: ActivityPub::EmojiSerializer, | ||||||
|  |                adapter: ActivityPub::Adapter, | ||||||
|  |                content_type: 'application/activity+json' | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def set_emoji | ||||||
|  |     @emoji = CustomEmoji.local.find(params[:id]) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -10,19 +10,39 @@ class FollowerAccountsController < ApplicationController | |||||||
|       format.html |       format.html | ||||||
|  |  | ||||||
|       format.json do |       format.json do | ||||||
|         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' |         render json: collection_presenter, | ||||||
|  |                serializer: ActivityPub::CollectionSerializer, | ||||||
|  |                adapter: ActivityPub::Adapter, | ||||||
|  |                content_type: 'application/activity+json' | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  |   def page_url(page) | ||||||
|  |     account_followers_url(@account, page: page) unless page.nil? | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def collection_presenter |   def collection_presenter | ||||||
|  |     page = ActivityPub::CollectionPresenter.new( | ||||||
|  |       id: account_followers_url(@account, page: params.fetch(:page, 1)), | ||||||
|  |       type: :ordered, | ||||||
|  |       size: @account.followers_count, | ||||||
|  |       items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, | ||||||
|  |       part_of: account_followers_url(@account), | ||||||
|  |       next: page_url(@follows.next_page), | ||||||
|  |       prev: page_url(@follows.prev_page) | ||||||
|  |     ) | ||||||
|  |     if params[:page].present? | ||||||
|  |       page | ||||||
|  |     else | ||||||
|       ActivityPub::CollectionPresenter.new( |       ActivityPub::CollectionPresenter.new( | ||||||
|         id: account_followers_url(@account), |         id: account_followers_url(@account), | ||||||
|         type: :ordered, |         type: :ordered, | ||||||
|         size: @account.followers_count, |         size: @account.followers_count, | ||||||
|       items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) } |         first: page | ||||||
|       ) |       ) | ||||||
|     end |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -10,19 +10,39 @@ class FollowingAccountsController < ApplicationController | |||||||
|       format.html |       format.html | ||||||
|  |  | ||||||
|       format.json do |       format.json do | ||||||
|         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' |         render json: collection_presenter, | ||||||
|  |                serializer: ActivityPub::CollectionSerializer, | ||||||
|  |                adapter: ActivityPub::Adapter, | ||||||
|  |                content_type: 'application/activity+json' | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  |   def page_url(page) | ||||||
|  |     account_following_index_url(@account, page: page) unless page.nil? | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def collection_presenter |   def collection_presenter | ||||||
|  |     page = ActivityPub::CollectionPresenter.new( | ||||||
|  |       id: account_following_index_url(@account, page: params.fetch(:page, 1)), | ||||||
|  |       type: :ordered, | ||||||
|  |       size: @account.following_count, | ||||||
|  |       items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }, | ||||||
|  |       part_of: account_following_index_url(@account), | ||||||
|  |       next: page_url(@follows.next_page), | ||||||
|  |       prev: page_url(@follows.prev_page) | ||||||
|  |     ) | ||||||
|  |     if params[:page].present? | ||||||
|  |       page | ||||||
|  |     else | ||||||
|       ActivityPub::CollectionPresenter.new( |       ActivityPub::CollectionPresenter.new( | ||||||
|         id: account_following_index_url(@account), |         id: account_following_index_url(@account), | ||||||
|         type: :ordered, |         type: :ordered, | ||||||
|         size: @account.following_count, |         size: @account.following_count, | ||||||
|       items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) } |         first: page | ||||||
|       ) |       ) | ||||||
|     end |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -1,11 +1,7 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
|  |  | ||||||
| class ManifestsController < ApplicationController | class ManifestsController < ApplicationController | ||||||
|   before_action :set_instance_presenter |   def show | ||||||
|  |     render json: InstancePresenter.new, serializer: ManifestSerializer | ||||||
|   def show; end |  | ||||||
|  |  | ||||||
|   def set_instance_presenter |  | ||||||
|     @instance_presenter = InstancePresenter.new |  | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										40
									
								
								app/controllers/media_proxy_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,40 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class MediaProxyController < ApplicationController | ||||||
|  |   include RoutingHelper | ||||||
|  |  | ||||||
|  |   def show | ||||||
|  |     RedisLock.acquire(lock_options) do |lock| | ||||||
|  |       if lock.acquired? | ||||||
|  |         @media_attachment = MediaAttachment.remote.find(params[:id]) | ||||||
|  |         redownload! if @media_attachment.needs_redownload? && !reject_media? | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     redirect_to full_asset_url(@media_attachment.file.url(version)) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def redownload! | ||||||
|  |     @media_attachment.file_remote_url = @media_attachment.remote_url | ||||||
|  |     @media_attachment.created_at      = Time.now.utc | ||||||
|  |     @media_attachment.save! | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def version | ||||||
|  |     if request.path.ends_with?('/small') | ||||||
|  |       :small | ||||||
|  |     else | ||||||
|  |       :original | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def lock_options | ||||||
|  |     { redis: Redis.current, key: "media_download:#{params[:id]}" } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def reject_media? | ||||||
|  |     DomainBlock.find_by(domain: @media_attachment.account.domain)&.reject_media? | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -9,7 +9,7 @@ class Settings::FollowerDomainsController < ApplicationController | |||||||
|  |  | ||||||
|   def show |   def show | ||||||
|     @account = current_account |     @account = current_account | ||||||
|     @domains = current_account.followers.reorder(nil).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) |     @domains = current_account.followers.reorder('MIN(follows.id) DESC').group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def update |   def update | ||||||
|   | |||||||
							
								
								
									
										32
									
								
								app/controllers/settings/notifications_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,32 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | class Settings::NotificationsController < ApplicationController | ||||||
|  |   layout 'admin' | ||||||
|  |  | ||||||
|  |   before_action :authenticate_user! | ||||||
|  |  | ||||||
|  |   def show; end | ||||||
|  |  | ||||||
|  |   def update | ||||||
|  |     user_settings.update(user_settings_params.to_h) | ||||||
|  |  | ||||||
|  |     if current_user.save | ||||||
|  |       redirect_to settings_notifications_path, notice: I18n.t('generic.changes_saved_msg') | ||||||
|  |     else | ||||||
|  |       render :show | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def user_settings | ||||||
|  |     UserSettingsDecorator.new(current_user) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def user_settings_params | ||||||
|  |     params.require(:user).permit( | ||||||
|  |       notification_emails: %i(follow follow_request reblog favourite mention digest), | ||||||
|  |       interactions: %i(must_be_follower must_be_following) | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -39,8 +39,10 @@ class Settings::PreferencesController < ApplicationController | |||||||
|       :setting_boost_modal, |       :setting_boost_modal, | ||||||
|       :setting_delete_modal, |       :setting_delete_modal, | ||||||
|       :setting_auto_play_gif, |       :setting_auto_play_gif, | ||||||
|  |       :setting_reduce_motion, | ||||||
|       :setting_system_font_ui, |       :setting_system_font_ui, | ||||||
|       :setting_noindex, |       :setting_noindex, | ||||||
|  |       :setting_theme, | ||||||
|       notification_emails: %i(follow follow_request reblog favourite mention digest), |       notification_emails: %i(follow follow_request reblog favourite mention digest), | ||||||
|       interactions: %i(must_be_follower must_be_following) |       interactions: %i(must_be_follower must_be_following) | ||||||
|     ) |     ) | ||||||
|   | |||||||
| @@ -21,13 +21,19 @@ class StatusesController < ApplicationController | |||||||
|       end |       end | ||||||
|  |  | ||||||
|       format.json do |       format.json do | ||||||
|         render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' |         render json: @status, | ||||||
|  |                serializer: ActivityPub::NoteSerializer, | ||||||
|  |                adapter: ActivityPub::Adapter, | ||||||
|  |                content_type: 'application/activity+json' | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def activity |   def activity | ||||||
|     render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' |     render json: @status, | ||||||
|  |            serializer: ActivityPub::ActivitySerializer, | ||||||
|  |            adapter: ActivityPub::Adapter, | ||||||
|  |            content_type: 'application/activity+json' | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def embed |   def embed | ||||||
|   | |||||||
| @@ -1,24 +1,40 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
|  |  | ||||||
| class TagsController < ApplicationController | class TagsController < ApplicationController | ||||||
|   layout 'public' |   before_action :set_body_classes | ||||||
|  |   before_action :set_instance_presenter | ||||||
|  |  | ||||||
|   def show |   def show | ||||||
|     @tag = Tag.find_by!(name: params[:id].downcase) |     @tag = Tag.find_by!(name: params[:id].downcase) | ||||||
|  |  | ||||||
|  |     respond_to do |format| | ||||||
|  |       format.html do | ||||||
|  |         serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) | ||||||
|  |         @initial_state_json   = serializable_resource.to_json | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       format.json do | ||||||
|         @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) |         @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) | ||||||
|         @statuses = cache_collection(@statuses, Status) |         @statuses = cache_collection(@statuses, Status) | ||||||
|  |  | ||||||
|     respond_to do |format| |         render json: collection_presenter, | ||||||
|       format.html |                serializer: ActivityPub::CollectionSerializer, | ||||||
|  |                adapter: ActivityPub::Adapter, | ||||||
|       format.json do |                content_type: 'application/activity+json' | ||||||
|         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' |  | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  |   def set_body_classes | ||||||
|  |     @body_classes = 'tag-body' | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def set_instance_presenter | ||||||
|  |     @instance_presenter = InstancePresenter.new | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def collection_presenter |   def collection_presenter | ||||||
|     ActivityPub::CollectionPresenter.new( |     ActivityPub::CollectionPresenter.new( | ||||||
|       id: tag_url(@tag), |       id: tag_url(@tag), | ||||||
| @@ -27,4 +43,11 @@ class TagsController < ApplicationController | |||||||
|       items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } |       items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def initial_state_params | ||||||
|  |     { | ||||||
|  |       settings: {}, | ||||||
|  |       token: current_session&.token, | ||||||
|  |     } | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								app/helpers/admin/account_moderation_notes_helper.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | module Admin::AccountModerationNotesHelper | ||||||
|  | end | ||||||
| @@ -42,4 +42,8 @@ module ApplicationHelper | |||||||
|  |  | ||||||
|     content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) |     content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def opengraph(property, content) | ||||||
|  |     tag(:meta, content: content, property: property) | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -1,24 +0,0 @@ | |||||||
| # frozen_string_literal: true |  | ||||||
|  |  | ||||||
| module EmojiHelper |  | ||||||
|   def emojify(text) |  | ||||||
|     return text if text.blank? |  | ||||||
|  |  | ||||||
|     text.gsub(emoji_pattern) do |match| |  | ||||||
|       emoji = Emoji.instance.unicode($1) # rubocop:disable Style/PerlBackrefs |  | ||||||
|  |  | ||||||
|       if emoji |  | ||||||
|         emoji |  | ||||||
|       else |  | ||||||
|         match |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   def emoji_pattern |  | ||||||
|     @emoji_pattern ||= |  | ||||||
|       /(?<=[^[:alnum:]:]|\n|^) |  | ||||||
|       (#{Emoji.instance.names.map { |name| Regexp.escape(name) }.join('|')}) |  | ||||||
|       (?=[^[:alnum:]:]|$)/x |  | ||||||
|   end |  | ||||||
| end |  | ||||||
| @@ -22,7 +22,18 @@ module JsonLdHelper | |||||||
|     graph.dump(:normalize) |     graph.dump(:normalize) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def fetch_resource(uri) |   def fetch_resource(uri, id) | ||||||
|  |     unless id | ||||||
|  |       json = fetch_resource_without_id_validation(uri) | ||||||
|  |       return unless json | ||||||
|  |       uri = json['id'] | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     json = fetch_resource_without_id_validation(uri) | ||||||
|  |     json.present? && json['id'] == uri ? json : nil | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def fetch_resource_without_id_validation(uri) | ||||||
|     response = build_request(uri).perform |     response = build_request(uri).perform | ||||||
|     return if response.code != 200 |     return if response.code != 200 | ||||||
|     body_to_json(response.to_s) |     body_to_json(response.to_s) | ||||||
|   | |||||||
| @@ -12,8 +12,14 @@ module RoutingHelper | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def full_asset_url(source, options = {}) |   def full_asset_url(source, options = {}) | ||||||
|     source = ActionController::Base.helpers.asset_url(source, options) unless Rails.configuration.x.use_s3 |     source = ActionController::Base.helpers.asset_url(source, options) unless use_storage? | ||||||
|  |  | ||||||
|     URI.join(root_url, source).to_s |     URI.join(root_url, source).to_s | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def use_storage? | ||||||
|  |     Rails.configuration.x.use_s3 || Rails.configuration.x.use_swift | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -27,9 +27,11 @@ module SettingsHelper | |||||||
|     pt: 'Português', |     pt: 'Português', | ||||||
|     'pt-BR': 'Português do Brasil', |     'pt-BR': 'Português do Brasil', | ||||||
|     ru: 'Русский', |     ru: 'Русский', | ||||||
|  |     sv: 'Svenska', | ||||||
|     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 +41,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 +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 | 
| @@ -122,7 +122,7 @@ export function unfollowAccount(id) { | |||||||
|     dispatch(unfollowAccountRequest(id)); |     dispatch(unfollowAccountRequest(id)); | ||||||
|  |  | ||||||
|     api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { |     api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { | ||||||
|       dispatch(unfollowAccountSuccess(response.data)); |       dispatch(unfollowAccountSuccess(response.data, getState().get('statuses'))); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(unfollowAccountFail(error)); |       dispatch(unfollowAccountFail(error)); | ||||||
|     }); |     }); | ||||||
| @@ -157,10 +157,11 @@ export function unfollowAccountRequest(id) { | |||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export function unfollowAccountSuccess(relationship) { | export function unfollowAccountSuccess(relationship, statuses) { | ||||||
|   return { |   return { | ||||||
|     type: ACCOUNT_UNFOLLOW_SUCCESS, |     type: ACCOUNT_UNFOLLOW_SUCCESS, | ||||||
|     relationship, |     relationship, | ||||||
|  |     statuses, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,14 @@ | |||||||
| import api from '../api'; | import api from '../api'; | ||||||
|  | import { throttle } from 'lodash'; | ||||||
|  | import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; | ||||||
|  | import { useEmoji } from './emojis'; | ||||||
|  |  | ||||||
| import { updateTimeline } 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'; | ||||||
| @@ -9,6 +17,7 @@ export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL'; | |||||||
| export const COMPOSE_REPLY           = 'COMPOSE_REPLY'; | export const COMPOSE_REPLY           = 'COMPOSE_REPLY'; | ||||||
| export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL'; | export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL'; | ||||||
| export const COMPOSE_MENTION         = 'COMPOSE_MENTION'; | export const COMPOSE_MENTION         = 'COMPOSE_MENTION'; | ||||||
|  | export const COMPOSE_RESET           = 'COMPOSE_RESET'; | ||||||
| export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST'; | export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST'; | ||||||
| export const COMPOSE_UPLOAD_SUCCESS  = 'COMPOSE_UPLOAD_SUCCESS'; | export const COMPOSE_UPLOAD_SUCCESS  = 'COMPOSE_UPLOAD_SUCCESS'; | ||||||
| export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL'; | export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL'; | ||||||
| @@ -31,6 +40,10 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; | |||||||
|  |  | ||||||
| export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; | export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; | ||||||
|  |  | ||||||
|  | export const COMPOSE_UPLOAD_CHANGE_REQUEST     = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; | ||||||
|  | export const COMPOSE_UPLOAD_CHANGE_SUCCESS     = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; | ||||||
|  | export const COMPOSE_UPLOAD_CHANGE_FAIL        = 'COMPOSE_UPLOAD_UPDATE_FAIL'; | ||||||
|  |  | ||||||
| export function changeCompose(text) { | export function changeCompose(text) { | ||||||
|   return { |   return { | ||||||
|     type: COMPOSE_CHANGE, |     type: COMPOSE_CHANGE, | ||||||
| @@ -57,6 +70,12 @@ export function cancelReplyCompose() { | |||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export function resetCompose() { | ||||||
|  |   return { | ||||||
|  |     type: COMPOSE_RESET, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export function mentionCompose(account, router) { | export function mentionCompose(account, router) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     dispatch({ |     dispatch({ | ||||||
| @@ -95,16 +114,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)); | ||||||
| @@ -155,6 +178,40 @@ export function uploadCompose(files) { | |||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export function changeUploadCompose(id, description) { | ||||||
|  |   return (dispatch, getState) => { | ||||||
|  |     dispatch(changeUploadComposeRequest()); | ||||||
|  |  | ||||||
|  |     api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { | ||||||
|  |       dispatch(changeUploadComposeSuccess(response.data)); | ||||||
|  |     }).catch(error => { | ||||||
|  |       dispatch(changeUploadComposeFail(id, error)); | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function changeUploadComposeRequest() { | ||||||
|  |   return { | ||||||
|  |     type: COMPOSE_UPLOAD_CHANGE_REQUEST, | ||||||
|  |     skipLoading: true, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  | export function changeUploadComposeSuccess(media) { | ||||||
|  |   return { | ||||||
|  |     type: COMPOSE_UPLOAD_CHANGE_SUCCESS, | ||||||
|  |     media: media, | ||||||
|  |     skipLoading: true, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function changeUploadComposeFail(error) { | ||||||
|  |   return { | ||||||
|  |     type: COMPOSE_UPLOAD_CHANGE_FAIL, | ||||||
|  |     error: error, | ||||||
|  |     skipLoading: true, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export function uploadComposeRequest() { | export function uploadComposeRequest() { | ||||||
|   return { |   return { | ||||||
|     type: COMPOSE_UPLOAD_REQUEST, |     type: COMPOSE_UPLOAD_REQUEST, | ||||||
| @@ -199,21 +256,42 @@ export function clearComposeSuggestions() { | |||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export function fetchComposeSuggestions(token) { | const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { | ||||||
|   return (dispatch, getState) => { |  | ||||||
|   api(getState).get('/api/v1/accounts/search', { |   api(getState).get('/api/v1/accounts/search', { | ||||||
|     params: { |     params: { | ||||||
|         q: token, |       q: token.slice(1), | ||||||
|       resolve: false, |       resolve: false, | ||||||
|       limit: 4, |       limit: 4, | ||||||
|     }, |     }, | ||||||
|   }).then(response => { |   }).then(response => { | ||||||
|       dispatch(readyComposeSuggestions(token, response.data)); |     dispatch(readyComposeSuggestionsAccounts(token, response.data)); | ||||||
|   }); |   }); | ||||||
|  | }, 200, { leading: true, trailing: true }); | ||||||
|  |  | ||||||
|  | const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { | ||||||
|  |   const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); | ||||||
|  |   dispatch(readyComposeSuggestionsEmojis(token, results)); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function fetchComposeSuggestions(token) { | ||||||
|  |   return (dispatch, getState) => { | ||||||
|  |     if (token[0] === ':') { | ||||||
|  |       fetchComposeSuggestionsEmojis(dispatch, getState, token); | ||||||
|  |     } else { | ||||||
|  |       fetchComposeSuggestionsAccounts(dispatch, getState, token); | ||||||
|  |     } | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export function readyComposeSuggestions(token, accounts) { | export function readyComposeSuggestionsEmojis(token, emojis) { | ||||||
|  |   return { | ||||||
|  |     type: COMPOSE_SUGGESTIONS_READY, | ||||||
|  |     token, | ||||||
|  |     emojis, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function readyComposeSuggestionsAccounts(token, accounts) { | ||||||
|   return { |   return { | ||||||
|     type: COMPOSE_SUGGESTIONS_READY, |     type: COMPOSE_SUGGESTIONS_READY, | ||||||
|     token, |     token, | ||||||
| @@ -221,13 +299,23 @@ export function readyComposeSuggestions(token, accounts) { | |||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export function selectComposeSuggestion(position, token, accountId) { | export function selectComposeSuggestion(position, token, suggestion) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     const completion = getState().getIn(['accounts', accountId, 'acct']); |     let completion, startPosition; | ||||||
|  |  | ||||||
|  |     if (typeof suggestion === 'object' && suggestion.id) { | ||||||
|  |       completion    = suggestion.native || suggestion.colons; | ||||||
|  |       startPosition = position - 1; | ||||||
|  |  | ||||||
|  |       dispatch(useEmoji(suggestion)); | ||||||
|  |     } else { | ||||||
|  |       completion    = getState().getIn(['accounts', suggestion, 'acct']); | ||||||
|  |       startPosition = position; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     dispatch({ |     dispatch({ | ||||||
|       type: COMPOSE_SUGGESTION_SELECT, |       type: COMPOSE_SUGGESTION_SELECT, | ||||||
|       position, |       position: startPosition, | ||||||
|       token, |       token, | ||||||
|       completion, |       completion, | ||||||
|     }); |     }); | ||||||
|   | |||||||
							
								
								
									
										14
									
								
								app/javascript/mastodon/actions/emojis.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,14 @@ | |||||||
|  | import { saveSettings } from './settings'; | ||||||
|  |  | ||||||
|  | export const EMOJI_USE = 'EMOJI_USE'; | ||||||
|  |  | ||||||
|  | export function useEmoji(emoji) { | ||||||
|  |   return dispatch => { | ||||||
|  |     dispatch({ | ||||||
|  |       type: EMOJI_USE, | ||||||
|  |       emoji, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     dispatch(saveSettings()); | ||||||
|  |   }; | ||||||
|  | }; | ||||||
							
								
								
									
										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, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -31,6 +31,7 @@ const fetchRelatedRelationships = (dispatch, notifications) => { | |||||||
|  |  | ||||||
| const unescapeHTML = (html) => { | const unescapeHTML = (html) => { | ||||||
|   const wrapper = document.createElement('div'); |   const wrapper = document.createElement('div'); | ||||||
|  |   html = html.replace(/<br \/>|<br>|\n/, ' '); | ||||||
|   wrapper.innerHTML = html; |   wrapper.innerHTML = html; | ||||||
|   return wrapper.textContent; |   return wrapper.textContent; | ||||||
| }; | }; | ||||||
|   | |||||||
							
								
								
									
										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, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -1,6 +1,8 @@ | |||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
|  | import { debounce } from 'lodash'; | ||||||
|  |  | ||||||
| export const SETTING_CHANGE = 'SETTING_CHANGE'; | export const SETTING_CHANGE = 'SETTING_CHANGE'; | ||||||
|  | export const SETTING_SAVE   = 'SETTING_SAVE'; | ||||||
|  |  | ||||||
| export function changeSetting(key, value) { | export function changeSetting(key, value) { | ||||||
|   return dispatch => { |   return dispatch => { | ||||||
| @@ -14,10 +16,16 @@ export function changeSetting(key, value) { | |||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const debouncedSave = debounce((dispatch, getState) => { | ||||||
|  |   if (getState().getIn(['settings', 'saved'])) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS(); | ||||||
|  |  | ||||||
|  |   axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE })); | ||||||
|  | }, 5000, { trailing: true }); | ||||||
|  |  | ||||||
| export function saveSettings() { | export function saveSettings() { | ||||||
|   return (_, getState) => { |   return (dispatch, getState) => debouncedSave(dispatch, getState); | ||||||
|     axios.put('/api/web/settings', { |  | ||||||
|       data: getState().get('settings').toJS(), |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -23,9 +23,6 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; | |||||||
| export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; | export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; | ||||||
| export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL'; | export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL'; | ||||||
|  |  | ||||||
| export const STATUS_SET_HEIGHT = 'STATUS_SET_HEIGHT'; |  | ||||||
| export const STATUSES_CLEAR_HEIGHT = 'STATUSES_CLEAR_HEIGHT'; |  | ||||||
|  |  | ||||||
| export function fetchStatusRequest(id, skipLoading) { | export function fetchStatusRequest(id, skipLoading) { | ||||||
|   return { |   return { | ||||||
|     type: STATUS_FETCH_REQUEST, |     type: STATUS_FETCH_REQUEST, | ||||||
| @@ -218,17 +215,3 @@ export function unmuteStatusFail(id, error) { | |||||||
|     error, |     error, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export function setStatusHeight (id, height) { |  | ||||||
|   return { |  | ||||||
|     type: STATUS_SET_HEIGHT, |  | ||||||
|     id, |  | ||||||
|     height, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export function clearStatusesHeight () { |  | ||||||
|   return { |  | ||||||
|     type: STATUSES_CLEAR_HEIGHT, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
|   | |||||||
| @@ -5,8 +5,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; | |||||||
|  |  | ||||||
| const convertState = rawState => | const convertState = rawState => | ||||||
|   fromJS(rawState, (k, v) => |   fromJS(rawState, (k, v) => | ||||||
|     Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x => |     Iterable.isIndexed(v) ? v.toList() : v.toMap()); | ||||||
|       Number.isNaN(x * 1) ? x : x * 1)); |  | ||||||
|  |  | ||||||
| export function hydrateStore(rawState) { | export function hydrateStore(rawState) { | ||||||
|   const state = convertState(rawState); |   const state = convertState(rawState); | ||||||
|   | |||||||
| @@ -17,6 +17,8 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; | |||||||
| export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT'; | export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT'; | ||||||
| export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; | export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; | ||||||
|  |  | ||||||
|  | export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; | ||||||
|  |  | ||||||
| export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { | export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { | ||||||
|   return { |   return { | ||||||
|     type: TIMELINE_REFRESH_SUCCESS, |     type: TIMELINE_REFRESH_SUCCESS, | ||||||
| @@ -30,6 +32,16 @@ export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { | |||||||
| export function updateTimeline(timeline, status) { | export function updateTimeline(timeline, status) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; |     const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; | ||||||
|  |     const parents = []; | ||||||
|  |  | ||||||
|  |     if (status.in_reply_to_id) { | ||||||
|  |       let parent = getState().getIn(['statuses', status.in_reply_to_id]); | ||||||
|  |  | ||||||
|  |       while (parent && parent.get('in_reply_to_id')) { | ||||||
|  |         parents.push(parent.get('id')); | ||||||
|  |         parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     dispatch({ |     dispatch({ | ||||||
|       type: TIMELINE_UPDATE, |       type: TIMELINE_UPDATE, | ||||||
| @@ -37,6 +49,14 @@ export function updateTimeline(timeline, status) { | |||||||
|       status, |       status, | ||||||
|       references, |       references, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     if (parents.length > 0) { | ||||||
|  |       dispatch({ | ||||||
|  |         type: TIMELINE_CONTEXT_UPDATE, | ||||||
|  |         status, | ||||||
|  |         references: parents, | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import 'intl'; | import 'intl'; | ||||||
| import 'intl/locale-data/jsonp/en.js'; | import 'intl/locale-data/jsonp/en'; | ||||||
| import 'es6-symbol/implement'; | import 'es6-symbol/implement'; | ||||||
| import includes from 'array-includes'; | import includes from 'array-includes'; | ||||||
| import assign from 'object-assign'; | import assign from 'object-assign'; | ||||||
|   | |||||||
| @@ -0,0 +1,33 @@ | |||||||
|  | // Jest Snapshot v1, https://goo.gl/fbAQLP | ||||||
|  |  | ||||||
|  | exports[`<Avatar /> Autoplay renders a animated avatar 1`] = ` | ||||||
|  | <div | ||||||
|  |   className="account__avatar" | ||||||
|  |   onMouseEnter={[Function]} | ||||||
|  |   onMouseLeave={[Function]} | ||||||
|  |   style={ | ||||||
|  |     Object { | ||||||
|  |       "backgroundImage": "url(/animated/alice.gif)", | ||||||
|  |       "backgroundSize": "100px 100px", | ||||||
|  |       "height": "100px", | ||||||
|  |       "width": "100px", | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | /> | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | exports[`<Avatar /> Still renders a still avatar 1`] = ` | ||||||
|  | <div | ||||||
|  |   className="account__avatar" | ||||||
|  |   onMouseEnter={[Function]} | ||||||
|  |   onMouseLeave={[Function]} | ||||||
|  |   style={ | ||||||
|  |     Object { | ||||||
|  |       "backgroundImage": "url(/static/alice.jpg)", | ||||||
|  |       "backgroundSize": "100px 100px", | ||||||
|  |       "height": "100px", | ||||||
|  |       "width": "100px", | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | /> | ||||||
|  | `; | ||||||
| @@ -0,0 +1,24 @@ | |||||||
|  | // Jest Snapshot v1, https://goo.gl/fbAQLP | ||||||
|  |  | ||||||
|  | exports[`<AvatarOverlay renders a overlay avatar 1`] = ` | ||||||
|  | <div | ||||||
|  |   className="account__avatar-overlay" | ||||||
|  | > | ||||||
|  |   <div | ||||||
|  |     className="account__avatar-overlay-base" | ||||||
|  |     style={ | ||||||
|  |       Object { | ||||||
|  |         "backgroundImage": "url(/static/alice.jpg)", | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   /> | ||||||
|  |   <div | ||||||
|  |     className="account__avatar-overlay-overlay" | ||||||
|  |     style={ | ||||||
|  |       Object { | ||||||
|  |         "backgroundImage": "url(/static/eve.jpg)", | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   /> | ||||||
|  | </div> | ||||||
|  | `; | ||||||
| @@ -0,0 +1,114 @@ | |||||||
|  | // Jest Snapshot v1, https://goo.gl/fbAQLP | ||||||
|  |  | ||||||
|  | exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = ` | ||||||
|  | <button | ||||||
|  |   className="button button-secondary" | ||||||
|  |   disabled={undefined} | ||||||
|  |   onClick={[Function]} | ||||||
|  |   style={ | ||||||
|  |     Object { | ||||||
|  |       "height": "36px", | ||||||
|  |       "lineHeight": "36px", | ||||||
|  |       "padding": "0 16px", | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | /> | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | exports[`<Button /> renders a button element 1`] = ` | ||||||
|  | <button | ||||||
|  |   className="button" | ||||||
|  |   disabled={undefined} | ||||||
|  |   onClick={[Function]} | ||||||
|  |   style={ | ||||||
|  |     Object { | ||||||
|  |       "height": "36px", | ||||||
|  |       "lineHeight": "36px", | ||||||
|  |       "padding": "0 16px", | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | /> | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = ` | ||||||
|  | <button | ||||||
|  |   className="button" | ||||||
|  |   disabled={true} | ||||||
|  |   onClick={[Function]} | ||||||
|  |   style={ | ||||||
|  |     Object { | ||||||
|  |       "height": "36px", | ||||||
|  |       "lineHeight": "36px", | ||||||
|  |       "padding": "0 16px", | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | /> | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | exports[`<Button /> renders class="button--block" if props.block given 1`] = ` | ||||||
|  | <button | ||||||
|  |   className="button button--block" | ||||||
|  |   disabled={undefined} | ||||||
|  |   onClick={[Function]} | ||||||
|  |   style={ | ||||||
|  |     Object { | ||||||
|  |       "height": "36px", | ||||||
|  |       "lineHeight": "36px", | ||||||
|  |       "padding": "0 16px", | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | /> | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | exports[`<Button /> renders the children 1`] = ` | ||||||
|  | <button | ||||||
|  |   className="button" | ||||||
|  |   disabled={undefined} | ||||||
|  |   onClick={[Function]} | ||||||
|  |   style={ | ||||||
|  |     Object { | ||||||
|  |       "height": "36px", | ||||||
|  |       "lineHeight": "36px", | ||||||
|  |       "padding": "0 16px", | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | > | ||||||
|  |   <p> | ||||||
|  |     children | ||||||
|  |   </p> | ||||||
|  | </button> | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | exports[`<Button /> renders the given text 1`] = ` | ||||||
|  | <button | ||||||
|  |   className="button" | ||||||
|  |   disabled={undefined} | ||||||
|  |   onClick={[Function]} | ||||||
|  |   style={ | ||||||
|  |     Object { | ||||||
|  |       "height": "36px", | ||||||
|  |       "lineHeight": "36px", | ||||||
|  |       "padding": "0 16px", | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | > | ||||||
|  |   foo | ||||||
|  | </button> | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | exports[`<Button /> renders the props.text instead of children 1`] = ` | ||||||
|  | <button | ||||||
|  |   className="button" | ||||||
|  |   disabled={undefined} | ||||||
|  |   onClick={[Function]} | ||||||
|  |   style={ | ||||||
|  |     Object { | ||||||
|  |       "height": "36px", | ||||||
|  |       "lineHeight": "36px", | ||||||
|  |       "padding": "0 16px", | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | > | ||||||
|  |   foo | ||||||
|  | </button> | ||||||
|  | `; | ||||||
| @@ -0,0 +1,23 @@ | |||||||
|  | // Jest Snapshot v1, https://goo.gl/fbAQLP | ||||||
|  |  | ||||||
|  | exports[`<DisplayName /> renders display name + account name 1`] = ` | ||||||
|  | <span | ||||||
|  |   className="display-name" | ||||||
|  | > | ||||||
|  |   <strong | ||||||
|  |     className="display-name__html" | ||||||
|  |     dangerouslySetInnerHTML={ | ||||||
|  |       Object { | ||||||
|  |         "__html": "<p>Foo</p>", | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   /> | ||||||
|  |     | ||||||
|  |   <span | ||||||
|  |     className="display-name__account" | ||||||
|  |   > | ||||||
|  |     @ | ||||||
|  |     bar@baz | ||||||
|  |   </span> | ||||||
|  | </span> | ||||||
|  | `; | ||||||
							
								
								
									
										36
									
								
								app/javascript/mastodon/components/__tests__/avatar-test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,36 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import renderer from 'react-test-renderer'; | ||||||
|  | import { fromJS } from 'immutable'; | ||||||
|  | import Avatar from '../avatar'; | ||||||
|  |  | ||||||
|  | describe('<Avatar />', () => { | ||||||
|  |   const account = fromJS({ | ||||||
|  |     username: 'alice', | ||||||
|  |     acct: 'alice', | ||||||
|  |     display_name: 'Alice', | ||||||
|  |     avatar: '/animated/alice.gif', | ||||||
|  |     avatar_static: '/static/alice.jpg', | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const size     = 100; | ||||||
|  |  | ||||||
|  |   describe('Autoplay', () => { | ||||||
|  |     it('renders a animated avatar', () => { | ||||||
|  |       const component = renderer.create(<Avatar account={account} animate size={size} />); | ||||||
|  |       const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |       expect(tree).toMatchSnapshot(); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('Still', () => { | ||||||
|  |     it('renders a still avatar', () => { | ||||||
|  |       const component = renderer.create(<Avatar account={account} size={size} />); | ||||||
|  |       const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |       expect(tree).toMatchSnapshot(); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   // TODO add autoplay test if possible | ||||||
|  | }); | ||||||
| @@ -0,0 +1,29 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import renderer from 'react-test-renderer'; | ||||||
|  | import { fromJS } from 'immutable'; | ||||||
|  | import AvatarOverlay from '../avatar_overlay'; | ||||||
|  |  | ||||||
|  | describe('<AvatarOverlay', () => { | ||||||
|  |   const account = fromJS({ | ||||||
|  |     username: 'alice', | ||||||
|  |     acct: 'alice', | ||||||
|  |     display_name: 'Alice', | ||||||
|  |     avatar: '/animated/alice.gif', | ||||||
|  |     avatar_static: '/static/alice.jpg', | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const friend = fromJS({ | ||||||
|  |     username: 'eve', | ||||||
|  |     acct: 'eve@blackhat.lair', | ||||||
|  |     display_name: 'Evelyn', | ||||||
|  |     avatar: '/animated/eve.gif', | ||||||
|  |     avatar_static: '/static/eve.jpg', | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('renders a overlay avatar', () => { | ||||||
|  |     const component = renderer.create(<AvatarOverlay account={account} friend={friend} />); | ||||||
|  |     const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |     expect(tree).toMatchSnapshot(); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										75
									
								
								app/javascript/mastodon/components/__tests__/button-test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,75 @@ | |||||||
|  | import { shallow } from 'enzyme'; | ||||||
|  | import React from 'react'; | ||||||
|  | import renderer from 'react-test-renderer'; | ||||||
|  | import Button from '../button'; | ||||||
|  |  | ||||||
|  | describe('<Button />', () => { | ||||||
|  |   it('renders a button element', () => { | ||||||
|  |     const component = renderer.create(<Button />); | ||||||
|  |     const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |     expect(tree).toMatchSnapshot(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('renders the given text', () => { | ||||||
|  |     const text      = 'foo'; | ||||||
|  |     const component = renderer.create(<Button text={text} />); | ||||||
|  |     const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |     expect(tree).toMatchSnapshot(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('handles click events using the given handler', () => { | ||||||
|  |     const handler = jest.fn(); | ||||||
|  |     const button  = shallow(<Button onClick={handler} />); | ||||||
|  |     button.find('button').simulate('click'); | ||||||
|  |  | ||||||
|  |     expect(handler.mock.calls.length).toEqual(1); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('does not handle click events if props.disabled given', () => { | ||||||
|  |     const handler = jest.fn(); | ||||||
|  |     const button  = shallow(<Button onClick={handler} disabled />); | ||||||
|  |     button.find('button').simulate('click'); | ||||||
|  |  | ||||||
|  |     expect(handler.mock.calls.length).toEqual(0); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('renders a disabled attribute if props.disabled given', () => { | ||||||
|  |     const component = renderer.create(<Button disabled />); | ||||||
|  |     const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |     expect(tree).toMatchSnapshot(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('renders the children', () => { | ||||||
|  |     const children  = <p>children</p>; | ||||||
|  |     const component = renderer.create(<Button>{children}</Button>); | ||||||
|  |     const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |     expect(tree).toMatchSnapshot(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('renders the props.text instead of children', () => { | ||||||
|  |     const text      = 'foo'; | ||||||
|  |     const children  = <p>children</p>; | ||||||
|  |     const component = renderer.create(<Button text={text}>{children}</Button>); | ||||||
|  |     const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |     expect(tree).toMatchSnapshot(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('renders class="button--block" if props.block given', () => { | ||||||
|  |     const component = renderer.create(<Button block />); | ||||||
|  |     const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |     expect(tree).toMatchSnapshot(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('adds class "button-secondary" if props.secondary given', () => { | ||||||
|  |     const component = renderer.create(<Button secondary />); | ||||||
|  |     const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |     expect(tree).toMatchSnapshot(); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -0,0 +1,18 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import renderer from 'react-test-renderer'; | ||||||
|  | import { fromJS }  from 'immutable'; | ||||||
|  | import DisplayName from '../display_name'; | ||||||
|  |  | ||||||
|  | describe('<DisplayName />', () => { | ||||||
|  |   it('renders display name + account name', () => { | ||||||
|  |     const account = fromJS({ | ||||||
|  |       username: 'bar', | ||||||
|  |       acct: 'bar@baz', | ||||||
|  |       display_name_html: '<p>Foo</p>', | ||||||
|  |     }); | ||||||
|  |     const component = renderer.create(<DisplayName account={account} />); | ||||||
|  |     const tree      = component.toJSON(); | ||||||
|  |  | ||||||
|  |     expect(tree).toMatchSnapshot(); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -21,7 +21,7 @@ export default class Account extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     account: ImmutablePropTypes.map.isRequired, |     account: ImmutablePropTypes.map.isRequired, | ||||||
|     me: PropTypes.number.isRequired, |     me: PropTypes.string.isRequired, | ||||||
|     onFollow: PropTypes.func.isRequired, |     onFollow: PropTypes.func.isRequired, | ||||||
|     onBlock: PropTypes.func.isRequired, |     onBlock: PropTypes.func.isRequired, | ||||||
|     onMute: PropTypes.func.isRequired, |     onMute: PropTypes.func.isRequired, | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								app/javascript/mastodon/components/autosuggest_emoji.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,42 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; | ||||||
|  |  | ||||||
|  | const assetHost = process.env.CDN_HOST || ''; | ||||||
|  |  | ||||||
|  | export default class AutosuggestEmoji extends React.PureComponent { | ||||||
|  |  | ||||||
|  |   static propTypes = { | ||||||
|  |     emoji: PropTypes.object.isRequired, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     const { emoji } = this.props; | ||||||
|  |     let url; | ||||||
|  |  | ||||||
|  |     if (emoji.custom) { | ||||||
|  |       url = emoji.imageUrl; | ||||||
|  |     } else { | ||||||
|  |       const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; | ||||||
|  |  | ||||||
|  |       if (!mapping) { | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       url = `${assetHost}/emoji/${mapping.filename}.svg`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <div className='autosuggest-emoji'> | ||||||
|  |         <img | ||||||
|  |           className='emojione' | ||||||
|  |           src={url} | ||||||
|  |           alt={emoji.native || emoji.colons} | ||||||
|  |         /> | ||||||
|  |  | ||||||
|  |         {emoji.colons} | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -1,10 +1,12 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; | import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; | ||||||
|  | import AutosuggestEmoji from './autosuggest_emoji'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { isRtl } from '../rtl'; | import { isRtl } from '../rtl'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import Textarea from 'react-textarea-autosize'; | import Textarea from 'react-textarea-autosize'; | ||||||
|  | import classNames from 'classnames'; | ||||||
|  |  | ||||||
| const textAtCursorMatchesToken = (str, caretPosition) => { | const textAtCursorMatchesToken = (str, caretPosition) => { | ||||||
|   let word; |   let word; | ||||||
| @@ -18,11 +20,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => { | |||||||
|     word = str.slice(left, right + caretPosition); |     word = str.slice(left, right + caretPosition); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (!word || word.trim().length < 2 || word[0] !== '@') { |   if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) { | ||||||
|     return [null, null]; |     return [null, null]; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   word = word.trim().toLowerCase().slice(1); |   word = word.trim().toLowerCase(); | ||||||
|  |  | ||||||
|   if (word.length > 0) { |   if (word.length > 0) { | ||||||
|     return [left + 1, word]; |     return [left + 1, word]; | ||||||
| @@ -123,12 +125,22 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | |||||||
|     this.props.onKeyDown(e); |     this.props.onKeyDown(e); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   onKeyUp = e => { | ||||||
|  |     if (e.key === 'Escape' && this.state.suggestionsHidden) { | ||||||
|  |       document.querySelector('.ui').parentElement.focus(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (this.props.onKeyUp) { | ||||||
|  |       this.props.onKeyUp(e); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   onBlur = () => { |   onBlur = () => { | ||||||
|     this.setState({ suggestionsHidden: true }); |     this.setState({ suggestionsHidden: true }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   onSuggestionClick = (e) => { |   onSuggestionClick = (e) => { | ||||||
|     const suggestion = Number(e.currentTarget.getAttribute('data-index')); |     const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); |     this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); | ||||||
|     this.textarea.focus(); |     this.textarea.focus(); | ||||||
| @@ -151,9 +163,28 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   renderSuggestion = (suggestion, i) => { | ||||||
|  |     const { selectedSuggestion } = this.state; | ||||||
|  |     let inner, key; | ||||||
|  |  | ||||||
|  |     if (typeof suggestion === 'object') { | ||||||
|  |       inner = <AutosuggestEmoji emoji={suggestion} />; | ||||||
|  |       key   = suggestion.id; | ||||||
|  |     } else { | ||||||
|  |       inner = <AutosuggestAccountContainer id={suggestion} />; | ||||||
|  |       key   = suggestion; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> | ||||||
|  |         {inner} | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; |     const { value, suggestions, disabled, placeholder, autoFocus } = this.props; | ||||||
|     const { suggestionsHidden, selectedSuggestion } = this.state; |     const { suggestionsHidden } = this.state; | ||||||
|     const style = { direction: 'ltr' }; |     const style = { direction: 'ltr' }; | ||||||
|  |  | ||||||
|     if (isRtl(value)) { |     if (isRtl(value)) { | ||||||
| @@ -164,6 +195,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | |||||||
|       <div className='autosuggest-textarea'> |       <div className='autosuggest-textarea'> | ||||||
|         <label> |         <label> | ||||||
|           <span style={{ display: 'none' }}>{placeholder}</span> |           <span style={{ display: 'none' }}>{placeholder}</span> | ||||||
|  |  | ||||||
|           <Textarea |           <Textarea | ||||||
|             inputRef={this.setTextarea} |             inputRef={this.setTextarea} | ||||||
|             className='autosuggest-textarea__textarea' |             className='autosuggest-textarea__textarea' | ||||||
| @@ -173,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | |||||||
|             value={value} |             value={value} | ||||||
|             onChange={this.onChange} |             onChange={this.onChange} | ||||||
|             onKeyDown={this.onKeyDown} |             onKeyDown={this.onKeyDown} | ||||||
|             onKeyUp={onKeyUp} |             onKeyUp={this.onKeyUp} | ||||||
|             onBlur={this.onBlur} |             onBlur={this.onBlur} | ||||||
|             onPaste={this.onPaste} |             onPaste={this.onPaste} | ||||||
|             style={style} |             style={style} | ||||||
| @@ -181,18 +213,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | |||||||
|         </label> |         </label> | ||||||
|  |  | ||||||
|         <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> |         <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> | ||||||
|           {suggestions.map((suggestion, i) => ( |           {suggestions.map(this.renderSuggestion)} | ||||||
|             <div |  | ||||||
|               role='button' |  | ||||||
|               tabIndex='0' |  | ||||||
|               key={suggestion} |  | ||||||
|               data-index={suggestion} |  | ||||||
|               className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} |  | ||||||
|               onMouseDown={this.onSuggestionClick} |  | ||||||
|             > |  | ||||||
|               <AutosuggestAccountContainer id={suggestion} /> |  | ||||||
|             </div> |  | ||||||
|           ))} |  | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import Motion from 'react-motion/lib/Motion'; | import Motion from '../features/ui/util/optional_motion'; | ||||||
| import spring from 'react-motion/lib/spring'; | import spring from 'react-motion/lib/spring'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -135,7 +135,7 @@ export default class ColumnHeader extends React.PureComponent { | |||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className={wrapperClassName}> |       <div className={wrapperClassName}> | ||||||
|         <h1 tabIndex={focusable && '0'} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> |         <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> | ||||||
|           <i className={`fa fa-fw fa-${icon} column-header__icon`} /> |           <i className={`fa fa-fw fa-${icon} column-header__icon`} /> | ||||||
|           {title} |           {title} | ||||||
|  |  | ||||||
| @@ -145,7 +145,7 @@ export default class ColumnHeader extends React.PureComponent { | |||||||
|           </div> |           </div> | ||||||
|         </h1> |         </h1> | ||||||
|  |  | ||||||
|         <div className={collapsibleClassName} tabIndex={collapsed && -1} onTransitionEnd={this.handleTransitionEnd}> |         <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> | ||||||
|           <div className='column-header__collapsible-inner'> |           <div className='column-header__collapsible-inner'> | ||||||
|             {(!collapsed || animating) && collapsedContent} |             {(!collapsed || animating) && collapsedContent} | ||||||
|           </div> |           </div> | ||||||
|   | |||||||
| @@ -1,53 +1,59 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; |  | ||||||
| import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; |  | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
|  | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
|  | import IconButton from './icon_button'; | ||||||
|  | import Overlay from 'react-overlays/lib/Overlay'; | ||||||
|  | import Motion from '../features/ui/util/optional_motion'; | ||||||
|  | import spring from 'react-motion/lib/spring'; | ||||||
|  | import detectPassiveEvents from 'detect-passive-events'; | ||||||
|  |  | ||||||
| export default class DropdownMenu extends React.PureComponent { | const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; | ||||||
|  |  | ||||||
|  | class DropdownMenu extends React.PureComponent { | ||||||
|  |  | ||||||
|   static contextTypes = { |   static contextTypes = { | ||||||
|     router: PropTypes.object, |     router: PropTypes.object, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     isUserTouching: PropTypes.func, |  | ||||||
|     isModalOpen: PropTypes.bool.isRequired, |  | ||||||
|     onModalOpen: PropTypes.func, |  | ||||||
|     onModalClose: PropTypes.func, |  | ||||||
|     icon: PropTypes.string.isRequired, |  | ||||||
|     items: PropTypes.array.isRequired, |     items: PropTypes.array.isRequired, | ||||||
|     size: PropTypes.number.isRequired, |     onClose: PropTypes.func.isRequired, | ||||||
|     direction: PropTypes.string, |     style: PropTypes.object, | ||||||
|     status: ImmutablePropTypes.map, |     placement: PropTypes.string, | ||||||
|     ariaLabel: PropTypes.string, |     arrowOffsetLeft: PropTypes.string, | ||||||
|     disabled: PropTypes.bool, |     arrowOffsetTop: PropTypes.string, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   static defaultProps = { |   static defaultProps = { | ||||||
|     ariaLabel: 'Menu', |     style: {}, | ||||||
|     isModalOpen: false, |     placement: 'bottom', | ||||||
|     isUserTouching: () => false, |  | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   state = { |   handleDocumentClick = e => { | ||||||
|     direction: 'left', |     if (this.node && !this.node.contains(e.target)) { | ||||||
|     expanded: false, |       this.props.onClose(); | ||||||
|   }; |     } | ||||||
|  |  | ||||||
|   setRef = (c) => { |  | ||||||
|     this.dropdown = c; |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   handleClick = (e) => { |   componentDidMount () { | ||||||
|  |     document.addEventListener('click', this.handleDocumentClick, false); | ||||||
|  |     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     document.removeEventListener('click', this.handleDocumentClick, false); | ||||||
|  |     document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setRef = c => { | ||||||
|  |     this.node = c; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleClick = e => { | ||||||
|     const i = Number(e.currentTarget.getAttribute('data-index')); |     const i = Number(e.currentTarget.getAttribute('data-index')); | ||||||
|     const { action, to } = this.props.items[i]; |     const { action, to } = this.props.items[i]; | ||||||
|  |  | ||||||
|     if (this.props.isModalOpen) { |     this.props.onClose(); | ||||||
|       this.props.onModalClose(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Don't call e.preventDefault() when the item uses 'href' property. |  | ||||||
|     // ex. "Edit profile" on the account action bar |  | ||||||
|  |  | ||||||
|     if (typeof action === 'function') { |     if (typeof action === 'function') { | ||||||
|       e.preventDefault(); |       e.preventDefault(); | ||||||
| @@ -56,46 +62,18 @@ export default class DropdownMenu extends React.PureComponent { | |||||||
|       e.preventDefault(); |       e.preventDefault(); | ||||||
|       this.context.router.history.push(to); |       this.context.router.history.push(to); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     this.dropdown.hide(); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   handleShow = () => { |   renderItem (option, i) { | ||||||
|     if (this.props.isUserTouching()) { |     if (option === null) { | ||||||
|       this.props.onModalOpen({ |       return <li key={`sep-${i}`} className='dropdown-menu__separator' />; | ||||||
|         status: this.props.status, |  | ||||||
|         actions: this.props.items, |  | ||||||
|         onClick: this.handleClick, |  | ||||||
|       }); |  | ||||||
|     } else { |  | ||||||
|       this.setState({ expanded: true }); |  | ||||||
|     } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   handleHide = () => this.setState({ expanded: false }) |     const { text, href = '#' } = option; | ||||||
|  |  | ||||||
|   handleToggle = (e) => { |  | ||||||
|     if (e.key === 'Enter') { |  | ||||||
|       if (this.props.isUserTouching()) { |  | ||||||
|         this.handleShow(); |  | ||||||
|       } else { |  | ||||||
|         this.setState({ expanded: !this.state.expanded }); |  | ||||||
|       } |  | ||||||
|     } else if (e.key === 'Escape') { |  | ||||||
|       this.setState({ expanded: false }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   renderItem = (item, i) => { |  | ||||||
|     if (item === null) { |  | ||||||
|       return <li key={`sep-${i}`} className='dropdown__sep' />; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     const { text, href = '#' } = item; |  | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <li className='dropdown__content-list-item' key={`${text}-${i}`}> |       <li className='dropdown-menu__item' key={`${text}-${i}`}> | ||||||
|         <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i} className='dropdown__content-list-link'> |         <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}> | ||||||
|           {text} |           {text} | ||||||
|         </a> |         </a> | ||||||
|       </li> |       </li> | ||||||
| @@ -103,43 +81,130 @@ export default class DropdownMenu extends React.PureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { icon, items, size, direction, ariaLabel, disabled } = this.props; |     const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; | ||||||
|     const { expanded }   = this.state; |  | ||||||
|     const isUserTouching = this.props.isUserTouching(); |  | ||||||
|     const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right'; |  | ||||||
|     const iconStyle      = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }; |  | ||||||
|     const iconClassname  = `fa fa-fw fa-${icon} dropdown__icon`; |  | ||||||
|  |  | ||||||
|     if (disabled) { |  | ||||||
|     return ( |     return ( | ||||||
|         <div className='icon-button disabled' style={iconStyle} aria-label={ariaLabel}> |       <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> | ||||||
|           <i className={iconClassname} aria-hidden /> |         {({ opacity, scaleX, scaleY }) => ( | ||||||
|         </div> |           <div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> | ||||||
|       ); |             <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> | ||||||
|     } |  | ||||||
|  |  | ||||||
|     const dropdownItems = expanded && ( |             <ul> | ||||||
|       <ul role='group' className='dropdown__content-list' onClick={this.handleHide}> |               {items.map((option, i) => this.renderItem(option, i))} | ||||||
|         {items.map(this.renderItem)} |  | ||||||
|             </ul> |             </ul> | ||||||
|     ); |           </div> | ||||||
|  |         )} | ||||||
|     // No need to render the actual dropdown if we use the modal. If we |       </Motion> | ||||||
|     // don't render anything <Dropdow /> breaks, so we just put an empty div. |     ); | ||||||
|     const dropdownContent = !isUserTouching ? ( |   } | ||||||
|       <DropdownContent className={directionClass} > |  | ||||||
|         {dropdownItems} | } | ||||||
|       </DropdownContent> |  | ||||||
|     ) : <div />; | export default class Dropdown extends React.PureComponent { | ||||||
|  |  | ||||||
|     return ( |   static contextTypes = { | ||||||
|       <Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}> |     router: PropTypes.object, | ||||||
|         <DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-expanded={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}> |   }; | ||||||
|           <i className={iconClassname} aria-hidden /> |  | ||||||
|         </DropdownTrigger> |   static propTypes = { | ||||||
|  |     icon: PropTypes.string.isRequired, | ||||||
|         {dropdownContent} |     items: PropTypes.array.isRequired, | ||||||
|       </Dropdown> |     size: PropTypes.number.isRequired, | ||||||
|  |     ariaLabel: PropTypes.string, | ||||||
|  |     disabled: PropTypes.bool, | ||||||
|  |     status: ImmutablePropTypes.map, | ||||||
|  |     isUserTouching: PropTypes.func, | ||||||
|  |     isModalOpen: PropTypes.bool.isRequired, | ||||||
|  |     onModalOpen: PropTypes.func, | ||||||
|  |     onModalClose: PropTypes.func, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   static defaultProps = { | ||||||
|  |     ariaLabel: 'Menu', | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   state = { | ||||||
|  |     expanded: false, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   handleClick = () => { | ||||||
|  |     if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) { | ||||||
|  |       const { status, items } = this.props; | ||||||
|  |  | ||||||
|  |       this.props.onModalOpen({ | ||||||
|  |         status, | ||||||
|  |         actions: items, | ||||||
|  |         onClick: this.handleItemClick, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this.setState({ expanded: !this.state.expanded }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleClose = () => { | ||||||
|  |     if (this.props.onModalClose) { | ||||||
|  |       this.props.onModalClose(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this.setState({ expanded: false }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleKeyDown = e => { | ||||||
|  |     switch(e.key) { | ||||||
|  |     case 'Enter': | ||||||
|  |       this.handleClick(); | ||||||
|  |       break; | ||||||
|  |     case 'Escape': | ||||||
|  |       this.handleClose(); | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleItemClick = e => { | ||||||
|  |     const i = Number(e.currentTarget.getAttribute('data-index')); | ||||||
|  |     const { action, to } = this.props.items[i]; | ||||||
|  |  | ||||||
|  |     this.handleClose(); | ||||||
|  |  | ||||||
|  |     if (typeof action === 'function') { | ||||||
|  |       e.preventDefault(); | ||||||
|  |       action(); | ||||||
|  |     } else if (to) { | ||||||
|  |       e.preventDefault(); | ||||||
|  |       this.context.router.history.push(to); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setTargetRef = c => { | ||||||
|  |     this.target = c; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   findTarget = () => { | ||||||
|  |     return this.target; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     const { icon, items, size, ariaLabel, disabled } = this.props; | ||||||
|  |     const { expanded } = this.state; | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <div onKeyDown={this.handleKeyDown}> | ||||||
|  |         <IconButton | ||||||
|  |           icon={icon} | ||||||
|  |           title={ariaLabel} | ||||||
|  |           active={expanded} | ||||||
|  |           disabled={disabled} | ||||||
|  |           size={size} | ||||||
|  |           ref={this.setTargetRef} | ||||||
|  |           onClick={this.handleClick} | ||||||
|  |         /> | ||||||
|  |  | ||||||
|  |         <Overlay show={expanded} placement='bottom' target={this.findTarget}> | ||||||
|  |           <DropdownMenu items={items} onClose={this.handleClose} /> | ||||||
|  |         </Overlay> | ||||||
|  |       </div> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { | |||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     src: PropTypes.string.isRequired, |     src: PropTypes.string.isRequired, | ||||||
|  |     alt: PropTypes.string, | ||||||
|     width: PropTypes.number, |     width: PropTypes.number, | ||||||
|     height: PropTypes.number, |     height: PropTypes.number, | ||||||
|     time: PropTypes.number, |     time: PropTypes.number, | ||||||
| @@ -31,15 +32,20 @@ export default class ExtendedVideoPlayer extends React.PureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|  |     const { src, muted, controls, alt } = this.props; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className='extended-video-player'> |       <div className='extended-video-player'> | ||||||
|         <video |         <video | ||||||
|           ref={this.setRef} |           ref={this.setRef} | ||||||
|           src={this.props.src} |           src={src} | ||||||
|           autoPlay |           autoPlay | ||||||
|           muted={this.props.muted} |           role='button' | ||||||
|           controls={this.props.controls} |           tabIndex='0' | ||||||
|           loop={!this.props.controls} |           aria-label={alt} | ||||||
|  |           muted={muted} | ||||||
|  |           controls={controls} | ||||||
|  |           loop={!controls} | ||||||
|         /> |         /> | ||||||
|       </div> |       </div> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import Motion from 'react-motion/lib/Motion'; | import Motion from '../features/ui/util/optional_motion'; | ||||||
| import spring from 'react-motion/lib/spring'; | import spring from 'react-motion/lib/spring'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
|  | import classNames from 'classnames'; | ||||||
|  |  | ||||||
| export default class IconButton extends React.PureComponent { | export default class IconButton extends React.PureComponent { | ||||||
|  |  | ||||||
| @@ -50,42 +51,41 @@ export default class IconButton extends React.PureComponent { | |||||||
|       ...(this.props.active ? this.props.activeStyle : {}), |       ...(this.props.active ? this.props.activeStyle : {}), | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     const classes = ['icon-button']; |     const { | ||||||
|  |       active, | ||||||
|  |       animate, | ||||||
|  |       className, | ||||||
|  |       disabled, | ||||||
|  |       expanded, | ||||||
|  |       icon, | ||||||
|  |       inverted, | ||||||
|  |       overlay, | ||||||
|  |       pressed, | ||||||
|  |       tabIndex, | ||||||
|  |       title, | ||||||
|  |     } = this.props; | ||||||
|  |  | ||||||
|     if (this.props.active) { |     const classes = classNames(className, 'icon-button', { | ||||||
|       classes.push('active'); |       active, | ||||||
|     } |       disabled, | ||||||
|  |       inverted, | ||||||
|     if (this.props.disabled) { |       overlayed: overlay, | ||||||
|       classes.push('disabled'); |     }); | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (this.props.inverted) { |  | ||||||
|       classes.push('inverted'); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (this.props.overlay) { |  | ||||||
|       classes.push('overlayed'); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (this.props.className) { |  | ||||||
|       classes.push(this.props.className); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> |       <Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> | ||||||
|         {({ rotate }) => |         {({ rotate }) => | ||||||
|           <button |           <button | ||||||
|             aria-label={this.props.title} |             aria-label={title} | ||||||
|             aria-pressed={this.props.pressed} |             aria-pressed={pressed} | ||||||
|             aria-expanded={this.props.expanded} |             aria-expanded={expanded} | ||||||
|             title={this.props.title} |             title={title} | ||||||
|             className={classes.join(' ')} |             className={classes} | ||||||
|             onClick={this.handleClick} |             onClick={this.handleClick} | ||||||
|             style={style} |             style={style} | ||||||
|             tabIndex={this.props.tabIndex} |             tabIndex={tabIndex} | ||||||
|           > |           > | ||||||
|             <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> |             <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> | ||||||
|           </button> |           </button> | ||||||
|         } |         } | ||||||
|       </Motion> |       </Motion> | ||||||
|   | |||||||
| @@ -1,16 +1,24 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; |  | ||||||
| import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; | import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; | ||||||
| import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; | import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; | ||||||
|  | import { is } from 'immutable'; | ||||||
|  |  | ||||||
| export default class IntersectionObserverArticle extends ImmutablePureComponent { | // Diff these props in the "rendered" state | ||||||
|  | const updateOnPropsForRendered = ['id', 'index', 'listLength']; | ||||||
|  | // Diff these props in the "unrendered" state | ||||||
|  | const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; | ||||||
|  |  | ||||||
|  | export default class IntersectionObserverArticle extends React.Component { | ||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     intersectionObserverWrapper: PropTypes.object, |     intersectionObserverWrapper: PropTypes.object.isRequired, | ||||||
|     id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |     id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||||||
|     index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |     index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||||||
|     listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |     listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||||||
|  |     saveHeightKey: PropTypes.string, | ||||||
|  |     cachedHeight: PropTypes.number, | ||||||
|  |     onHeightChange: PropTypes.func, | ||||||
|     children: PropTypes.node, |     children: PropTypes.node, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
| @@ -19,28 +27,22 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   shouldComponentUpdate (nextProps, nextState) { |   shouldComponentUpdate (nextProps, nextState) { | ||||||
|     if (!nextState.isIntersecting && nextState.isHidden) { |     const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight); | ||||||
|       // It's only if we're not intersecting (i.e. offscreen) and isHidden is true |     const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight); | ||||||
|       // that either "isIntersecting" or "isHidden" matter, and then they're |     if (!!isUnrendered !== !!willBeUnrendered) { | ||||||
|       // the only things that matter (and updated ARIA attributes). |       // If we're going from rendered to unrendered (or vice versa) then update | ||||||
|       return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; |  | ||||||
|     } else if (nextState.isIntersecting && !this.state.isIntersecting) { |  | ||||||
|       // If we're going from a non-intersecting state to an intersecting state, |  | ||||||
|       // (i.e. offscreen to onscreen), then we definitely need to re-render |  | ||||||
|       return true; |       return true; | ||||||
|     } |     } | ||||||
|     // Otherwise, diff based on "updateOnProps" and "updateOnStates" |     // Otherwise, diff based on props | ||||||
|     return super.shouldComponentUpdate(nextProps, nextState); |     const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered; | ||||||
|  |     return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop])); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     if (!this.props.intersectionObserverWrapper) { |     const { intersectionObserverWrapper, id } = this.props; | ||||||
|       // TODO: enable IntersectionObserver optimization for notification statuses. |  | ||||||
|       // These are managed in notifications/index.js rather than status_list.js |     intersectionObserverWrapper.observe( | ||||||
|       return; |       id, | ||||||
|     } |  | ||||||
|     this.props.intersectionObserverWrapper.observe( |  | ||||||
|       this.props.id, |  | ||||||
|       this.node, |       this.node, | ||||||
|       this.handleIntersection |       this.handleIntersection | ||||||
|     ); |     ); | ||||||
| @@ -49,32 +51,38 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|     if (this.props.intersectionObserverWrapper) { |     const { intersectionObserverWrapper, id } = this.props; | ||||||
|       this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); |     intersectionObserverWrapper.unobserve(id, this.node); | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this.componentMounted = false; |     this.componentMounted = false; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   handleIntersection = (entry) => { |   handleIntersection = (entry) => { | ||||||
|     if (this.node && this.node.children.length !== 0) { |     this.entry = entry; | ||||||
|       // save the height of the fully-rendered element |  | ||||||
|       this.height = getRectFromEntry(entry).height; |  | ||||||
|  |  | ||||||
|       if (this.props.onHeightChange) { |     scheduleIdleTask(this.calculateHeight); | ||||||
|         this.props.onHeightChange(this.props.status, this.height); |     this.setState(this.updateStateAfterIntersection); | ||||||
|       } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|     this.setState((prevState) => { |   updateStateAfterIntersection = (prevState) => { | ||||||
|       if (prevState.isIntersecting && !entry.isIntersecting) { |     if (prevState.isIntersecting && !this.entry.isIntersecting) { | ||||||
|       scheduleIdleTask(this.hideIfNotIntersecting); |       scheduleIdleTask(this.hideIfNotIntersecting); | ||||||
|     } |     } | ||||||
|     return { |     return { | ||||||
|         isIntersecting: entry.isIntersecting, |       isIntersecting: this.entry.isIntersecting, | ||||||
|       isHidden: false, |       isHidden: false, | ||||||
|     }; |     }; | ||||||
|     }); |   } | ||||||
|  |  | ||||||
|  |   calculateHeight = () => { | ||||||
|  |     const { onHeightChange, saveHeightKey, id } = this.props; | ||||||
|  |     // save the height of the fully-rendered element (this is expensive | ||||||
|  |     // on Chrome, where we need to fall back to getBoundingClientRect) | ||||||
|  |     this.height = getRectFromEntry(this.entry).height; | ||||||
|  |  | ||||||
|  |     if (onHeightChange && saveHeightKey) { | ||||||
|  |       onHeightChange(saveHeightKey, id, this.height); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   hideIfNotIntersecting = () => { |   hideIfNotIntersecting = () => { | ||||||
| @@ -94,16 +102,16 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { children, id, index, listLength } = this.props; |     const { children, id, index, listLength, cachedHeight } = this.props; | ||||||
|     const { isIntersecting, isHidden } = this.state; |     const { isIntersecting, isHidden } = this.state; | ||||||
|  |  | ||||||
|     if (!isIntersecting && isHidden) { |     if (!isIntersecting && (isHidden || cachedHeight)) { | ||||||
|       return ( |       return ( | ||||||
|         <article |         <article | ||||||
|           ref={this.handleRef} |           ref={this.handleRef} | ||||||
|           aria-posinset={index} |           aria-posinset={index} | ||||||
|           aria-setsize={listLength} |           aria-setsize={listLength} | ||||||
|           style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }} |           style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }} | ||||||
|           data-id={id} |           data-id={id} | ||||||
|           tabIndex='0' |           tabIndex='0' | ||||||
|         > |         > | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ export default class LoadMore extends React.PureComponent { | |||||||
|     const { visible } = this.props; |     const { visible } = this.props; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <button className='load-more' disabled={!visible} style={{ opacity: visible ? 1 : 0 }} onClick={this.props.onClick}> |       <button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}> | ||||||
|         <FormattedMessage id='status.load_more' defaultMessage='Load more' /> |         <FormattedMessage id='status.load_more' defaultMessage='Load more' /> | ||||||
|       </button> |       </button> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -1,9 +1,11 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
|  | import { is } from 'immutable'; | ||||||
| import IconButton from './icon_button'; | import IconButton from './icon_button'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import { isIOS } from '../is_mobile'; | import { isIOS } from '../is_mobile'; | ||||||
|  | import classNames from 'classnames'; | ||||||
|  |  | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, |   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, | ||||||
| @@ -17,6 +19,7 @@ class Item extends React.PureComponent { | |||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     attachment: ImmutablePropTypes.map.isRequired, |     attachment: ImmutablePropTypes.map.isRequired, | ||||||
|  |     standalone: PropTypes.bool, | ||||||
|     index: PropTypes.number.isRequired, |     index: PropTypes.number.isRequired, | ||||||
|     size: PropTypes.number.isRequired, |     size: PropTypes.number.isRequired, | ||||||
|     onClick: PropTypes.func.isRequired, |     onClick: PropTypes.func.isRequired, | ||||||
| @@ -25,6 +28,9 @@ class Item extends React.PureComponent { | |||||||
|  |  | ||||||
|   static defaultProps = { |   static defaultProps = { | ||||||
|     autoPlayGif: false, |     autoPlayGif: false, | ||||||
|  |     standalone: false, | ||||||
|  |     index: 0, | ||||||
|  |     size: 1, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   handleMouseEnter = (e) => { |   handleMouseEnter = (e) => { | ||||||
| @@ -57,7 +63,7 @@ class Item extends React.PureComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { attachment, index, size } = this.props; |     const { attachment, index, size, standalone } = this.props; | ||||||
|  |  | ||||||
|     let width  = 50; |     let width  = 50; | ||||||
|     let height = 100; |     let height = 100; | ||||||
| @@ -119,8 +125,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 | ||||||
| @@ -129,16 +135,17 @@ class Item extends React.PureComponent { | |||||||
|           onClick={this.handleClick} |           onClick={this.handleClick} | ||||||
|           target='_blank' |           target='_blank' | ||||||
|         > |         > | ||||||
|           <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' /> |           <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} /> | ||||||
|         </a> |         </a> | ||||||
|       ); |       ); | ||||||
|     } else if (attachment.get('type') === 'gifv') { |     } else if (attachment.get('type') === 'gifv') { | ||||||
|       const autoPlay = !isIOS() && this.props.autoPlayGif; |       const autoPlay = !isIOS() && this.props.autoPlayGif; | ||||||
|  |  | ||||||
|       thumbnail = ( |       thumbnail = ( | ||||||
|         <div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}> |         <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> | ||||||
|           <video |           <video | ||||||
|             className='media-gallery__item-gifv-thumbnail' |             className='media-gallery__item-gifv-thumbnail' | ||||||
|  |             aria-label={attachment.get('description')} | ||||||
|             role='application' |             role='application' | ||||||
|             src={attachment.get('url')} |             src={attachment.get('url')} | ||||||
|             onClick={this.handleClick} |             onClick={this.handleClick} | ||||||
| @@ -155,7 +162,7 @@ class Item extends React.PureComponent { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> |       <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> | ||||||
|         {thumbnail} |         {thumbnail} | ||||||
|       </div> |       </div> | ||||||
|     ); |     ); | ||||||
| @@ -168,7 +175,9 @@ export default class MediaGallery extends React.PureComponent { | |||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     sensitive: PropTypes.bool, |     sensitive: PropTypes.bool, | ||||||
|  |     standalone: PropTypes.bool, | ||||||
|     media: ImmutablePropTypes.list.isRequired, |     media: ImmutablePropTypes.list.isRequired, | ||||||
|  |     size: PropTypes.object, | ||||||
|     height: PropTypes.number.isRequired, |     height: PropTypes.number.isRequired, | ||||||
|     onOpenMedia: PropTypes.func.isRequired, |     onOpenMedia: PropTypes.func.isRequired, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
| @@ -177,6 +186,7 @@ export default class MediaGallery extends React.PureComponent { | |||||||
|  |  | ||||||
|   static defaultProps = { |   static defaultProps = { | ||||||
|     autoPlayGif: false, |     autoPlayGif: false, | ||||||
|  |     standalone: false, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   state = { |   state = { | ||||||
| @@ -184,7 +194,7 @@ export default class MediaGallery extends React.PureComponent { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   componentWillReceiveProps (nextProps) { |   componentWillReceiveProps (nextProps) { | ||||||
|     if (nextProps.sensitive !== this.props.sensitive) { |     if (!is(nextProps.media, this.props.media)) { | ||||||
|       this.setState({ visible: !nextProps.sensitive }); |       this.setState({ visible: !nextProps.sensitive }); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -197,12 +207,42 @@ export default class MediaGallery extends React.PureComponent { | |||||||
|     this.props.onOpenMedia(this.props.media, index); |     this.props.onOpenMedia(this.props.media, index); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   handleRef = (node) => { | ||||||
|  |     if (node && this.isStandaloneEligible()) { | ||||||
|  |       // offsetWidth triggers a layout, so only calculate when we need to | ||||||
|  |       this.setState({ | ||||||
|  |         width: node.offsetWidth, | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   isStandaloneEligible() { | ||||||
|  |     const { media, standalone } = this.props; | ||||||
|  |     return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { media, intl, sensitive } = this.props; |     const { media, intl, sensitive, height } = this.props; | ||||||
|  |     const { width, visible } = this.state; | ||||||
|  |  | ||||||
|     let children; |     let children; | ||||||
|  |  | ||||||
|     if (!this.state.visible) { |     const style = {}; | ||||||
|  |  | ||||||
|  |     if (this.isStandaloneEligible()) { | ||||||
|  |       if (!visible && width) { | ||||||
|  |         // only need to forcibly set the height in "sensitive" mode | ||||||
|  |         style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); | ||||||
|  |       } else { | ||||||
|  |         // layout automatically, using image's natural aspect ratio | ||||||
|  |         style.height = ''; | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       // crop the image | ||||||
|  |       style.height = height; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!visible) { | ||||||
|       let warning; |       let warning; | ||||||
|  |  | ||||||
|       if (sensitive) { |       if (sensitive) { | ||||||
| @@ -212,20 +252,25 @@ export default class MediaGallery extends React.PureComponent { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       children = ( |       children = ( | ||||||
|         <button className='media-spoiler' onClick={this.handleOpen}> |         <button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}> | ||||||
|           <span className='media-spoiler__warning'>{warning}</span> |           <span className='media-spoiler__warning'>{warning}</span> | ||||||
|           <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> |           <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> | ||||||
|         </button> |         </button> | ||||||
|       ); |       ); | ||||||
|     } else { |     } else { | ||||||
|       const size = media.take(4).size; |       const size = media.take(4).size; | ||||||
|  |  | ||||||
|  |       if (this.isStandaloneEligible()) { | ||||||
|  |         children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />; | ||||||
|  |       } else { | ||||||
|         children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />); |         children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />); | ||||||
|       } |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className='media-gallery' style={{ height: `${this.props.height}px` }}> |       <div className='media-gallery' style={style}> | ||||||
|         <div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}> |         <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}> | ||||||
|           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> |           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         {children} |         {children} | ||||||
|   | |||||||
| @@ -1,7 +1,15 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { injectIntl, FormattedRelative } from 'react-intl'; | import { injectIntl, defineMessages } from 'react-intl'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
|  |  | ||||||
|  | const messages = defineMessages({ | ||||||
|  |   just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, | ||||||
|  |   seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, | ||||||
|  |   minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, | ||||||
|  |   hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, | ||||||
|  |   days: { id: 'relative_time.days', defaultMessage: '{number}d' }, | ||||||
|  | }); | ||||||
|  |  | ||||||
| const dateFormatOptions = { | const dateFormatOptions = { | ||||||
|   hour12: false, |   hour12: false, | ||||||
|   year: 'numeric', |   year: 'numeric', | ||||||
| @@ -11,6 +19,47 @@ const dateFormatOptions = { | |||||||
|   minute: '2-digit', |   minute: '2-digit', | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const shortDateFormatOptions = { | ||||||
|  |   month: 'numeric', | ||||||
|  |   day: 'numeric', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const SECOND = 1000; | ||||||
|  | const MINUTE = 1000 * 60; | ||||||
|  | const HOUR   = 1000 * 60 * 60; | ||||||
|  | const DAY    = 1000 * 60 * 60 * 24; | ||||||
|  |  | ||||||
|  | const MAX_DELAY = 2147483647; | ||||||
|  |  | ||||||
|  | const selectUnits = delta => { | ||||||
|  |   const absDelta = Math.abs(delta); | ||||||
|  |  | ||||||
|  |   if (absDelta < MINUTE) { | ||||||
|  |     return 'second'; | ||||||
|  |   } else if (absDelta < HOUR) { | ||||||
|  |     return 'minute'; | ||||||
|  |   } else if (absDelta < DAY) { | ||||||
|  |     return 'hour'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return 'day'; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const getUnitDelay = units => { | ||||||
|  |   switch (units) { | ||||||
|  |   case 'second': | ||||||
|  |     return SECOND; | ||||||
|  |   case 'minute': | ||||||
|  |     return MINUTE; | ||||||
|  |   case 'hour': | ||||||
|  |     return HOUR; | ||||||
|  |   case 'day': | ||||||
|  |     return DAY; | ||||||
|  |   default: | ||||||
|  |     return MAX_DELAY; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
| @injectIntl | @injectIntl | ||||||
| export default class RelativeTimestamp extends React.Component { | export default class RelativeTimestamp extends React.Component { | ||||||
|  |  | ||||||
| @@ -19,20 +68,78 @@ export default class RelativeTimestamp extends React.Component { | |||||||
|     timestamp: PropTypes.string.isRequired, |     timestamp: PropTypes.string.isRequired, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   shouldComponentUpdate (nextProps) { |   state = { | ||||||
|  |     now: this.props.intl.now(), | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   shouldComponentUpdate (nextProps, nextState) { | ||||||
|     // As of right now the locale doesn't change without a new page load, |     // As of right now the locale doesn't change without a new page load, | ||||||
|     // but we might as well check in case that ever changes. |     // but we might as well check in case that ever changes. | ||||||
|     return this.props.timestamp !== nextProps.timestamp || |     return this.props.timestamp !== nextProps.timestamp || | ||||||
|       this.props.intl.locale !== nextProps.intl.locale; |       this.props.intl.locale !== nextProps.intl.locale || | ||||||
|  |       this.state.now !== nextState.now; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentWillReceiveProps (nextProps) { | ||||||
|  |     if (this.props.timestamp !== nextProps.timestamp) { | ||||||
|  |       this.setState({ now: this.props.intl.now() }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentDidMount () { | ||||||
|  |     this._scheduleNextUpdate(this.props, this.state); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentWillUpdate (nextProps, nextState) { | ||||||
|  |     this._scheduleNextUpdate(nextProps, nextState); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     clearTimeout(this._timer); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   _scheduleNextUpdate (props, state) { | ||||||
|  |     clearTimeout(this._timer); | ||||||
|  |  | ||||||
|  |     const { timestamp }  = props; | ||||||
|  |     const delta          = (new Date(timestamp)).getTime() - state.now; | ||||||
|  |     const unitDelay      = getUnitDelay(selectUnits(delta)); | ||||||
|  |     const unitRemainder  = Math.abs(delta % unitDelay); | ||||||
|  |     const updateInterval = 1000 * 10; | ||||||
|  |     const delay          = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder); | ||||||
|  |  | ||||||
|  |     this._timer = setTimeout(() => { | ||||||
|  |       this.setState({ now: this.props.intl.now() }); | ||||||
|  |     }, delay); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { timestamp, intl } = this.props; |     const { timestamp, intl } = this.props; | ||||||
|  |  | ||||||
|     const date  = new Date(timestamp); |     const date  = new Date(timestamp); | ||||||
|  |     const delta = this.state.now - date.getTime(); | ||||||
|  |  | ||||||
|  |     let relativeTime; | ||||||
|  |  | ||||||
|  |     if (delta < 10 * SECOND) { | ||||||
|  |       relativeTime = intl.formatMessage(messages.just_now); | ||||||
|  |     } else if (delta < 3 * DAY) { | ||||||
|  |       if (delta < MINUTE) { | ||||||
|  |         relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); | ||||||
|  |       } else if (delta < HOUR) { | ||||||
|  |         relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); | ||||||
|  |       } else if (delta < DAY) { | ||||||
|  |         relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); | ||||||
|  |       } else { | ||||||
|  |         relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       relativeTime = intl.formatDate(date, shortDateFormatOptions); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> |       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> | ||||||
|         <FormattedRelative value={date} /> |         {relativeTime} | ||||||
|       </time> |       </time> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,13 +1,20 @@ | |||||||
| import React, { PureComponent } from 'react'; | import React, { PureComponent } from 'react'; | ||||||
| import { ScrollContainer } from 'react-router-scroll'; | import { ScrollContainer } from 'react-router-scroll'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import IntersectionObserverArticle from './intersection_observer_article'; | import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; | ||||||
| import LoadMore from './load_more'; | import LoadMore from './load_more'; | ||||||
| import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; | import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; | ||||||
| import { throttle } from 'lodash'; | import { throttle } from 'lodash'; | ||||||
|  | import { List as ImmutableList } from 'immutable'; | ||||||
|  | import classNames from 'classnames'; | ||||||
|  | import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; | ||||||
|  |  | ||||||
| export default class ScrollableList extends PureComponent { | export default class ScrollableList extends PureComponent { | ||||||
|  |  | ||||||
|  |   static contextTypes = { | ||||||
|  |     router: PropTypes.object, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     scrollKey: PropTypes.string.isRequired, |     scrollKey: PropTypes.string.isRequired, | ||||||
|     onScrollToBottom: PropTypes.func, |     onScrollToBottom: PropTypes.func, | ||||||
| @@ -26,6 +33,10 @@ export default class ScrollableList extends PureComponent { | |||||||
|     trackScroll: true, |     trackScroll: true, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   state = { | ||||||
|  |     lastMouseMove: null, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   intersectionObserverWrapper = new IntersectionObserverWrapper(); |   intersectionObserverWrapper = new IntersectionObserverWrapper(); | ||||||
|  |  | ||||||
|   handleScroll = throttle(() => { |   handleScroll = throttle(() => { | ||||||
| @@ -46,20 +57,33 @@ export default class ScrollableList extends PureComponent { | |||||||
|     trailing: true, |     trailing: true, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   handleMouseMove = throttle(() => { | ||||||
|  |     this._lastMouseMove = new Date(); | ||||||
|  |   }, 300); | ||||||
|  |  | ||||||
|  |   handleMouseLeave = () => { | ||||||
|  |     this._lastMouseMove = null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     this.attachScrollListener(); |     this.attachScrollListener(); | ||||||
|     this.attachIntersectionObserver(); |     this.attachIntersectionObserver(); | ||||||
|  |     attachFullscreenListener(this.onFullScreenChange); | ||||||
|  |  | ||||||
|     // Handle initial scroll posiiton |     // Handle initial scroll posiiton | ||||||
|     this.handleScroll(); |     this.handleScroll(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   componentDidUpdate (prevProps) { |   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 |     // 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. |     // jerk the scrollbar around if you're already scrolled down the page. | ||||||
|     if (React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this._oldScrollPosition && this.node.scrollTop > 0) { |     if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) { | ||||||
|       if (this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props)) { |  | ||||||
|       const newScrollTop = this.node.scrollHeight - this._oldScrollPosition; |       const newScrollTop = this.node.scrollHeight - this._oldScrollPosition; | ||||||
|  |  | ||||||
|       if (this.node.scrollTop !== newScrollTop) { |       if (this.node.scrollTop !== newScrollTop) { | ||||||
|         this.node.scrollTop = newScrollTop; |         this.node.scrollTop = newScrollTop; | ||||||
|       } |       } | ||||||
| @@ -67,11 +91,15 @@ export default class ScrollableList extends PureComponent { | |||||||
|       this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; |       this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   } |  | ||||||
|  |  | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|     this.detachScrollListener(); |     this.detachScrollListener(); | ||||||
|     this.detachIntersectionObserver(); |     this.detachIntersectionObserver(); | ||||||
|  |     detachFullscreenListener(this.onFullScreenChange); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   onFullScreenChange = () => { | ||||||
|  |     this.setState({ fullscreen: isFullscreen() }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   attachIntersectionObserver () { |   attachIntersectionObserver () { | ||||||
| @@ -95,7 +123,12 @@ export default class ScrollableList extends PureComponent { | |||||||
|  |  | ||||||
|   getFirstChildKey (props) { |   getFirstChildKey (props) { | ||||||
|     const { children } = props; |     const { children } = props; | ||||||
|     const firstChild = Array.isArray(children) ? children[0] : children; |     let firstChild = children; | ||||||
|  |     if (children instanceof ImmutableList) { | ||||||
|  |       firstChild = children.get(0); | ||||||
|  |     } else if (Array.isArray(children)) { | ||||||
|  |       firstChild = children[0]; | ||||||
|  |     } | ||||||
|     return firstChild && firstChild.key; |     return firstChild && firstChild.key; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -108,49 +141,35 @@ export default class ScrollableList extends PureComponent { | |||||||
|     this.props.onScrollToBottom(); |     this.props.onScrollToBottom(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   handleKeyDown = (e) => { |   _recentlyMoved () { | ||||||
|     if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { |     return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); | ||||||
|       const article = (() => { |  | ||||||
|         switch (e.key) { |  | ||||||
|         case 'PageDown': |  | ||||||
|           return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; |  | ||||||
|         case 'PageUp': |  | ||||||
|           return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; |  | ||||||
|         case 'End': |  | ||||||
|           return this.node.querySelector('[role="feed"] > article:last-of-type'); |  | ||||||
|         case 'Home': |  | ||||||
|           return this.node.querySelector('[role="feed"] > article:first-of-type'); |  | ||||||
|         default: |  | ||||||
|           return null; |  | ||||||
|         } |  | ||||||
|       })(); |  | ||||||
|  |  | ||||||
|  |  | ||||||
|       if (article) { |  | ||||||
|         e.preventDefault(); |  | ||||||
|         article.focus(); |  | ||||||
|         article.scrollIntoView(); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; |     const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; | ||||||
|  |     const { fullscreen } = this.state; | ||||||
|     const childrenCount = React.Children.count(children); |     const childrenCount = React.Children.count(children); | ||||||
|  |  | ||||||
|     const loadMore     = <LoadMore visible={!isLoading && childrenCount > 0 && hasMore} onClick={this.handleLoadMore} />; |     const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; | ||||||
|     let scrollableArea = null; |     let scrollableArea = null; | ||||||
|  |  | ||||||
|     if (isLoading || childrenCount > 0 || !emptyMessage) { |     if (isLoading || childrenCount > 0 || !emptyMessage) { | ||||||
|       scrollableArea = ( |       scrollableArea = ( | ||||||
|         <div className='scrollable' ref={this.setRef}> |         <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> | ||||||
|           <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}> |           <div role='feed' className='item-list'> | ||||||
|             {prepend} |             {prepend} | ||||||
|  |  | ||||||
|             {React.Children.map(this.props.children, (child, index) => ( |             {React.Children.map(this.props.children, (child, index) => ( | ||||||
|               <IntersectionObserverArticle key={child.key} id={child.key} index={index} listLength={childrenCount} intersectionObserverWrapper={this.intersectionObserverWrapper}> |               <IntersectionObserverArticleContainer | ||||||
|  |                 key={child.key} | ||||||
|  |                 id={child.key} | ||||||
|  |                 index={index} | ||||||
|  |                 listLength={childrenCount} | ||||||
|  |                 intersectionObserverWrapper={this.intersectionObserverWrapper} | ||||||
|  |                 saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} | ||||||
|  |               > | ||||||
|                 {child} |                 {child} | ||||||
|               </IntersectionObserverArticle> |               </IntersectionObserverArticleContainer> | ||||||
|             ))} |             ))} | ||||||
|  |  | ||||||
|             {loadMore} |             {loadMore} | ||||||
|   | |||||||
| @@ -9,7 +9,9 @@ import StatusContent from './status_content'; | |||||||
| import StatusActionBar from './status_action_bar'; | import StatusActionBar from './status_action_bar'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components'; | import { MediaGallery, Video } from '../features/ui/util/async-components'; | ||||||
|  | import { HotKeys } from 'react-hotkeys'; | ||||||
|  | import classNames from 'classnames'; | ||||||
|  |  | ||||||
| // 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 | ||||||
| @@ -34,11 +36,13 @@ export default class Status extends ImmutablePureComponent { | |||||||
|     onBlock: PropTypes.func, |     onBlock: PropTypes.func, | ||||||
|     onEmbed: PropTypes.func, |     onEmbed: PropTypes.func, | ||||||
|     onHeightChange: PropTypes.func, |     onHeightChange: PropTypes.func, | ||||||
|     me: PropTypes.number, |     me: PropTypes.string, | ||||||
|     boostModal: PropTypes.bool, |     boostModal: PropTypes.bool, | ||||||
|     autoPlayGif: PropTypes.bool, |     autoPlayGif: PropTypes.bool, | ||||||
|     muted: PropTypes.bool, |     muted: PropTypes.bool, | ||||||
|     hidden: PropTypes.bool, |     hidden: PropTypes.bool, | ||||||
|  |     onMoveUp: PropTypes.func, | ||||||
|  |     onMoveDown: PropTypes.func, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   state = { |   state = { | ||||||
| @@ -70,7 +74,7 @@ export default class Status extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|   handleAccountClick = (e) => { |   handleAccountClick = (e) => { | ||||||
|     if (this.context.router && e.button === 0) { |     if (this.context.router && e.button === 0) { | ||||||
|       const id = Number(e.currentTarget.getAttribute('data-id')); |       const id = e.currentTarget.getAttribute('data-id'); | ||||||
|       e.preventDefault(); |       e.preventDefault(); | ||||||
|       this.context.router.history.push(`/accounts/${id}`); |       this.context.router.history.push(`/accounts/${id}`); | ||||||
|     } |     } | ||||||
| @@ -88,13 +92,63 @@ 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._properStatus().getIn(['media_attachments', 0]), startTime); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleHotkeyReply = e => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     this.props.onReply(this._properStatus(), this.context.router.history); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleHotkeyFavourite = () => { | ||||||
|  |     this.props.onFavourite(this._properStatus()); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleHotkeyBoost = e => { | ||||||
|  |     this.props.onReblog(this._properStatus(), e); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleHotkeyMention = e => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     this.props.onMention(this._properStatus().get('account'), this.context.router.history); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleHotkeyOpen = () => { | ||||||
|  |     this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleHotkeyOpenProfile = () => { | ||||||
|  |     this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleHotkeyMoveUp = () => { | ||||||
|  |     this.props.onMoveUp(this.props.status.get('id')); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleHotkeyMoveDown = () => { | ||||||
|  |     this.props.onMoveDown(this.props.status.get('id')); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   _properStatus () { | ||||||
|  |     const { status } = this.props; | ||||||
|  |  | ||||||
|  |     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | ||||||
|  |       return status.get('reblog'); | ||||||
|  |     } else { | ||||||
|  |       return status; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     let media = null; |     let media = null; | ||||||
|     let statusAvatar; |     let statusAvatar, prepend; | ||||||
|  |  | ||||||
|     const { status, account, hidden, ...other } = this.props; |     const { hidden }     = this.props; | ||||||
|     const { isExpanded } = this.state; |     const { isExpanded } = this.state; | ||||||
|  |  | ||||||
|  |     let { status, account, ...other } = this.props; | ||||||
|  |  | ||||||
|     if (status === null) { |     if (status === null) { | ||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
| @@ -111,25 +165,33 @@ export default class Status extends ImmutablePureComponent { | |||||||
|     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { |     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | ||||||
|       const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; |       const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; | ||||||
|  |  | ||||||
|       return ( |       prepend = ( | ||||||
|         <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={display_name_html} /></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} status={status.get('reblog')} account={status.get('account')} /> |  | ||||||
|         </div> |  | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|  |       account = status.get('account'); | ||||||
|  |       status  = status.get('reblog'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (status.get('media_attachments').size > 0 && !this.props.muted) { |     if (status.get('media_attachments').size > 0 && !this.props.muted) { | ||||||
|       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 { | ||||||
| @@ -147,12 +209,27 @@ export default class Status extends ImmutablePureComponent { | |||||||
|       statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; |       statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     const handlers = this.props.muted ? {} : { | ||||||
|  |       reply: this.handleHotkeyReply, | ||||||
|  |       favourite: this.handleHotkeyFavourite, | ||||||
|  |       boost: this.handleHotkeyBoost, | ||||||
|  |       mention: this.handleHotkeyMention, | ||||||
|  |       open: this.handleHotkeyOpen, | ||||||
|  |       openProfile: this.handleHotkeyOpenProfile, | ||||||
|  |       moveUp: this.handleHotkeyMoveUp, | ||||||
|  |       moveDown: this.handleHotkeyMoveDown, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}> |       <HotKeys handlers={handlers}> | ||||||
|  |         <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}> | ||||||
|  |           {prepend} | ||||||
|  |  | ||||||
|  |           <div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}> | ||||||
|             <div className='status__info'> |             <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> | ||||||
|  |  | ||||||
|           <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'> |               <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'> | ||||||
|                 <div className='status__avatar'> |                 <div className='status__avatar'> | ||||||
|                   {statusAvatar} |                   {statusAvatar} | ||||||
|                 </div> |                 </div> | ||||||
| @@ -165,8 +242,10 @@ export default class Status extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|             {media} |             {media} | ||||||
|  |  | ||||||
|         <StatusActionBar {...this.props} /> |             <StatusActionBar status={status} account={account} {...other} /> | ||||||
|           </div> |           </div> | ||||||
|  |         </div> | ||||||
|  |       </HotKeys> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ const messages = defineMessages({ | |||||||
|   block: { id: 'account.block', defaultMessage: 'Block @{name}' }, |   block: { id: 'account.block', defaultMessage: 'Block @{name}' }, | ||||||
|   reply: { id: 'status.reply', defaultMessage: 'Reply' }, |   reply: { id: 'status.reply', defaultMessage: 'Reply' }, | ||||||
|   share: { id: 'status.share', defaultMessage: 'Share' }, |   share: { id: 'status.share', defaultMessage: 'Share' }, | ||||||
|  |   more: { id: 'status.more', defaultMessage: 'More' }, | ||||||
|   replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, |   replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, | ||||||
|   reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, |   reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, | ||||||
|   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, |   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, | ||||||
| @@ -46,7 +47,7 @@ export default class StatusActionBar extends ImmutablePureComponent { | |||||||
|     onEmbed: PropTypes.func, |     onEmbed: PropTypes.func, | ||||||
|     onMuteConversation: PropTypes.func, |     onMuteConversation: PropTypes.func, | ||||||
|     onPin: PropTypes.func, |     onPin: PropTypes.func, | ||||||
|     me: PropTypes.number, |     me: PropTypes.string, | ||||||
|     withDismiss: PropTypes.bool, |     withDismiss: PropTypes.bool, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|   }; |   }; | ||||||
| @@ -134,7 +135,7 @@ export default class StatusActionBar extends ImmutablePureComponent { | |||||||
|  |  | ||||||
|     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); | ||||||
|     } |     } | ||||||
| @@ -179,7 +180,7 @@ export default class StatusActionBar extends ImmutablePureComponent { | |||||||
|         {shareButton} |         {shareButton} | ||||||
|  |  | ||||||
|         <div className='status__action-bar-dropdown'> |         <div className='status__action-bar-dropdown'> | ||||||
|           <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' /> |           <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} /> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -122,6 +122,7 @@ export default class StatusContent extends React.PureComponent { | |||||||
|     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, | ||||||
|  |       'status__content--with-spoiler': status.get('spoiler_text').length > 0, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     if (isRtl(status.get('search_index'))) { |     if (isRtl(status.get('search_index'))) { | ||||||
| @@ -144,7 +145,7 @@ export default class StatusContent extends React.PureComponent { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       return ( |       return ( | ||||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' aria-label={status.get('search_index')} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> |         <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> | ||||||
|           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> |           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> | ||||||
|             <span dangerouslySetInnerHTML={spoilerContent} /> |             <span dangerouslySetInnerHTML={spoilerContent} /> | ||||||
|             {' '} |             {' '} | ||||||
| @@ -153,7 +154,7 @@ export default class StatusContent extends React.PureComponent { | |||||||
|  |  | ||||||
|           {mentionsPlaceholder} |           {mentionsPlaceholder} | ||||||
|  |  | ||||||
|           <div tabIndex={!hidden && 0} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> |           <div tabIndex={!hidden ? 0 : null} 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) { | ||||||
| @@ -161,7 +162,6 @@ export default class StatusContent extends React.PureComponent { | |||||||
|         <div |         <div | ||||||
|           ref={this.setRef} |           ref={this.setRef} | ||||||
|           tabIndex='0' |           tabIndex='0' | ||||||
|           aria-label={status.get('search_index')} |  | ||||||
|           className={classNames} |           className={classNames} | ||||||
|           style={directionStyle} |           style={directionStyle} | ||||||
|           onMouseDown={this.handleMouseDown} |           onMouseDown={this.handleMouseDown} | ||||||
| @@ -173,7 +173,6 @@ export default class StatusContent extends React.PureComponent { | |||||||
|       return ( |       return ( | ||||||
|         <div |         <div | ||||||
|           tabIndex='0' |           tabIndex='0' | ||||||
|           aria-label={status.get('search_index')} |  | ||||||
|           ref={this.setRef} |           ref={this.setRef} | ||||||
|           className='status__content' |           className='status__content' | ||||||
|           style={directionStyle} |           style={directionStyle} | ||||||
|   | |||||||
| @@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent { | |||||||
|     trackScroll: true, |     trackScroll: true, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   handleMoveUp = id => { | ||||||
|  |     const elementIndex = this.props.statusIds.indexOf(id) - 1; | ||||||
|  |     this._selectChild(elementIndex); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleMoveDown = id => { | ||||||
|  |     const elementIndex = this.props.statusIds.indexOf(id) + 1; | ||||||
|  |     this._selectChild(elementIndex); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   _selectChild (index) { | ||||||
|  |     const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); | ||||||
|  |  | ||||||
|  |     if (element) { | ||||||
|  |       element.focus(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setRef = c => { | ||||||
|  |     this.node = c; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { statusIds, ...other } = this.props; |     const { statusIds, ...other } = this.props; | ||||||
|     const { isLoading } = other; |     const { isLoading } = other; | ||||||
|  |  | ||||||
|     const scrollableContent = (isLoading || statusIds.size > 0) ? ( |     const scrollableContent = (isLoading || statusIds.size > 0) ? ( | ||||||
|       statusIds.map((statusId) => ( |       statusIds.map((statusId) => ( | ||||||
|         <StatusContainer key={statusId} id={statusId} /> |         <StatusContainer | ||||||
|  |           key={statusId} | ||||||
|  |           id={statusId} | ||||||
|  |           onMoveUp={this.handleMoveUp} | ||||||
|  |           onMoveDown={this.handleMoveDown} | ||||||
|  |         /> | ||||||
|       )) |       )) | ||||||
|     ) : null; |     ) : null; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <ScrollableList {...other}> |       <ScrollableList {...other} ref={this.setRef}> | ||||||
|         {scrollableContent} |         {scrollableContent} | ||||||
|       </ScrollableList> |       </ScrollableList> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -1,204 +0,0 @@ | |||||||
| import React from 'react'; |  | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; |  | ||||||
| import PropTypes from 'prop-types'; |  | ||||||
| import IconButton from './icon_button'; |  | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |  | ||||||
| import { isIOS } from '../is_mobile'; |  | ||||||
|  |  | ||||||
| const messages = defineMessages({ |  | ||||||
|   toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, |  | ||||||
|   toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }, |  | ||||||
|   expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' }, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| @injectIntl |  | ||||||
| export default class VideoPlayer extends React.PureComponent { |  | ||||||
|  |  | ||||||
|   static contextTypes = { |  | ||||||
|     router: PropTypes.object, |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   static propTypes = { |  | ||||||
|     media: ImmutablePropTypes.map.isRequired, |  | ||||||
|     width: PropTypes.number, |  | ||||||
|     height: PropTypes.number, |  | ||||||
|     sensitive: PropTypes.bool, |  | ||||||
|     intl: PropTypes.object.isRequired, |  | ||||||
|     autoplay: PropTypes.bool, |  | ||||||
|     onOpenVideo: PropTypes.func.isRequired, |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   static defaultProps = { |  | ||||||
|     width: 239, |  | ||||||
|     height: 110, |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   state = { |  | ||||||
|     visible: !this.props.sensitive, |  | ||||||
|     preview: true, |  | ||||||
|     muted: true, |  | ||||||
|     hasAudio: true, |  | ||||||
|     videoError: false, |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   handleClick = () => { |  | ||||||
|     this.setState({ muted: !this.state.muted }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleVideoClick = (e) => { |  | ||||||
|     e.stopPropagation(); |  | ||||||
|  |  | ||||||
|     const node = this.video; |  | ||||||
|  |  | ||||||
|     if (node.paused) { |  | ||||||
|       node.play(); |  | ||||||
|     } else { |  | ||||||
|       node.pause(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleOpen = () => { |  | ||||||
|     this.setState({ preview: !this.state.preview }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleVisibility = () => { |  | ||||||
|     this.setState({ |  | ||||||
|       visible: !this.state.visible, |  | ||||||
|       preview: true, |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleExpand = () => { |  | ||||||
|     this.video.pause(); |  | ||||||
|     this.props.onOpenVideo(this.props.media, this.video.currentTime); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   setRef = (c) => { |  | ||||||
|     this.video = c; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleLoadedData = () => { |  | ||||||
|     if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) { |  | ||||||
|       this.setState({ hasAudio: false }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   handleVideoError = () => { |  | ||||||
|     this.setState({ videoError: true }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   componentDidMount () { |  | ||||||
|     if (!this.video) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this.video.addEventListener('loadeddata', this.handleLoadedData); |  | ||||||
|     this.video.addEventListener('error', this.handleVideoError); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   componentDidUpdate () { |  | ||||||
|     if (!this.video) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this.video.addEventListener('loadeddata', this.handleLoadedData); |  | ||||||
|     this.video.addEventListener('error', this.handleVideoError); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   componentWillUnmount () { |  | ||||||
|     if (!this.video) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this.video.removeEventListener('loadeddata', this.handleLoadedData); |  | ||||||
|     this.video.removeEventListener('error', this.handleVideoError); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   render () { |  | ||||||
|     const { media, intl, width, height, sensitive, autoplay } = this.props; |  | ||||||
|  |  | ||||||
|     let spoilerButton = ( |  | ||||||
|       <div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}> |  | ||||||
|         <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} /> |  | ||||||
|       </div> |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     let expandButton = ''; |  | ||||||
|  |  | ||||||
|     if (this.context.router) { |  | ||||||
|       expandButton = ( |  | ||||||
|         <div className='status__video-player-expand'> |  | ||||||
|           <IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} /> |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     let muteButton = ''; |  | ||||||
|  |  | ||||||
|     if (this.state.hasAudio) { |  | ||||||
|       muteButton = ( |  | ||||||
|         <div className='status__video-player-mute'> |  | ||||||
|           <IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /> |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (!this.state.visible) { |  | ||||||
|       if (sensitive) { |  | ||||||
|         return ( |  | ||||||
|           <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> |  | ||||||
|             {spoilerButton} |  | ||||||
|             <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> |  | ||||||
|             <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> |  | ||||||
|           </div> |  | ||||||
|         ); |  | ||||||
|       } else { |  | ||||||
|         return ( |  | ||||||
|           <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> |  | ||||||
|             {spoilerButton} |  | ||||||
|             <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> |  | ||||||
|             <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> |  | ||||||
|           </div> |  | ||||||
|         ); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (this.state.preview && !autoplay) { |  | ||||||
|       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}> |  | ||||||
|           {spoilerButton} |  | ||||||
|           <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div> |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (this.state.videoError) { |  | ||||||
|       return ( |  | ||||||
|         <div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' > |  | ||||||
|           <span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span> |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|       <div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}> |  | ||||||
|         {spoilerButton} |  | ||||||
|         {muteButton} |  | ||||||
|         {expandButton} |  | ||||||
|  |  | ||||||
|         <video |  | ||||||
|           className='status__video-player-video' |  | ||||||
|           role='button' |  | ||||||
|           tabIndex='0' |  | ||||||
|           ref={this.setRef} |  | ||||||
|           src={media.get('url')} |  | ||||||
|           autoPlay={!isIOS()} |  | ||||||
|           loop |  | ||||||
|           muted={this.state.muted} |  | ||||||
|           onClick={this.handleVideoClick} |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
| } |  | ||||||
							
								
								
									
										18
									
								
								app/javascript/mastodon/containers/card_container.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import Card from '../features/status/components/card'; | ||||||
|  | import { fromJS } from 'immutable'; | ||||||
|  |  | ||||||
|  | export default class CardContainer extends React.PureComponent { | ||||||
|  |  | ||||||
|  |   static propTypes = { | ||||||
|  |     locale: PropTypes.string, | ||||||
|  |     card: PropTypes.array.isRequired, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   render () { | ||||||
|  |     const { card, ...props } = this.props; | ||||||
|  |     return <Card card={fromJS(card)} {...props} />; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -0,0 +1,17 @@ | |||||||
|  | import { connect } from 'react-redux'; | ||||||
|  | import IntersectionObserverArticle from '../components/intersection_observer_article'; | ||||||
|  | import { setHeight } from '../actions/height_cache'; | ||||||
|  |  | ||||||
|  | const makeMapStateToProps = (state, props) => ({ | ||||||
|  |   cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const mapDispatchToProps = (dispatch) => ({ | ||||||
|  |  | ||||||
|  |   onHeightChange (key, id, height) { | ||||||
|  |     dispatch(setHeight(key, id, height)); | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle); | ||||||
| @@ -3,14 +3,14 @@ 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 { showOnboardingOnce } from '../actions/onboarding'; | import { showOnboardingOnce } from '../actions/onboarding'; | ||||||
| import BrowserRouter from 'react-router-dom/BrowserRouter'; | import { BrowserRouter, Route } from 'react-router-dom'; | ||||||
| import Route from 'react-router-dom/Route'; | import { ScrollContext } from 'react-router-scroll'; | ||||||
| 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 { connectUserStream } from '../actions/streaming'; | 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(); | ||||||
| addLocaleData(localeData); | addLocaleData(localeData); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -18,7 +18,7 @@ import { | |||||||
|   blockAccount, |   blockAccount, | ||||||
|   muteAccount, |   muteAccount, | ||||||
| } from '../actions/accounts'; | } from '../actions/accounts'; | ||||||
| import { muteStatus, unmuteStatus, deleteStatus, setStatusHeight } from '../actions/statuses'; | import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; | ||||||
| import { initReport } from '../actions/reports'; | import { initReport } from '../actions/reports'; | ||||||
| import { openModal } from '../actions/modal'; | import { openModal } from '../actions/modal'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| @@ -138,10 +138,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|   onHeightChange (status, height) { |  | ||||||
|     dispatch(setStatusHeight(status.get('id'), height)); |  | ||||||
|   }, |  | ||||||
|  |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); | export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store'; | |||||||
| import { IntlProvider, addLocaleData } from 'react-intl'; | import { IntlProvider, addLocaleData } from 'react-intl'; | ||||||
| import { getLocale } from '../locales'; | import { getLocale } from '../locales'; | ||||||
| import PublicTimeline from '../features/standalone/public_timeline'; | import PublicTimeline from '../features/standalone/public_timeline'; | ||||||
|  | import HashtagTimeline from '../features/standalone/hashtag_timeline'; | ||||||
|  |  | ||||||
| const { localeData, messages } = getLocale(); | const { localeData, messages } = getLocale(); | ||||||
| addLocaleData(localeData); | addLocaleData(localeData); | ||||||
| @@ -22,15 +23,24 @@ export default class TimelineContainer extends React.PureComponent { | |||||||
|  |  | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     locale: PropTypes.string.isRequired, |     locale: PropTypes.string.isRequired, | ||||||
|  |     hashtag: PropTypes.string, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   render () { |   render () { | ||||||
|     const { locale } = this.props; |     const { locale, hashtag } = this.props; | ||||||
|  |  | ||||||
|  |     let timeline; | ||||||
|  |  | ||||||
|  |     if (hashtag) { | ||||||
|  |       timeline = <HashtagTimeline hashtag={hashtag} />; | ||||||
|  |     } else { | ||||||
|  |       timeline = <PublicTimeline />; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <IntlProvider locale={locale} messages={messages}> |       <IntlProvider locale={locale} messages={messages}> | ||||||
|         <Provider store={store}> |         <Provider store={store}> | ||||||
|           <PublicTimeline /> |           {timeline} | ||||||
|         </Provider> |         </Provider> | ||||||
|       </IntlProvider> |       </IntlProvider> | ||||||
|     ); |     ); | ||||||
|   | |||||||