diff --git a/.env.production.sample b/.env.production.sample index e022a84059c3f2..91fcce6ac4568b 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -1,5 +1,6 @@ # Service dependencies # You may set REDIS_URL instead for more advanced options +# You may also set REDIS_NAMESPACE to share Redis between multiple Mastodon servers REDIS_HOST=redis REDIS_PORT=6379 # You may set DATABASE_URL instead for more advanced options diff --git a/.eslintrc.yml b/.eslintrc.yml index 1c60cbdb3e5c93..7c6da9d57a0326 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -5,12 +5,14 @@ env: browser: true node: true es6: true + jest: true parser: babel-eslint plugins: - react - jsx-a11y +- import parserOptions: sourceType: module @@ -21,8 +23,14 @@ parserOptions: modules: true spread: true -rules: +settings: + import/extensions: + - .js + import/ignore: + - node_modules + - \\.(css|scss|json)$ +rules: brace-style: warn comma-dangle: - error @@ -125,3 +133,17 @@ rules: jsx-a11y/role-supports-aria-props: off jsx-a11y/scope: 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 diff --git a/.ruby-version b/.ruby-version index 005119baaa0653..8e8299dcc06835 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.4.1 +2.4.2 diff --git a/.travis.yml b/.travis.yml index 2f8a728fe635d3..27a907b4c1f391 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,17 +26,15 @@ addons: postgresql: 9.4 apt: sources: - - ubuntu-toolchain-r-test - trusty-media packages: - ffmpeg - - g++-6 - libprotobuf-dev - protobuf-compiler - libicu-dev rvm: - - 2.4.1 + - 2.4.2 services: - redis-server @@ -54,5 +52,5 @@ before_script: script: - travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec - - npm test - - bundle exec i18n-tasks unused + - yarn test + - bundle exec i18n-tasks check-normalized && bundle exec i18n-tasks unused diff --git a/.yarnclean b/.yarnclean new file mode 100644 index 00000000000000..f2de52869c823b --- /dev/null +++ b/.yarnclean @@ -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 + diff --git a/CODEOWNERS b/CODEOWNERS index 42fc73ded75718..32919bd50c5fc3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -8,8 +8,25 @@ # /config/locales/*.fr.yml @żelipapą # /config/locales/fr.yml @żelipapą +# Polish /app/javascript/mastodon/locales/pl.json @m4sk1n /app/views/user_mailer/*.pl.html.erb @m4sk1n /app/views/user_mailer/*.pl.text.erb @m4sk1n /config/locales/*.pl.yml @m4sk1n /config/locales/pl.yml @m4sk1n + +# 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 diff --git a/Dockerfile b/Dockerfile index 15138065b688fd..c3b38fa8b1a7f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:2.4.1-alpine3.6 +FROM ruby:2.4.2-alpine3.6 LABEL maintainer="https://github.com/tootsuite/mastodon" \ description="A GNU Social-compatible microblogging server" @@ -7,6 +7,8 @@ ENV UID=991 GID=991 \ RAILS_SERVE_STATIC_FILES=true \ RAILS_ENV=production NODE_ENV=production +ARG YARN_VERSION=1.1.0 +ARG YARN_DOWNLOAD_SHA256=171c1f9ee93c488c0d774ac6e9c72649047c3f896277d88d0f805266519430f3 ARG LIBICONV_VERSION=1.15 ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 @@ -19,6 +21,7 @@ RUN apk -U upgrade \ build-base \ icu-dev \ libidn-dev \ + libressl \ libtool \ postgresql-dev \ protobuf-dev \ @@ -32,16 +35,21 @@ RUN apk -U upgrade \ imagemagick \ libidn \ libpq \ - nodejs-npm \ nodejs \ + nodejs-npm \ protobuf \ su-exec \ tini \ - yarn \ && update-ca-certificates \ + && mkdir -p /tmp/src /opt \ + && wget -O yarn.tar.gz "https://github.com/yarnpkg/yarn/releases/download/v$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \ + && echo "$YARN_DOWNLOAD_SHA256 *yarn.tar.gz" | sha256sum -c - \ + && tar -xzf yarn.tar.gz -C /tmp/src \ + && rm yarn.tar.gz \ + && mv /tmp/src/yarn-v$YARN_VERSION /opt/yarn \ + && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \ && wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \ && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \ - && mkdir -p /tmp/src \ && tar -xzf libiconv.tar.gz -C /tmp/src \ && rm libiconv.tar.gz \ && cd /tmp/src/libiconv-$LIBICONV_VERSION \ @@ -52,11 +60,12 @@ RUN apk -U upgrade \ && cd /mastodon \ && rm -rf /tmp/* /var/cache/apk/* -COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/ +COPY Gemfile Gemfile.lock package.json yarn.lock .yarnclean /mastodon/ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \ && bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \ - && yarn --ignore-optional --pure-lockfile + && yarn --pure-lockfile \ + && yarn cache clean COPY . /mastodon diff --git a/Gemfile b/Gemfile index 514d690013e978..c6099e5030ed17 100644 --- a/Gemfile +++ b/Gemfile @@ -42,6 +42,7 @@ gem 'kaminari', '~> 1.0' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.1' gem 'nokogiri', '~> 1.7' +gem 'nsa', '~> 0.2' gem 'oj', '~> 3.0' gem 'ostatus2', '~> 2.0' gem 'ox', '~> 2.5' @@ -64,10 +65,10 @@ gem 'sidekiq-bulk', '~>0.1.1' gem 'simple-navigation', '~> 4.0' gem 'simple_form', '~> 3.4' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' -gem 'statsd-instrument', '~> 2.1' +gem 'strong_migrations' gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2017' -gem 'webpacker', '~> 2.0' +gem 'webpacker', '~> 3.0' gem 'webpush' gem 'airbrake', '~> 5.0' @@ -103,8 +104,8 @@ group :development do gem 'letter_opener', '~> 1.4' gem 'letter_opener_web', '~> 1.3' gem 'rubocop', require: false - gem 'brakeman', '~> 3.6', require: false - gem 'bundler-audit', '~> 0.5', require: false + gem 'brakeman', '~> 4.0', require: false + gem 'bundler-audit', '~> 0.6', require: false gem 'scss_lint', '~> 0.53', require: false gem 'capistrano', '~> 3.8' @@ -119,7 +120,7 @@ group :production do end # for friends -gem 'omniauth-niconico' +gem 'omniauth-niconico', '0.0.1' group :production do gem 'exception_notification', '~> 4.2.2' gem 'slack-notifier', '~> 2.3.1' diff --git a/Gemfile.lock b/Gemfile.lock index e838c5d567d984..f94c765f883946 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -60,33 +60,33 @@ GEM encryptor (~> 3.0.0) av (0.9.0) cocaine (~> 0.5.3) - aws-sdk (2.10.21) - aws-sdk-resources (= 2.10.21) - aws-sdk-core (2.10.21) + aws-sdk (2.10.46) + aws-sdk-resources (= 2.10.46) + aws-sdk-core (2.10.46) aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-resources (2.10.21) - aws-sdk-core (= 2.10.21) - aws-sigv4 (1.0.1) + aws-sdk-resources (2.10.46) + aws-sdk-core (= 2.10.46) + aws-sigv4 (1.0.2) bcrypt (3.1.11) - better_errors (2.1.1) + better_errors (2.3.0) coderay (>= 1.0.0) - erubis (>= 2.6.6) + erubi (>= 1.0.0) rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) - bootsnap (1.1.2) + bootsnap (1.1.3) msgpack (~> 1.0) - brakeman (3.7.2) - browser (2.4.0) + brakeman (4.0.1) + browser (2.5.1) builder (3.2.3) - bullet (5.5.1) + bullet (5.6.1) activesupport (>= 3.0.0) uniform_notifier (~> 1.10.0) bundler-audit (0.6.0) bundler (~> 1.2) thor (~> 0.18) - capistrano (3.8.2) + capistrano (3.9.1) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) @@ -102,9 +102,9 @@ GEM sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (2.14.4) + capybara (2.15.1) addressable - mime-types (>= 1.16) + mini_mime (>= 0.1.3) nokogiri (>= 1.3.3) rack (>= 1.0.0) rack-test (>= 0.5.4) @@ -118,7 +118,7 @@ GEM climate_control (0.2.0) cocaine (0.5.8) climate_control (>= 0.0.3, < 1.0) - coderay (1.1.1) + coderay (1.1.2) colorize (0.8.1) concurrent-ruby (1.0.5) connection_pool (2.2.1) @@ -154,16 +154,15 @@ GEM thread_safe encryptor (3.0.0) erubi (1.6.1) - erubis (2.7.0) et-orbi (1.0.5) tzinfo exception_notification (4.2.2) actionmailer (>= 4.0, < 6) activesupport (>= 4.0, < 6) - excon (0.58.0) + excon (0.59.0) execjs (2.7.0) - fabrication (2.16.2) - faker (1.7.3) + fabrication (2.16.3) + faker (1.8.4) i18n (~> 0.5) faraday (0.12.2) multipart-post (>= 1.2, < 3) @@ -202,7 +201,7 @@ GEM railties (>= 4.0.1) hamster (3.0.0) concurrent-ruby (~> 1.0) - hashdiff (0.3.5) + hashdiff (0.3.7) hashie (3.5.6) highline (1.7.8) hiredis (0.6.1) @@ -222,11 +221,11 @@ GEM colorize rack i18n (0.8.6) - i18n-tasks (0.9.16) + i18n-tasks (0.9.18) activesupport (>= 4.0.2) ast (>= 2.1.0) easy_translate (>= 0.5.0) - erubis + erubi highline (>= 1.7.3) i18n parser (>= 2.2.3.0) @@ -240,7 +239,7 @@ GEM json-ld (2.1.5) multi_json (~> 1.12) rdf (~> 2.2) - json-ld-preloaded (2.2.1) + json-ld-preloaded (2.2.2) json-ld (~> 2.1, >= 2.1.5) multi_json (~> 1.11) rdf (~> 2.2) @@ -267,10 +266,11 @@ GEM letter_opener (~> 1.0) railties (>= 3.2) link_header (0.0.8) - lograge (0.5.1) + lograge (0.6.0) actionpack (>= 4, < 5.2) activesupport (>= 4, < 5.2) railties (>= 4, < 5.2) + request_store (~> 1.0) loofah (2.0.3) nokogiri (>= 1.5.9) mail (2.6.6) @@ -285,28 +285,34 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) mimemagic (0.3.2) + mini_mime (0.1.4) mini_portile2 (2.2.0) minitest (5.10.3) msgpack (1.1.0) - multi_json (1.12.1) + multi_json (1.12.2) multi_xml (0.6.0) multipart-post (2.0.0) net-scp (1.2.1) net-ssh (>= 2.6.5) - net-ssh (4.1.0) + net-ssh (4.2.0) nio4r (2.1.0) nokogiri (1.8.0) mini_portile2 (~> 2.2.0) nokogumbo (1.4.13) nokogiri + nsa (0.2.4) + activesupport (>= 4.2, < 6) + concurrent-ruby (~> 1.0.0) + sidekiq (>= 3.5.0) + statsd-ruby (~> 1.2.0) oauth2 (1.4.0) faraday (>= 0.8, < 0.13) jwt (~> 1.0) multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - oj (3.3.4) - omniauth (1.6.1) + oj (3.3.5) + omniauth (1.7.1) hashie (>= 3.4.6, < 3.6.0) rack (>= 1.6.2, < 3) omniauth-niconico (0.0.1) @@ -314,14 +320,14 @@ GEM omniauth-oauth2 (1.4.0) oauth2 (~> 1.0) omniauth (~> 1.2) - openssl (2.0.4) + openssl (2.0.5) orm_adapter (0.5.0) ostatus2 (2.0.1) addressable (~> 2.4) http (~> 2.0) nokogiri (~> 1.6) openssl (~> 2.0) - ox (2.5.0) + ox (2.6.0) paperclip (5.1.0) activemodel (>= 4.2.0) activesupport (>= 4.2.0) @@ -331,15 +337,15 @@ GEM paperclip-av-transcoder (0.6.4) av (~> 0.9.0) paperclip (>= 2.5.2) - parallel (1.11.2) - parallel_tests (2.14.2) + parallel (1.12.0) + parallel_tests (2.15.0) parallel parser (2.4.0.0) ast (~> 2.2) pg (0.21.0) pghero (1.7.0) activerecord - pkg-config (1.2.4) + pkg-config (1.2.7) powerpack (0.1.1) pry (0.10.4) coderay (~> 1.1.0) @@ -359,6 +365,8 @@ GEM rack-cors (0.4.1) rack-protection (2.0.0) rack + rack-proxy (0.6.2) + rack rack-test (0.7.0) rack (>= 1.0, < 3) rack-timeout (0.4.2) @@ -396,8 +404,8 @@ GEM thor (>= 0.18.1, < 2.0) rainbow (2.2.2) rake - rake (12.0.0) - rdf (2.2.8) + rake (12.1.0) + rdf (2.2.9) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.3.2) @@ -421,6 +429,7 @@ GEM redis-store (>= 1.2, < 2) redis-store (1.3.0) redis (>= 2.2) + request_store (1.3.2) responders (2.4.0) actionpack (>= 4.2.0, < 5.3) railties (>= 4.2.0, < 5.3) @@ -435,7 +444,7 @@ GEM rspec-mocks (3.6.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.6.0) - rspec-rails (3.6.0) + rspec-rails (3.6.1) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) @@ -447,15 +456,15 @@ GEM rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) rspec-support (3.6.0) - rubocop (0.49.1) + rubocop (0.50.0) parallel (~> 1.10) parser (>= 2.3.3.1, < 3.0) powerpack (~> 0.1) - rainbow (>= 1.99.1, < 3.0) + rainbow (>= 2.2.2, < 3.0) ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) ruby-oembed (0.12.0) - ruby-progressbar (1.8.1) + ruby-progressbar (1.8.3) rufus-scheduler (3.4.2) et-orbi (~> 1.0) safe_yaml (1.0.4) @@ -463,7 +472,7 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.4.4) nokogumbo (~> 1.4.1) - sass (3.4.24) + sass (3.4.25) scss_lint (0.54.0) rake (>= 0.9, < 13) sass (~> 3.4.20) @@ -475,12 +484,12 @@ GEM sidekiq-bulk (0.1.1) activesupport sidekiq - sidekiq-scheduler (2.1.8) + sidekiq-scheduler (2.1.9) redis (~> 3) rufus-scheduler (~> 3.2) sidekiq (>= 3) tilt (>= 1.4.0) - sidekiq-unique-jobs (5.0.9) + sidekiq-unique-jobs (5.0.10) sidekiq (>= 4.0, <= 6.0) thor (~> 0) simple-navigation (4.0.5) @@ -488,11 +497,11 @@ GEM simple_form (3.5.0) actionpack (> 4, < 5.2) activemodel (> 4, < 5.2) - simplecov (0.14.1) + simplecov (0.15.1) docile (~> 1.1.0) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) - simplecov-html (0.10.1) + simplecov-html (0.10.2) slack-notifier (2.3.1) slop (3.6.0) sprockets (3.7.1) @@ -502,10 +511,12 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - sshkit (1.13.1) + sshkit (1.14.0) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) - statsd-instrument (2.1.4) + statsd-ruby (1.2.1) + strong_migrations (0.1.9) + activerecord (>= 3.2.0) temple (0.8.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) @@ -528,13 +539,13 @@ GEM uniform_notifier (1.10.0) warden (1.2.7) rack (>= 1.0) - webmock (3.0.1) + webmock (3.1.0) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff - webpacker (2.0) + webpacker (3.0.1) activesupport (>= 4.2) - multi_json (~> 1.2) + rack-proxy (>= 0.6.1) railties (>= 4.2) webpush (0.3.2) hkdf (~> 0.2) @@ -558,10 +569,10 @@ DEPENDENCIES better_errors (~> 2.1) binding_of_caller (~> 0.7) bootsnap - brakeman (~> 3.6) + brakeman (~> 4.0) browser bullet (~> 5.5) - bundler-audit (~> 0.5) + bundler-audit (~> 0.6) capistrano (~> 3.8) capistrano-rails (~> 1.2) capistrano-rbenv (~> 2.1) @@ -600,8 +611,9 @@ DEPENDENCIES microformats (~> 4.0) mime-types (~> 3.1) nokogiri (~> 1.7) + nsa (~> 0.2) oj (~> 3.0) - omniauth-niconico + omniauth-niconico (= 0.0.1) ostatus2 (~> 2.0) ox (~> 2.5) paperclip (~> 5.1) @@ -642,16 +654,16 @@ DEPENDENCIES simplecov (~> 0.14) slack-notifier (~> 2.3.1) sprockets-rails (~> 3.2) - statsd-instrument (~> 2.1) + strong_migrations twitter-text (~> 1.14) tzinfo-data (~> 1.2017) uglifier (~> 3.2) webmock (~> 3.0) - webpacker (~> 2.0) + webpacker (~> 3.0) webpush RUBY VERSION - ruby 2.4.1p111 + ruby 2.4.2p198 BUNDLED WITH 1.15.4 diff --git a/Procfile.dev b/Procfile.dev index 9084b4263ce831..e75a491c7b4219 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,4 +1,4 @@ web: PORT=3000 bundle exec puma -C config/puma.rb sidekiq: PORT=3000 bundle exec sidekiq stream: PORT=4000 yarn run start -webpack: ./bin/webpack-dev-server --host 0.0.0.0 +webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0 diff --git a/README.md b/README.md index 7b3fe447ee2863..a0bde5b9e6b515 100644 --- a/README.md +++ b/README.md @@ -26,46 +26,62 @@ friends.nicoへようこそ! [travis]: https://travis-ci.org/tootsuite/mastodon [code_climate]: https://codeclimate.com/github/tootsuite/mastodon -Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. +Mastodon is a **free, open-source social network server** based on **open web protocols** like ActivityPub and OStatus. The social focus of the project is a viable decentralized alternative to commercial social media silos that returns the control of the content distribution channels to the people. The technical focus of the project is a good user interface, a clean REST API for 3rd party apps and robust anti-abuse tools. -An alternative implementation of the GNU social project. Based on [ActivityStreams](https://en.wikipedia.org/wiki/Activity_Streams_(format)), [Webfinger](https://en.wikipedia.org/wiki/WebFinger), [WebSub](https://en.wikipedia.org/wiki/WebSub) and [Salmon](https://en.wikipedia.org/wiki/Salmon_(protocol)). - -Click on the screenshot to watch a demo of the UI: +Click on the screenshot below to watch a demo of the UI: [![Screenshot](https://i.imgur.com/pG3Nnz3.jpg)][youtube_demo] [youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU -The project focus is a clean REST API and a good user interface. Ruby on Rails is used for the back-end, while React.js and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided. +**Ruby on Rails** is used for the back-end, while **React.js** and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided. If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd` [patreon]: https://www.patreon.com/user?u=619786 +--- + ## Resources -- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md) -- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com) -- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) - [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md) +- [Use this tool to find Twitter friends on Mastodon](https://bridge.joinmastodon.org) +- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md) +- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md) - [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md) +- [List of sponsors](https://joinmastodon.org/sponsors) ## Features -- **Fully interoperable with GNU social and any OStatus platform** - Whatever implements Atom feeds, ActivityStreams, Salmon, WebSub and Webfinger is part of the network -- **Real-time timeline updates** - See the updates of people you're following appear in real-time in the UI via WebSockets -- **Federated thread resolving** - If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI -- **Media attachments like images and WebM** - Upload and view images and WebM videos attached to the updates -- **OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider so 3rd party apps can use the API, which is RESTful and simple -- **Background processing for long-running tasks** - Mastodon tries to be as fast and responsive as possible, so all long-running tasks that can be delegated to background processing, are -- **Deployable via Docker** - You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy +**No vendor lock-in: Fully interoperable with any conforming platform** + +It doesn't have to be Mastodon, whatever implements ActivityPub or OStatus is part of the social network! + +**Real-time timeline updates** + +See the updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well! + +**Federated thread resolving** + +If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI + +**Media attachments like images and short videos** + +Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos are looped - like vines! + +**OAuth2 and a straightforward REST API** + +Mastodon acts as an OAuth2 provider so 3rd party apps can use the API + +**Fast response times** + +Mastodon tries to be as fast and responsive as possible, so all long-running tasks are delegated to background processing + +**Deployable via Docker** + +You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy + +--- ## Development @@ -81,9 +97,8 @@ You can open issues for bugs you've found or features you think are missing. You **IRC channel**: #mastodon on irc.freenode.net -## Extra credits +--- -- The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis -- The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo) +## Extra credits -![Mastodon error image](https://mastodon.social/oops.png) +The elephant friend illustrations are created by [Dopatwo](https://mastodon.social/@dopatwo) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 26ab6636b54758..75915b33712f62 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -26,7 +26,10 @@ def show end format.json do - render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: @account, + serializer: ActivityPub::ActorSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end end end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index b37910b364d771..76553a162a24b0 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -9,9 +9,9 @@ def create if signed_request_account upgrade_account process_payload - head 201 - else head 202 + else + [signature_verification_failure_reason, 401] end end @@ -32,6 +32,7 @@ def upgrade_account end Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? + DeliveryFailureTracker.track_inverse_success!(signed_request_account) end def process_payload diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb new file mode 100644 index 00000000000000..414a875d04be4c --- /dev/null +++ b/app/controllers/admin/account_moderation_notes_controller.rb @@ -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 diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 54c659e1b9f137..ffa4dc850f0cd5 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -9,7 +9,10 @@ def index @accounts = filtered_accounts.page(params[:page]) end - def show; end + def show + @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) + @moderation_notes = @account.targeted_moderation_notes.latest + end def subscribe Pubsubhubbub::SubscribeWorker.perform_async(@account.id) diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb new file mode 100644 index 00000000000000..5cce5bce4667d7 --- /dev/null +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -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 diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb new file mode 100644 index 00000000000000..09275d5dc822d6 --- /dev/null +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -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 diff --git a/app/controllers/api/salmon_controller.rb b/app/controllers/api/salmon_controller.rb index e9e700b18de5c7..143e9d3cdc6b8b 100644 --- a/app/controllers/api/salmon_controller.rb +++ b/app/controllers/api/salmon_controller.rb @@ -7,9 +7,11 @@ class Api::SalmonController < Api::BaseController def update if verify_payload? process_salmon - head 201 - else head 202 + elsif payload.present? + [signature_verification_failure_reason, 401] + else + head 400 end end diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb index a88cf2021acf96..91a942d7530fb5 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -7,7 +7,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController respond_to :json def index - @accounts = Account.where(id: account_ids).select('id') + accounts = Account.where(id: account_ids).select('id') + # .where doesn't guarantee that our results are in the same order + # we requested them, so return the "right" order to the requestor. + @accounts = accounts.index_by(&:id).values_at(*account_ids) render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/apps/credentials_controller.rb b/app/controllers/api/v1/apps/credentials_controller.rb new file mode 100644 index 00000000000000..e469c7d210459e --- /dev/null +++ b/app/controllers/api/v1/apps/credentials_controller.rb @@ -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 diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index 44a27b20a2208b..e9f7a7291c15d9 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Api::V1::AppsController < Api::BaseController - respond_to :json - def create @app = Doorkeeper::Application.create!(application_options) render json: @app, serializer: REST::ApplicationSerializer diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index a412e434145805..3a6690766cf01a 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -15,19 +15,17 @@ def index private def load_accounts - default_accounts.merge(paginated_blocks).to_a - end - - def default_accounts - Account.includes(:blocked_by).references(:blocked_by) + paginated_blocks.map(&:target_account) end def paginated_blocks - Block.where(account: current_account).paginate_by_max_id( - limit_param(DEFAULT_ACCOUNTS_LIMIT), - params[:max_id], - params[:since_id] - ) + @paginated_blocks ||= Block.eager_load(:target_account) + .where(account: current_account) + .paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) end def insert_pagination_headers @@ -41,21 +39,21 @@ def next_path end def prev_path - unless @accounts.empty? + unless paginated_blocks.empty? api_v1_blocks_url pagination_params(since_id: pagination_since_id) end end def pagination_max_id - @accounts.last.blocked_by_ids.last + paginated_blocks.last.id end def pagination_since_id - @accounts.first.blocked_by_ids.first + paginated_blocks.first.id end def records_continue? - @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + paginated_blocks.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end def pagination_params(core_params) diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb new file mode 100644 index 00000000000000..f8cd64455a009b --- /dev/null +++ b/app/controllers/api/v1/custom_emojis_controller.rb @@ -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 diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 8a1992fca4160b..9f330f0dfe962d 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController respond_to :json def create - @media = current_account.media_attachments.create!(file: media_params[:file]) + @media = current_account.media_attachments.create!(media_params) render json: @media, serializer: REST::MediaAttachmentSerializer rescue Paperclip::Errors::NotIdentifiedByImageMagickError render json: file_type_error, status: 422 @@ -18,10 +18,16 @@ def create render json: processing_error, status: 500 end + def update + @media = current_account.media_attachments.where(status_id: nil).find(params[:id]) + @media.update!(media_params) + render json: @media, serializer: REST::MediaAttachmentSerializer + end + private def media_params - params.permit(:file) + params.permit(:file, :description) end def file_type_error diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0b40fb05b730f3..d5eca6ffb60125 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base helper_method :current_account helper_method :current_session + helper_method :current_theme helper_method :single_user_mode? rescue_from ActionController::RoutingError, with: :not_found @@ -77,6 +78,11 @@ def current_session @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id']) end + def current_theme + return Setting.default_settings['theme'] unless Themes.instance.names.include? current_user&.setting_theme + current_user.setting_theme + end + def cache_collection(raw, klass) return raw unless klass.respond_to?(:with_includes) diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 60ace04d7b1e4b..223db96ff24059 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -6,6 +6,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :check_enabled_registrations, only: [:new, :create] before_action :configure_sign_up_params, only: [:create] before_action :set_sessions, only: [:edit, :update] + before_action :set_instance_presenter, only: [:new, :create, :update] def destroy not_found @@ -39,6 +40,10 @@ def check_enabled_registrations private + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + def determine_layout %w(edit update).include?(action_name) ? 'admin' : 'auth' end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index bc3bd2f4bcd156..463a183e432a53 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -8,6 +8,7 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_no_authentication, only: [:create] skip_before_action :check_suspension, only: [:destroy] prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] + before_action :set_instance_presenter, only: [:new] def create super do |resource| @@ -84,6 +85,10 @@ def prompt_for_two_factor(user) private + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + def home_paths(resource) paths = [about_path] if single_user_mode? && resource.is_a?(User) diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 4211283ed737a4..2baafb5bf5d0a5 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -9,10 +9,15 @@ def signed_request? request.headers['Signature'].present? end + def signature_verification_failure_reason + return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason) + end + def signed_request_account return @signed_request_account if defined?(@signed_request_account) unless signed_request? + @signature_verification_failure_reason = 'Request not signed' @signed_request_account = nil return end @@ -27,6 +32,7 @@ def signed_request_account end if incompatible_signature?(signature_params) + @signature_verification_failure_reason = 'Incompatible request signature' @signed_request_account = nil return end @@ -34,6 +40,7 @@ def signed_request_account account = account_from_key_id(signature_params['keyId']) if account.nil? + @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" @signed_request_account = nil return end @@ -44,7 +51,18 @@ def signed_request_account if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) @signed_request_account = account @signed_request_account + elsif account.possibly_stale? + account = account.refresh! + + if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) + @signed_request_account = account + @signed_request_account + else + @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" + @signed_request_account = nil + end else + @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" @signed_request_account = nil end end @@ -99,7 +117,7 @@ def account_from_key_id(key_id) ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) elsif !ActivityPub::TagManager.instance.local_uri?(key_id) account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) - account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) + account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) account end end diff --git a/app/controllers/concerns/user_tracking_concern.rb b/app/controllers/concerns/user_tracking_concern.rb index 8a63af95d0aa3d..8663c3086b2dfa 100644 --- a/app/controllers/concerns/user_tracking_concern.rb +++ b/app/controllers/concerns/user_tracking_concern.rb @@ -7,12 +7,14 @@ module UserTrackingConcern UPDATE_SIGN_IN_HOURS = 24 included do - before_action :set_user_activity, if: %i(user_signed_in? user_needs_sign_in_update?) + before_action :set_user_activity end private def set_user_activity + return unless user_needs_sign_in_update? + # Mark as signed-in today current_user.update_tracked_fields!(request) @@ -21,7 +23,7 @@ def set_user_activity end 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 def user_needs_feed_update? diff --git a/app/controllers/emojis_controller.rb b/app/controllers/emojis_controller.rb new file mode 100644 index 00000000000000..a82b9340bf83ae --- /dev/null +++ b/app/controllers/emojis_controller.rb @@ -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 diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 0e194989790006..399e79665e728b 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -10,19 +10,39 @@ def index format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end end end private + def page_url(page) + account_followers_url(@account, page: page) unless page.nil? + end + def collection_presenter - ActivityPub::CollectionPresenter.new( - id: account_followers_url(@account), + page = ActivityPub::CollectionPresenter.new( + id: account_followers_url(@account, page: params.fetch(:page, 1)), type: :ordered, size: @account.followers_count, - items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) } + items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, + part_of: account_followers_url(@account), + next: page_url(@follows.next_page), + prev: page_url(@follows.prev_page) ) + if params[:page].present? + page + else + ActivityPub::CollectionPresenter.new( + id: account_followers_url(@account), + type: :ordered, + size: @account.followers_count, + first: page + ) + end end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index d4593093ff643b..1e73d4bd4087a0 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -10,19 +10,39 @@ def index format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end end end private + def page_url(page) + account_following_index_url(@account, page: page) unless page.nil? + end + def collection_presenter - ActivityPub::CollectionPresenter.new( - id: account_following_index_url(@account), + page = ActivityPub::CollectionPresenter.new( + id: account_following_index_url(@account, page: params.fetch(:page, 1)), type: :ordered, size: @account.following_count, - items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) } + items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }, + part_of: account_following_index_url(@account), + next: page_url(@follows.next_page), + prev: page_url(@follows.prev_page) ) + if params[:page].present? + page + else + ActivityPub::CollectionPresenter.new( + id: account_following_index_url(@account), + type: :ordered, + size: @account.following_count, + first: page + ) + end end end diff --git a/app/controllers/manifests_controller.rb b/app/controllers/manifests_controller.rb index 832e1eb6f67e79..ac267c229459b2 100644 --- a/app/controllers/manifests_controller.rb +++ b/app/controllers/manifests_controller.rb @@ -1,11 +1,7 @@ # frozen_string_literal: true class ManifestsController < ApplicationController - before_action :set_instance_presenter - - def show; end - - def set_instance_presenter - @instance_presenter = InstancePresenter.new + def show + render json: InstancePresenter.new, serializer: ManifestSerializer end end diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index 6a83cf9dc06eb5..155670837eb740 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -18,7 +18,7 @@ def show def redownload! @media_attachment.file_remote_url = @media_attachment.remote_url - @media_attachment.touch(:created_at) + @media_attachment.created_at = Time.now.utc @media_attachment.save! end diff --git a/app/controllers/settings/follower_domains_controller.rb b/app/controllers/settings/follower_domains_controller.rb index 90b48887facd74..9968504e5f74e4 100644 --- a/app/controllers/settings/follower_domains_controller.rb +++ b/app/controllers/settings/follower_domains_controller.rb @@ -9,7 +9,7 @@ class Settings::FollowerDomainsController < ApplicationController def show @account = current_account - @domains = current_account.followers.reorder(nil).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) + @domains = current_account.followers.reorder('MIN(follows.id) DESC').group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) end def update diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb new file mode 100644 index 00000000000000..09839f16eaa1cb --- /dev/null +++ b/app/controllers/settings/notifications_controller.rb @@ -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 diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index f107f2b165f844..0690267151c958 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -39,8 +39,10 @@ def user_settings_params :setting_boost_modal, :setting_delete_modal, :setting_auto_play_gif, + :setting_reduce_motion, :setting_system_font_ui, :setting_noindex, + :setting_theme, notification_emails: %i(follow follow_request reblog favourite mention digest), interactions: %i(must_be_follower must_be_following) ) diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 65206ea969e549..e8a360fb5752eb 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -21,13 +21,19 @@ def show end format.json do - render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: @status, + serializer: ActivityPub::NoteSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end end end def activity - render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: @status, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end def embed diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 3001b2ee314c52..9f3090e37be363 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -1,24 +1,40 @@ # frozen_string_literal: true class TagsController < ApplicationController - layout 'public' + before_action :set_body_classes + before_action :set_instance_presenter def show - @tag = Tag.find_by!(name: params[:id].downcase) - @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) - @statuses = cache_collection(@statuses, Status) + @tag = Tag.find_by!(name: params[:id].downcase) respond_to do |format| - format.html + format.html do + serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) + @initial_state_json = serializable_resource.to_json + end format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) + @statuses = cache_collection(@statuses, Status) + + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' end end end private + def set_body_classes + @body_classes = 'tag-body' + end + + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + def collection_presenter ActivityPub::CollectionPresenter.new( id: tag_url(@tag), @@ -27,4 +43,11 @@ def collection_presenter items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } ) end + + def initial_state_params + { + settings: {}, + token: current_session&.token, + } + end end diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb new file mode 100644 index 00000000000000..b17c522643d234 --- /dev/null +++ b/app/helpers/admin/account_moderation_notes_helper.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +module Admin::AccountModerationNotesHelper +end diff --git a/app/helpers/emoji_helper.rb b/app/helpers/emoji_helper.rb deleted file mode 100644 index 848c03fce721aa..00000000000000 --- a/app/helpers/emoji_helper.rb +++ /dev/null @@ -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 diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index d82a073320d3b4..c23a2e095278d4 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -22,7 +22,18 @@ def canonicalize(json) graph.dump(:normalize) end - def fetch_resource(uri) + def fetch_resource(uri, id) + unless id + json = fetch_resource_without_id_validation(uri) + return unless json + uri = json['id'] + end + + json = fetch_resource_without_id_validation(uri) + json.present? && json['id'] == uri ? json : nil + end + + def fetch_resource_without_id_validation(uri) response = build_request(uri).perform return if response.code != 200 body_to_json(response.to_s) diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 14776b35400c7a..abce858123425e 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -27,6 +27,7 @@ module SettingsHelper pt: 'Português', 'pt-BR': 'Português do Brasil', ru: 'Русский', + sv: 'Svenska', th: 'ภาษาไทย', tr: 'Türkçe', uk: 'Українська', diff --git a/app/javascript/__mock__/fileMock.js b/app/javascript/__mock__/fileMock.js new file mode 100644 index 00000000000000..f053ebf7976e37 --- /dev/null +++ b/app/javascript/__mock__/fileMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 03e3d3d9f94f1f..73d6baaceaa61b 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -122,7 +122,7 @@ export function unfollowAccount(id) { dispatch(unfollowAccountRequest(id)); api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { - dispatch(unfollowAccountSuccess(response.data)); + dispatch(unfollowAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { dispatch(unfollowAccountFail(error)); }); @@ -157,10 +157,11 @@ export function unfollowAccountRequest(id) { }; }; -export function unfollowAccountSuccess(relationship) { +export function unfollowAccountSuccess(relationship, statuses) { return { type: ACCOUNT_UNFOLLOW_SUCCESS, relationship, + statuses, }; }; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index c47840911c45c8..7d9aec6e505fd2 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -1,4 +1,7 @@ import api from '../api'; +import { throttle } from 'lodash'; +import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; +import { useEmoji } from './emojis'; import { updateTimeline, @@ -14,6 +17,7 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; +export const COMPOSE_RESET = 'COMPOSE_RESET'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; @@ -41,6 +45,10 @@ export const COMPOSE_PROFILE_EMOJI_SUGGESTIONS_CLEAR = 'COMPOSE_PROFILE_EMOJI_SU export const COMPOSE_PROFILE_EMOJI_SUGGESTIONS_READY = 'COMPOSE_PROFILE_EMOJI_SUGGESTIONS_READY'; export const COMPOSE_PROFILE_EMOJI_SUGGESTION_SELECT = 'COMPOSE_PROFILE_EMOJI_SUGGESTION_SELECT'; +export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; +export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; +export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -67,6 +75,12 @@ export function cancelReplyCompose() { }; }; +export function resetCompose() { + return { + type: COMPOSE_RESET, + }; +}; + export function mentionCompose(account, router) { return (dispatch, getState) => { dispatch({ @@ -181,6 +195,40 @@ export function uploadCompose(files) { }; }; +export function changeUploadCompose(id, description) { + return (dispatch, getState) => { + dispatch(changeUploadComposeRequest()); + + api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { + dispatch(changeUploadComposeSuccess(response.data)); + }).catch(error => { + dispatch(changeUploadComposeFail(id, error)); + }); + }; +}; + +export function changeUploadComposeRequest() { + return { + type: COMPOSE_UPLOAD_CHANGE_REQUEST, + skipLoading: true, + }; +}; +export function changeUploadComposeSuccess(media) { + return { + type: COMPOSE_UPLOAD_CHANGE_SUCCESS, + media: media, + skipLoading: true, + }; +}; + +export function changeUploadComposeFail(error) { + return { + type: COMPOSE_UPLOAD_CHANGE_FAIL, + error: error, + skipLoading: true, + }; +}; + export function uploadComposeRequest() { return { type: COMPOSE_UPLOAD_REQUEST, @@ -225,21 +273,46 @@ export function clearComposeSuggestions() { }; }; +const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { + api(getState).get('/api/v1/accounts/search', { + params: { + q: token.slice(1), + resolve: false, + limit: 4, + }, + }).then(response => { + dispatch(readyComposeSuggestionsAccounts(token, response.data)); + }); +}, 200, { leading: true, trailing: true }); + +const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { + const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); + dispatch(readyComposeSuggestionsEmojis(token, results)); +}; + export function fetchComposeSuggestions(token) { return (dispatch, getState) => { - api(getState).get('/api/v1/accounts/search', { - params: { - q: token, - resolve: false, - limit: 4, - }, - }).then(response => { - dispatch(readyComposeSuggestions(token, response.data)); - }); + if (token[0] === ':') { + if (token[1] === '@') { + fetchComposeSuggestionsAccounts(dispatch, getState, token.slice(1)); + } else { + fetchComposeSuggestionsEmojis(dispatch, getState, token); + } + } else { + fetchComposeSuggestionsAccounts(dispatch, getState, token); + } + }; +}; + +export function readyComposeSuggestionsEmojis(token, emojis) { + return { + type: COMPOSE_SUGGESTIONS_READY, + token, + emojis, }; }; -export function readyComposeSuggestions(token, accounts) { +export function readyComposeSuggestionsAccounts(token, accounts) { return { type: COMPOSE_SUGGESTIONS_READY, token, @@ -247,13 +320,26 @@ export function readyComposeSuggestions(token, accounts) { }; }; -export function selectComposeSuggestion(position, token, accountId) { +export function selectComposeSuggestion(position, token, suggestion) { return (dispatch, getState) => { - const completion = getState().getIn(['accounts', accountId, 'acct']); + let completion, startPosition; + + if (typeof suggestion === 'object' && suggestion.id) { + completion = suggestion.native || suggestion.colons; + startPosition = position - 1; + + dispatch(useEmoji(suggestion)); + } else { + completion = getState().getIn(['accounts', suggestion, 'acct']); + if (token[1] === '@') { + completion = `@${completion}:`; + } + startPosition = position; + } dispatch({ type: COMPOSE_SUGGESTION_SELECT, - position, + position: startPosition, token, completion, }); diff --git a/app/javascript/mastodon/actions/emojis.js b/app/javascript/mastodon/actions/emojis.js new file mode 100644 index 00000000000000..7cd9d4b7b359f1 --- /dev/null +++ b/app/javascript/mastodon/actions/emojis.js @@ -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()); + }; +}; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index c7d2481227386d..b24ac8b733858b 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -31,6 +31,7 @@ const fetchRelatedRelationships = (dispatch, notifications) => { const unescapeHTML = (html) => { const wrapper = document.createElement('div'); + html = html.replace(/
|
|\n/, ' '); wrapper.innerHTML = html; return wrapper.textContent; }; diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js index f9d304c96a37bd..79adca18c6ea90 100644 --- a/app/javascript/mastodon/actions/settings.js +++ b/app/javascript/mastodon/actions/settings.js @@ -1,6 +1,8 @@ import axios from 'axios'; +import { debounce } from 'lodash'; export const SETTING_CHANGE = 'SETTING_CHANGE'; +export const SETTING_SAVE = 'SETTING_SAVE'; export function changeSetting(key, value) { return dispatch => { @@ -14,10 +16,16 @@ export function changeSetting(key, value) { }; }; +const debouncedSave = debounce((dispatch, getState) => { + if (getState().getIn(['settings', 'saved'])) { + return; + } + + const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS(); + + axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE })); +}, 5000, { trailing: true }); + export function saveSettings() { - return (_, getState) => { - axios.put('/api/web/settings', { - data: getState().get('settings').toJS(), - }); - }; + return (dispatch, getState) => debouncedSave(dispatch, getState); }; diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js index 0597d265ec40b5..a1db0fdd51c60c 100644 --- a/app/javascript/mastodon/actions/store.js +++ b/app/javascript/mastodon/actions/store.js @@ -5,8 +5,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; const convertState = rawState => fromJS(rawState, (k, v) => - Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x => - Number.isNaN(x * 1) ? x : x * 1)); + Iterable.isIndexed(v) ? v.toList() : v.toMap()); export function hydrateStore(rawState) { const state = convertState(rawState); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index b6f9a34e1cc549..bb59800bab7c8f 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -18,6 +18,8 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; + export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { return { type: TIMELINE_REFRESH_SUCCESS, @@ -31,6 +33,16 @@ export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { export function updateTimeline(timeline, status) { return (dispatch, getState) => { const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; + const parents = []; + + if (status.in_reply_to_id) { + let parent = getState().getIn(['statuses', status.in_reply_to_id]); + + while (parent && parent.get('in_reply_to_id')) { + parents.push(parent.get('id')); + parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]); + } + } dispatch(checkHighlightNotification(status)); @@ -40,6 +52,14 @@ export function updateTimeline(timeline, status) { status, references, }); + + if (parents.length > 0) { + dispatch({ + type: TIMELINE_CONTEXT_UPDATE, + status, + references: parents, + }); + } }; }; diff --git a/app/javascript/mastodon/base_polyfills.js b/app/javascript/mastodon/base_polyfills.js index 266a0020cc85fb..7856b26f9d8860 100644 --- a/app/javascript/mastodon/base_polyfills.js +++ b/app/javascript/mastodon/base_polyfills.js @@ -1,5 +1,5 @@ import 'intl'; -import 'intl/locale-data/jsonp/en.js'; +import 'intl/locale-data/jsonp/en'; import 'es6-symbol/implement'; import includes from 'array-includes'; import assign from 'object-assign'; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap new file mode 100644 index 00000000000000..76ab3374ae8488 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Autoplay renders a animated avatar 1`] = ` +
+`; + +exports[` Still renders a still avatar 1`] = ` +
+`; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap new file mode 100644 index 00000000000000..d59fee42f66e5c --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` +
+
+
+`; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap new file mode 100644 index 00000000000000..c3f018d90e507d --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap @@ -0,0 +1,114 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` +`; + +exports[` +`; + +exports[` +`; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap new file mode 100644 index 00000000000000..533359ffea0763 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders display name + account name 1`] = ` + + Foo

", + } + } + /> + + + @ + bar@baz + +
+`; diff --git a/app/javascript/mastodon/components/__tests__/avatar-test.js b/app/javascript/mastodon/components/__tests__/avatar-test.js new file mode 100644 index 00000000000000..dd3f7b7d2104f0 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/avatar-test.js @@ -0,0 +1,36 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { fromJS } from 'immutable'; +import Avatar from '../avatar'; + +describe('', () => { + 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(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + }); + + describe('Still', () => { + it('renders a still avatar', () => { + const component = renderer.create(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + }); + + // TODO add autoplay test if possible +}); diff --git a/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js new file mode 100644 index 00000000000000..44addea832f723 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js @@ -0,0 +1,29 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { fromJS } from 'immutable'; +import AvatarOverlay from '../avatar_overlay'; + +describe(' { + 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(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/app/javascript/mastodon/components/__tests__/button-test.js b/app/javascript/mastodon/components/__tests__/button-test.js new file mode 100644 index 00000000000000..160cd3cbc75306 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/button-test.js @@ -0,0 +1,75 @@ +import { shallow } from 'enzyme'; +import React from 'react'; +import renderer from 'react-test-renderer'; +import Button from '../button'; + +describe('); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders the props.text instead of children', () => { + const text = 'foo'; + const children =

children

; + const component = renderer.create(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders class="button--block" if props.block given', () => { + const component = renderer.create(