diff --git a/.cache/.gitkeep b/.cache/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/.env.nanobox b/.env.nanobox index cfbe487fb..03aa01a34 100644 --- a/.env.nanobox +++ b/.env.nanobox @@ -183,6 +183,11 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # LDAP_BIND_DN= # LDAP_PASSWORD= # LDAP_UID=cn +# LDAP_MAIL=mail +# LDAP_SEARCH_FILTER=(|(%{uid}=%{email})(%{mail}=%{email})) +# LDAP_UID_CONVERSION_ENABLED=true +# LDAP_UID_CONVERSION_SEARCH=., - +# LDAP_UID_CONVERSION_REPLACE=_ # PAM authentication (optional) # PAM authentication uses for the email generation the "email" pam variable diff --git a/.env.production.sample b/.env.production.sample index f9a8bb7c1..9cab992e3 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -178,7 +178,11 @@ STREAMING_CLUSTER_NUM=1 # LDAP_BIND_DN= # LDAP_PASSWORD= # LDAP_UID=cn -# LDAP_SEARCH_FILTER=%{uid}=%{email} +# LDAP_MAIL=mail +# LDAP_SEARCH_FILTER=(|(%{uid}=%{email})(%{mail}=%{email})) +# LDAP_UID_CONVERSION_ENABLED=true +# LDAP_UID_CONVERSION_SEARCH=., - +# LDAP_UID_CONVERSION_REPLACE=_ # PAM authentication (optional) # PAM authentication uses for the email generation the "email" pam variable diff --git a/.env.test b/.env.test index fa4e1d91f..761d0d921 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,5 @@ # Node.js -NODE_ENV=test +NODE_ENV=tests # Federation LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_HTTPS=true diff --git a/.env.vagrant b/.env.vagrant index f3b54f6e3..c2d26fa45 100644 --- a/.env.vagrant +++ b/.env.vagrant @@ -1,2 +1,3 @@ VAGRANT=true LOCAL_DOMAIN=mastodon.local +BIND=0.0.0.0 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..768868516 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Mastodon Meta Discussion Board + url: https://discourse.joinmastodon.org/ + about: Please ask and answer questions here. diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 000000000..6601ef8c0 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,10 @@ +daysUntilStale: 120 +daysUntilClose: 7 +exemptLabels: + - security +staleLabel: wontfix +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +only: pulls diff --git a/.gitignore b/.gitignore index f3d9926c1..c867f8634 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,9 @@ npm-debug.log yarn-error.log yarn-debug.log +# Ignore vagrant log files +ubuntu-xenial-16.04-cloudimg-console.log + # Ignore Docker option files docker-compose.override.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0d23a22..b200747b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,39 @@ Changelog All notable changes to this project will be documented in this file. +## [3.0.1] - 2019-10-10 +### Added + +- Add `tootctl media usage` command ([Gargron](https://github.com/tootsuite/mastodon/pull/12115)) +- Add admin setting to auto-approve trending hashtags ([Gargron](https://github.com/tootsuite/mastodon/pull/12122), [Gargron](https://github.com/tootsuite/mastodon/pull/12130)) + +### Changed + +- Change `tootctl media refresh` to skip already downloaded attachments ([Gargron](https://github.com/tootsuite/mastodon/pull/12118)) + +### Removed + +- Remove auto-silence behaviour from spam check ([Gargron](https://github.com/tootsuite/mastodon/pull/12117)) +- Remove HTML `lang` attribute from individual statuses in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12124)) +- Remove fallback to long description on sidebar and meta description ([Gargron](https://github.com/tootsuite/mastodon/pull/12119)) + +### Fixed + +- Fix preloaded JSON-LD context for identity not being used ([Gargron](https://github.com/tootsuite/mastodon/pull/12138)) +- Fix media editing modal changing dimensions once the image loads ([Gargron](https://github.com/tootsuite/mastodon/pull/12131)) +- Fix not showing whether a custom emoji has a local counterpart in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12135)) +- Fix attachment not being re-downloaded even if file is not stored ([Gargron](https://github.com/tootsuite/mastodon/pull/12125)) +- Fix old migration trying to use new column due to default status scope ([Gargron](https://github.com/tootsuite/mastodon/pull/12095)) +- Fix column back button missing for not found accounts ([trwnh](https://github.com/tootsuite/mastodon/pull/12094)) +- Fix issues with tootctl's parallelization and progress reporting ([Gargron](https://github.com/tootsuite/mastodon/pull/12093), [Gargron](https://github.com/tootsuite/mastodon/pull/12097)) +- Fix existing user records with now-renamed `pt` locale ([Gargron](https://github.com/tootsuite/mastodon/pull/12092)) +- Fix hashtag timeline REST API accepting too many hashtags ([Gargron](https://github.com/tootsuite/mastodon/pull/12091)) +- Fix `GET /api/v1/instance` REST APIs being unavailable in secure mode ([Gargron](https://github.com/tootsuite/mastodon/pull/12089)) +- Fix performance of home feed regeneration and merging ([Gargron](https://github.com/tootsuite/mastodon/pull/12084)) +- Fix ffmpeg performance issues due to stdout buffer overflow ([hugogameiro](https://github.com/tootsuite/mastodon/pull/12088)) +- Fix S3 adapter retrying failing uploads with exponential backoff ([Gargron](https://github.com/tootsuite/mastodon/pull/12085)) +- Fix `tootctl accounts cull` advertising unused option flag ([Kjwon15](https://github.com/tootsuite/mastodon/pull/12074)) + ## [3.0.0] - 2019-10-03 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76f512198..f7b8f17cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,13 +14,13 @@ If your contributions are accepted into Mastodon, you can request to be paid thr ## Bug reports -Bug reports and feature suggestions can be submitted to [GitHub Issues](https://github.com/tootsuite/mastodon/issues). Please make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected in the past using the search function. Please also use descriptive, concise titles. +Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/tootsuite/mastodon/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected. ## Translations You can submit translations via [Crowdin](https://crowdin.com/project/mastodon). They are periodically merged into the codebase. -[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] +[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)](https://crowdin.com/project/mastodon) ## Pull requests diff --git a/Dockerfile b/Dockerfile index e963674a5..117727d08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,8 @@ FROM ubuntu:18.04 as build-dep # Use bash for the shell SHELL ["bash", "-c"] -# Install Node -ENV NODE_VER="12.11.1" +# Install Node v12 (LTS) +ENV NODE_VER="12.13.1" RUN echo "Etc/UTC" > /etc/localtime && \ apt update && \ apt -y install wget python && \ @@ -123,3 +123,4 @@ RUN cd ~ && \ # Set the work dir and the container entry point WORKDIR /opt/mastodon ENTRYPOINT ["/tini", "--"] +EXPOSE 3000 4000 diff --git a/Gemfile b/Gemfile index 6f1fcb6f1..559cb99c7 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source 'https://rubygems.org' ruby '>= 2.4.0', '< 2.7.0' -gem 'pkg-config', '~> 1.3' +gem 'pkg-config', '~> 1.4' gem 'puma', '~> 4.2' gem 'rails', '~> 5.2.3' @@ -12,10 +12,10 @@ gem 'thor', '~> 0.20' gem 'hamlit-rails', '~> 0.2' gem 'pg', '~> 1.1' gem 'makara', '~> 0.4' -gem 'pghero', '~> 2.3' +gem 'pghero', '~> 2.4' gem 'dotenv-rails', '~> 2.7' -gem 'aws-sdk-s3', '~> 1.48', require: false +gem 'aws-sdk-s3', '~> 1.57', require: false gem 'fog-core', '<= 2.1.0' gem 'fog-openstack', '~> 0.3', require: false gem 'paperclip', '~> 6.0' @@ -27,7 +27,7 @@ gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.7' gem 'bootsnap', '~> 1.4', require: false gem 'browser' -gem 'charlock_holmes', '~> 0.7.6' +gem 'charlock_holmes', '~> 0.7.7' gem 'iso-639' gem 'chewy', '~> 5.1' gem 'cld3', '~> 3.2.4' @@ -38,7 +38,7 @@ group :pam_authentication, optional: true do gem 'devise_pam_authenticatable2', '~> 9.2' end -gem 'net-ldap', '~> 0.10' +gem 'net-ldap', '~> 0.16' gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.9' @@ -67,12 +67,12 @@ gem 'oj', '~> 3.9' gem 'ostatus2', '~> 2.0' gem 'ox', '~> 2.11' gem 'parslet' -gem 'parallel', '~> 1.17' +gem 'parallel', '~> 1.19' gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c' gem 'pundit', '~> 2.1' gem 'premailer-rails' -gem 'rack-attack', '~> 6.1' -gem 'rack-cors', '~> 1.0', require: 'rack/cors' +gem 'rack-attack', '~> 6.2' +gem 'rack-cors', '~> 1.1', require: 'rack/cors' gem 'rails-i18n', '~> 5.1' gem 'rails-settings-cached', '~> 0.6' gem 'redis', '~> 4.1', require: ['redis', 'redis/connection/hiredis'] @@ -85,15 +85,15 @@ gem 'sidekiq-scheduler', '~> 3.0' gem 'sidekiq-unique-jobs', '~> 6.0' gem 'sidekiq-bulk', '~>0.2.0' gem 'simple-navigation', '~> 4.1' -gem 'simple_form', '~> 4.1' +gem 'simple_form', '~> 5.0' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' -gem 'stoplight', '~> 2.1.3' +gem 'stoplight', '~> 2.2.0' gem 'strong_migrations', '~> 0.4' gem 'tty-command', '~> 0.9', require: false -gem 'tty-prompt', '~> 0.19', require: false +gem 'tty-prompt', '~> 0.20', require: false gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2019' -gem 'webpacker', '~> 4.0' +gem 'webpacker', '~> 4.2' gem 'webpush' gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: 'e742697a0906e74e8bb777ef98137bc3955d981d' @@ -101,12 +101,12 @@ gem 'json-ld-preloaded', '~> 3.0' gem 'rdf-normalize', '~> 0.3' group :development, :test do - gem 'fabrication', '~> 2.20' - gem 'fuubar', '~> 2.4' + gem 'fabrication', '~> 2.21' + gem 'fuubar', '~> 2.5' gem 'i18n-tasks', '~> 0.9', require: false gem 'pry-byebug', '~> 3.7' gem 'pry-rails', '~> 0.3' - gem 'rspec-rails', '~> 3.8' + gem 'rspec-rails', '~> 3.9' end group :production, :test do @@ -116,7 +116,7 @@ end group :test do gem 'capybara', '~> 3.29' gem 'climate_control', '~> 0.2' - gem 'faker', '~> 2.5' + gem 'faker', '~> 2.7' gem 'microformats', '~> 4.1' gem 'rails-controller-testing', '~> 1.0' gem 'rspec-sidekiq', '~> 3.0' @@ -126,17 +126,17 @@ group :test do end group :development do - gem 'active_record_query_trace', '~> 1.6' - gem 'annotate', '~> 2.7' + gem 'active_record_query_trace', '~> 1.7' + gem 'annotate', '~> 3.0' gem 'better_errors', '~> 2.5' gem 'binding_of_caller', '~> 0.7' gem 'bullet', '~> 6.0' gem 'letter_opener', '~> 1.7' gem 'letter_opener_web', '~> 1.3' gem 'memory_profiler' - gem 'rubocop', '~> 0.74', require: false - gem 'rubocop-rails', '~> 2.3', require: false - gem 'brakeman', '~> 4.6', require: false + gem 'rubocop', '~> 0.76', require: false + gem 'rubocop-rails', '~> 2.4', require: false + gem 'brakeman', '~> 4.7', require: false gem 'bundler-audit', '~> 0.6', require: false gem 'capistrano', '~> 3.11' diff --git a/Gemfile.lock b/Gemfile.lock index 3c52f378f..4cd40c214 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -72,7 +72,7 @@ GEM activemodel (>= 4.1, < 6.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - active_record_query_trace (1.6.2) + active_record_query_trace (1.7) activejob (5.2.3) activesupport (= 5.2.3) globalid (>= 0.3.6) @@ -95,9 +95,9 @@ GEM public_suffix (>= 2.0.2, < 5.0) airbrussh (1.3.4) sshkit (>= 1.6.1, != 1.7.0) - annotate (2.7.5) + annotate (3.0.3) activerecord (>= 3.2, < 7.0) - rake (>= 10.4, < 13.0) + rake (>= 10.4, < 14.0) arel (9.0.0) ast (2.4.0) attr_encrypted (3.1.0) @@ -105,17 +105,17 @@ GEM av (0.9.0) cocaine (~> 0.5.3) aws-eventstream (1.0.3) - aws-partitions (1.207.0) - aws-sdk-core (3.65.1) + aws-partitions (1.246.0) + aws-sdk-core (3.82.0) aws-eventstream (~> 1.0, >= 1.0.2) - aws-partitions (~> 1.0) + aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.24.0) - aws-sdk-core (~> 3, >= 3.61.1) + aws-sdk-kms (1.26.0) + aws-sdk-core (~> 3, >= 3.71.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.48.0) - aws-sdk-core (~> 3, >= 3.61.1) + aws-sdk-s3 (1.57.0) + aws-sdk-core (~> 3, >= 3.77.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) aws-sigv4 (1.1.0) @@ -132,8 +132,8 @@ GEM ffi (~> 1.10.0) bootsnap (1.4.5) msgpack (~> 1.0) - brakeman (4.6.1) - browser (2.6.1) + brakeman (4.7.1) + browser (2.7.1) builder (3.2.3) bullet (6.0.2) activesupport (>= 3.0.0) @@ -168,7 +168,7 @@ GEM xpath (~> 3.2) case_transform (0.2) activesupport - charlock_holmes (0.7.6) + charlock_holmes (0.7.7) chewy (5.1.0) activesupport (>= 4.0) elasticsearch (>= 2.0.0) @@ -184,17 +184,17 @@ GEM connection_pool (2.2.2) crack (0.4.3) safe_yaml (~> 1.0.0) - crass (1.0.4) + crass (1.0.5) css_parser (1.7.0) addressable debug_inspector (0.0.3) - derailed_benchmarks (1.4.0) + derailed_benchmarks (1.4.2) benchmark-ips (~> 2) get_process_mem (~> 0) heapy (~> 0) memory_profiler (~> 0) rack (>= 1) - rake (> 10, < 13) + rake (> 10, < 14) ruby-statistics (>= 2.1) thor (~> 0.19) devise (4.7.1) @@ -218,7 +218,7 @@ GEM docile (1.3.2) domain_name (0.5.20180417) unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.2.1) + doorkeeper (5.2.2) railties (>= 5) dotenv (2.7.5) dotenv-rails (2.7.5) @@ -235,13 +235,13 @@ GEM multi_json encryptor (3.0.0) equatable (0.6.1) - erubi (1.8.0) + erubi (1.9.0) et-orbi (1.1.6) tzinfo excon (0.62.0) - fabrication (2.20.2) - faker (2.5.0) - i18n (~> 1.6.0) + fabrication (2.21.0) + faker (2.7.0) + i18n (>= 1.6, < 1.8) faraday (0.15.4) multipart-post (>= 1.2, < 3) fast_blank (1.0.0) @@ -263,10 +263,10 @@ GEM fugit (1.1.6) et-orbi (~> 1.1, >= 1.1.6) raabro (~> 1.1) - fuubar (2.4.1) + fuubar (2.5.0) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) - get_process_mem (0.2.4) + get_process_mem (0.2.5) ffi (~> 1.0) globalid (0.4.2) activesupport (>= 4.2.0) @@ -302,10 +302,10 @@ GEM domain_name (~> 0.5) http-form_data (2.1.1) http_accept_language (2.1.1) - httplog (1.3.2) + httplog (1.3.3) rack (>= 1.0) rainbow (>= 2.0.0) - i18n (1.6.0) + i18n (1.7.0) concurrent-ruby (~> 1.0) i18n-tasks (0.9.29) activesupport (>= 4.0.2) @@ -320,7 +320,7 @@ GEM idn-ruby (0.1.0) ipaddress (0.8.3) iso-639 (0.2.8) - jaro_winkler (1.5.3) + jaro_winkler (1.5.4) jmespath (1.4.0) json (2.2.0) json-canonicalization (0.1.0) @@ -356,7 +356,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.2.3) + loofah (2.3.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -378,17 +378,17 @@ GEM mimemagic (0.3.3) mini_mime (1.0.2) mini_portile2 (2.4.0) - minitest (5.12.0) + minitest (5.13.0) msgpack (1.3.1) multi_json (1.13.1) multipart-post (2.1.1) - necromancer (0.5.0) - net-ldap (0.16.1) + necromancer (0.5.1) + net-ldap (0.16.2) net-scp (2.0.0) net-ssh (>= 2.6.5, < 6.0.0) net-ssh (5.2.0) nio4r (2.5.1) - nokogiri (1.10.4) + nokogiri (1.10.5) mini_portile2 (~> 2.4.0) nokogumbo (2.0.1) nokogiri (~> 1.8, >= 1.8.4) @@ -397,7 +397,7 @@ GEM concurrent-ruby (~> 1.0, >= 1.0.2) sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.9.1) + oj (3.9.2) omniauth (1.9.0) hashie (>= 3.4.6, < 3.7.0) rack (>= 1.6.2, < 3) @@ -423,19 +423,19 @@ GEM paperclip-av-transcoder (0.6.4) av (~> 0.9.0) paperclip (>= 2.5.2) - parallel (1.17.0) + parallel (1.19.1) parallel_tests (2.29.2) parallel - parser (2.6.4.0) + parser (2.6.5.0) ast (~> 2.4.0) parslet (1.8.2) pastel (0.7.3) equatable (~> 0.6) tty-color (~> 0.5) pg (1.1.4) - pghero (2.3.0) + pghero (2.4.1) activerecord (>= 5) - pkg-config (1.3.9) + pkg-config (1.4.0) premailer (1.11.1) addressable css_parser (>= 1.6.0) @@ -459,10 +459,11 @@ GEM activesupport (>= 3.0.0) raabro (1.1.6) rack (2.0.7) - rack-attack (6.1.0) + rack-attack (6.2.1) rack (>= 1.0, < 3) - rack-cors (1.0.3) - rack-protection (2.0.5) + rack-cors (1.1.0) + rack (>= 2.0.0) + rack-protection (2.0.7) rack rack-proxy (0.6.5) rack @@ -488,8 +489,8 @@ GEM rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.2.0) - loofah (~> 2.2, >= 2.2.2) + rails-html-sanitizer (1.3.0) + loofah (~> 2.3) rails-i18n (5.1.3) i18n (>= 0.7, < 2) railties (>= 5.0, < 6) @@ -502,7 +503,7 @@ GEM rake (>= 0.8.7) thor (>= 0.19.0, < 2.0) rainbow (3.0.0) - rake (12.3.3) + rake (13.0.1) rdf (3.0.12) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) @@ -537,34 +538,34 @@ GEM rpam2 (4.0.2) rqrcode (0.10.1) chunky_png (~> 1.0) - rspec-core (3.8.0) - rspec-support (~> 3.8.0) - rspec-expectations (3.8.2) + rspec-core (3.9.0) + rspec-support (~> 3.9.0) + rspec-expectations (3.9.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-mocks (3.8.0) + rspec-support (~> 3.9.0) + rspec-mocks (3.9.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-rails (3.8.2) + rspec-support (~> 3.9.0) + rspec-rails (3.9.0) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) - rspec-core (~> 3.8.0) - rspec-expectations (~> 3.8.0) - rspec-mocks (~> 3.8.0) - rspec-support (~> 3.8.0) + rspec-core (~> 3.9.0) + rspec-expectations (~> 3.9.0) + rspec-mocks (~> 3.9.0) + rspec-support (~> 3.9.0) rspec-sidekiq (3.0.3) rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) - rspec-support (3.8.0) - rubocop (0.74.0) + rspec-support (3.9.0) + rubocop (0.76.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) parser (>= 2.6) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 1.7) - rubocop-rails (2.3.2) + rubocop-rails (2.4.0) rack (>= 1.1) rubocop (>= 0.72.0) ruby-progressbar (1.10.1) @@ -590,13 +591,13 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 3) tilt (>= 1.4.0) - sidekiq-unique-jobs (6.0.13) + sidekiq-unique-jobs (6.0.15) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 4.0, < 7.0) thor (~> 0) simple-navigation (4.1.0) activesupport (>= 2.3.2) - simple_form (4.1.0) + simple_form (5.0.1) actionpack (>= 5.0) activemodel (>= 5.0) simplecov (0.17.1) @@ -614,12 +615,12 @@ GEM sshkit (1.20.0) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) - stackprof (0.2.12) + stackprof (0.2.13) statsd-ruby (1.4.0) - stoplight (2.1.3) + stoplight (2.2.0) streamio-ffmpeg (3.0.2) multi_json (~> 1.8) - strong_migrations (0.4.1) + strong_migrations (0.4.2) activerecord (>= 5) temple (0.8.1) terminal-table (1.8.0) @@ -633,11 +634,11 @@ GEM tty-command (0.9.0) pastel (~> 0.7.0) tty-cursor (0.7.0) - tty-prompt (0.19.0) + tty-prompt (0.20.0) necromancer (~> 0.5.0) pastel (~> 0.7.0) - tty-reader (~> 0.6.0) - tty-reader (0.6.0) + tty-reader (~> 0.7.0) + tty-reader (0.7.0) tty-cursor (~> 0.7) tty-screen (~> 0.7) wisper (~> 2.0.0) @@ -659,7 +660,7 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webpacker (4.0.7) + webpacker (4.2.0) activesupport (>= 4.2) rack-proxy (>= 0.6.1) railties (>= 4.2) @@ -669,7 +670,7 @@ GEM websocket-driver (0.7.0) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) - wisper (2.0.0) + wisper (2.0.1) xpath (3.2.0) nokogiri (~> 1.8) @@ -678,15 +679,15 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10) - active_record_query_trace (~> 1.6) + active_record_query_trace (~> 1.7) addressable (~> 2.7) - annotate (~> 2.7) - aws-sdk-s3 (~> 1.48) + annotate (~> 3.0) + aws-sdk-s3 (~> 1.57) better_errors (~> 2.5) binding_of_caller (~> 0.7) blurhash (~> 0.1) bootsnap (~> 1.4) - brakeman (~> 4.6) + brakeman (~> 4.7) browser bullet (~> 6.0) bundler-audit (~> 0.6) @@ -695,7 +696,7 @@ DEPENDENCIES capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) capybara (~> 3.29) - charlock_holmes (~> 0.7.6) + charlock_holmes (~> 0.7.7) chewy (~> 5.1) cld3 (~> 3.2.4) climate_control (~> 0.2) @@ -708,13 +709,13 @@ DEPENDENCIES discard (~> 1.1) doorkeeper (~> 5.2) dotenv-rails (~> 2.7) - fabrication (~> 2.20) - faker (~> 2.5) + fabrication (~> 2.21) + faker (~> 2.7) fast_blank (~> 1.0) fastimage fog-core (<= 2.1.0) fog-openstack (~> 0.3) - fuubar (~> 2.4) + fuubar (~> 2.5) goldfinger (~> 2.1) hamlit-rails (~> 0.2) health_check! @@ -739,7 +740,7 @@ DEPENDENCIES memory_profiler microformats (~> 4.1) mime-types (~> 3.3) - net-ldap (~> 0.10) + net-ldap (~> 0.16) nilsimsa! nokogiri (~> 1.10) nsa (~> 0.2) @@ -751,12 +752,12 @@ DEPENDENCIES ox (~> 2.11) paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) - parallel (~> 1.17) + parallel (~> 1.19) parallel_tests (~> 2.29) parslet pg (~> 1.1) - pghero (~> 2.3) - pkg-config (~> 1.3) + pghero (~> 2.4) + pkg-config (~> 1.4) posix-spawn! premailer-rails private_address_check (~> 0.5) @@ -764,8 +765,8 @@ DEPENDENCIES pry-rails (~> 0.3) puma (~> 4.2) pundit (~> 2.1) - rack-attack (~> 6.1) - rack-cors (~> 1.0) + rack-attack (~> 6.2) + rack-cors (~> 1.1) rails (~> 5.2.3) rails-controller-testing (~> 1.0) rails-i18n (~> 5.1) @@ -775,10 +776,10 @@ DEPENDENCIES redis-namespace (~> 1.5) redis-rails (~> 5.0) rqrcode (~> 0.10) - rspec-rails (~> 3.8) + rspec-rails (~> 3.9) rspec-sidekiq (~> 3.0) - rubocop (~> 0.74) - rubocop-rails (~> 2.3) + rubocop (~> 0.76) + rubocop-rails (~> 2.4) ruby-progressbar (~> 1.10) sanitize (~> 5.1) sidekiq (~> 5.2) @@ -786,20 +787,20 @@ DEPENDENCIES sidekiq-scheduler (~> 3.0) sidekiq-unique-jobs (~> 6.0) simple-navigation (~> 4.1) - simple_form (~> 4.1) + simple_form (~> 5.0) simplecov (~> 0.17) sprockets-rails (~> 3.2) stackprof - stoplight (~> 2.1.3) + stoplight (~> 2.2.0) streamio-ffmpeg (~> 3.0) strong_migrations (~> 0.4) thor (~> 0.20) tty-command (~> 0.9) - tty-prompt (~> 0.19) + tty-prompt (~> 0.20) twitter-text (~> 1.14) tzinfo-data (~> 1.2019) webmock (~> 3.7) - webpacker (~> 4.0) + webpacker (~> 4.2) webpush RUBY VERSION diff --git a/README.md b/README.md index d50c1b3bc..54a738c83 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [crowdin]: https://crowdin.com/project/mastodon [docker]: https://hub.docker.com/r/tootsuite/mastodon/ -Mastodon is a **free, open-source social network server** based on ActivityPub. Follow friends and discover new ones. Publish anything you want: links, pictures, text, video. All servers of Mastodon are interoperable as a federated network, i.e. users on one server can seamlessly communicate with users from another one. This includes non-Mastodon software that also implements ActivityPub! +Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub)! Click below to **learn more** in a video: @@ -78,7 +78,7 @@ A **Vagrant** configuration is included for development purposes. ## Contributing -Mastodon is **free, open source software** licensed under **AGPLv3**. +Mastodon is **free, open-source software** licensed under **AGPLv3**. You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository, or submit translations using Weblate. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index bcfc1e6d4..291eec19a 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -7,6 +7,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController before_action :skip_unknown_actor_delete before_action :require_signature! + skip_before_action :authenticate_user! def create upgrade_account diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index f138376b2..09ce1761c 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -55,7 +55,8 @@ module Admin params.permit( :account_id, :resolved, - :target_account_id + :target_account_id, + :by_target_domain ) end diff --git a/app/controllers/api/proofs_controller.rb b/app/controllers/api/proofs_controller.rb index a98599eee..dd32cd577 100644 --- a/app/controllers/api/proofs_controller.rb +++ b/app/controllers/api/proofs_controller.rb @@ -3,6 +3,8 @@ class Api::ProofsController < Api::BaseController include AccountOwnedConcern + skip_before_action :require_authenticated_user! + before_action :set_provider def index diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index e77f57910..64b5cb747 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -25,7 +25,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController end def user_settings_params - return nil unless params.key?(:source) + return nil if params[:source].blank? source_params = params.require(:source) diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb new file mode 100644 index 000000000..e1b244e76 --- /dev/null +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class Api::V1::BookmarksController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:bookmarks' } + before_action :require_user! + after_action :insert_pagination_headers + + respond_to :json + + def index + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_bookmarks + end + + def cached_bookmarks + cache_collection( + Status.reorder(nil).joins(:bookmarks).merge(results), + Status + ) + end + + def results + @_results ||= account_bookmarks.paginate_by_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) + end + + def account_bookmarks + current_account.bookmarks + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_bookmarks_url pagination_params(min_id: pagination_since_id) unless results.empty? + end + + def pagination_max_id + results.last.id + end + + def pagination_since_id + results.first.id + end + + def records_continue? + results.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 1b658f870..1cbc92b93 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -51,6 +51,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController def data_params return {} if params[:data].blank? - params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention, :poll]) + params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll]) end end diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb new file mode 100644 index 000000000..bb9729cf5 --- /dev/null +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::BookmarksController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:bookmarks' } + before_action :require_user! + + respond_to :json + + def create + @status = bookmarked_status + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + @status = requested_status + @bookmarks_map = { @status.id => false } + + bookmark = Bookmark.find_by!(account: current_user.account, status: @status) + bookmark.destroy! + + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, bookmarks_map: @bookmarks_map) + end + + private + + def bookmarked_status + authorize_with current_user.account, requested_status, :show? + + bookmark = Bookmark.find_or_create_by!(account: current_user.account, status: requested_status) + + bookmark.status.reload + end + + def requested_status + Status.find(params[:status_id]) + end +end diff --git a/app/controllers/api/v1/streaming_controller.rb b/app/controllers/api/v1/streaming_controller.rb index 66b812e76..ebb17608c 100644 --- a/app/controllers/api/v1/streaming_controller.rb +++ b/app/controllers/api/v1/streaming_controller.rb @@ -5,11 +5,17 @@ class Api::V1::StreamingController < Api::BaseController def index if Rails.configuration.x.streaming_api_base_url != request.host - uri = URI.parse(request.url) - uri.host = URI.parse(Rails.configuration.x.streaming_api_base_url).host - redirect_to uri.to_s, status: 301 + redirect_to streaming_api_url, status: 301 else - raise ActiveRecord::RecordNotFound + not_found end end + + private + + def streaming_api_url + Addressable::URI.parse(request.url).tap do |uri| + uri.host = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url).host + end.to_s + end end diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index d8153e082..f388b17e5 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -19,6 +19,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController data = { alerts: { follow: alerts_enabled, + follow_request: false, favourite: alerts_enabled, reblog: alerts_enabled, mention: alerts_enabled, @@ -58,6 +59,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController end def data_params - @data_params ||= params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention, :poll]) + @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll]) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bd3d13774..e19d5b142 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -136,9 +136,6 @@ class ApplicationController < ActionController::Base end def respond_with_error(code) - respond_to do |format| - format.any { head code } - format.html { render "errors/#{code}", layout: 'error', status: code } - end + render "errors/#{code}", layout: 'error', status: code end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index edf29947b..bac9b329d 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -57,6 +57,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_use_blurhash, :setting_use_pending_items, :setting_trends, + :setting_crop_images, notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag), interactions: %i(must_be_follower must_be_following must_be_following_dm) ) diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb new file mode 100644 index 000000000..5b060a181 --- /dev/null +++ b/app/helpers/accounts_helper.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module AccountsHelper + def display_name(account, **options) + if options[:custom_emojify] + Formatter.instance.format_display_name(account, options) + else + account.display_name.presence || account.username + end + end + + def acct(account) + if account.local? + "@#{account.acct}@#{Rails.configuration.x.local_domain}" + else + "@#{account.acct}" + end + end + + def account_action_button(account) + if user_signed_in? + if account.id == current_user.account_id + link_to settings_profile_url, class: 'button logo-button' do + safe_join([svg_logo, t('settings.edit_profile')]) + end + elsif current_account.following?(account) || current_account.requested?(account) + link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do + safe_join([svg_logo, t('accounts.unfollow')]) + end + elsif !(account.memorial? || account.moved?) + link_to account_follow_path(account), class: "button logo-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post } do + safe_join([svg_logo, t('accounts.follow')]) + end + end + elsif !(account.memorial? || account.moved?) + link_to account_remote_follow_path(account), class: 'button logo-button modal-button', target: '_new' do + safe_join([svg_logo, t('accounts.follow')]) + end + end + end + + def minimal_account_action_button(account) + if user_signed_in? + return if account.id == current_user.account_id + + if current_account.following?(account) || current_account.requested?(account) + link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do + fa_icon('user-times fw') + end + elsif !(account.memorial? || account.moved?) + link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do + fa_icon('user-plus fw') + end + end + elsif !(account.memorial? || account.moved?) + link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do + fa_icon('user-plus fw') + end + end + end + + def account_badge(account, all: false) + if account.bot? + content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles') + elsif (Setting.show_staff_badge && account.user_staff?) || all + content_tag(:div, class: 'roles') do + if all && !account.user_staff? + content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role') + elsif account.user_admin? + content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin') + elsif account.user_moderator? + content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator') + end + end + end + end + + def account_description(account) + prepend_str = [ + [ + number_to_human(account.statuses_count, strip_insignificant_zeros: true), + I18n.t('accounts.posts', count: account.statuses_count), + ].join(' '), + + [ + number_to_human(account.following_count, strip_insignificant_zeros: true), + I18n.t('accounts.following', count: account.following_count), + ].join(' '), + + [ + number_to_human(account.followers_count, strip_insignificant_zeros: true), + I18n.t('accounts.followers', count: account.followers_count), + ].join(' '), + ].join(', ') + + [prepend_str, account.note].join(' · ') + end + + def svg_logo + content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') + end + + def svg_logo_full + content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo-full'), 'viewBox' => '0 0 713.35878 175.8678') + end +end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 1daa60774..608a99dd5 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -44,6 +44,8 @@ module Admin::ActionLogsHelper 'flag' when 'DomainBlock' 'lock' + when 'DomainAllow' + 'plus-circle' when 'EmailDomainBlock' 'envelope' when 'Status' @@ -86,7 +88,7 @@ module Admin::ActionLogsHelper record.shortcode when 'Report' link_to "##{record.id}", admin_report_path(record) - when 'DomainBlock', 'EmailDomainBlock' + when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock' link_to record.domain, "https://#{record.domain}" when 'Status' link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record) @@ -99,7 +101,7 @@ module Admin::ActionLogsHelper case type when 'CustomEmoji' attributes['shortcode'] - when 'DomainBlock', 'EmailDomainBlock' + when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock' link_to attributes['domain'], "https://#{attributes['domain']}" when 'Status' tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count')) diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 8af1683e7..fc4f15985 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -2,7 +2,7 @@ module Admin::FilterHelper ACCOUNT_FILTERS = %i(local remote by_domain active pending silenced suspended username display_name email ip staff).freeze - REPORT_FILTERS = %i(resolved account_id target_account_id).freeze + REPORT_FILTERS = %i(resolved account_id target_account_id by_target_domain).freeze INVITE_FILTER = %i(available expired).freeze CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze TAGS_FILTERS = %i(directory reviewed unreviewed pending_review popular active name).freeze diff --git a/app/helpers/domain_control_helper.rb b/app/helpers/domain_control_helper.rb index 067b2c2cd..ac60cad29 100644 --- a/app/helpers/domain_control_helper.rb +++ b/app/helpers/domain_control_helper.rb @@ -6,7 +6,7 @@ module DomainControlHelper domain = begin if uri_or_domain.include?('://') - Addressable::URI.parse(uri_or_domain).domain + Addressable::URI.parse(uri_or_domain).host else uri_or_domain end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index aa0a4d467..39eb4180e 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -36,11 +36,13 @@ module SettingsHelper ja: '日本語', ka: 'ქართული', kk: 'Қазақша', + kn: 'ಕನ್ನಡ', ko: '한국어', lt: 'Lietuvių', lv: 'Latviešu', mk: 'Македонски', ml: 'മലയാളം', + mr: 'मराठी', ms: 'Bahasa Melayu', nl: 'Nederlands', nn: 'Nynorsk', @@ -63,6 +65,7 @@ module SettingsHelper th: 'ไทย', tr: 'Türkçe', uk: 'Українська', + ur: 'اُردُو', 'zh-CN': '简体中文', 'zh-HK': '繁體中文(香港)', 'zh-TW': '繁體中文(臺灣)', diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 8380b3c42..866a9902c 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -4,80 +4,6 @@ module StatusesHelper EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' - def display_name(account, **options) - if options[:custom_emojify] - Formatter.instance.format_display_name(account, options) - else - account.display_name.presence || account.username - end - end - - def account_action_button(account) - if user_signed_in? - if account.id == current_user.account_id - link_to settings_profile_url, class: 'button logo-button' do - safe_join([svg_logo, t('settings.edit_profile')]) - end - elsif current_account.following?(account) || current_account.requested?(account) - link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do - safe_join([svg_logo, t('accounts.unfollow')]) - end - elsif !(account.memorial? || account.moved?) - link_to account_follow_path(account), class: "button logo-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post } do - safe_join([svg_logo, t('accounts.follow')]) - end - end - elsif !(account.memorial? || account.moved?) - link_to account_remote_follow_path(account), class: 'button logo-button modal-button', target: '_new' do - safe_join([svg_logo, t('accounts.follow')]) - end - end - end - - def minimal_account_action_button(account) - if user_signed_in? - return if account.id == current_user.account_id - - if current_account.following?(account) || current_account.requested?(account) - link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do - fa_icon('user-times fw') - end - elsif !(account.memorial? || account.moved?) - link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do - fa_icon('user-plus fw') - end - end - elsif !(account.memorial? || account.moved?) - link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do - fa_icon('user-plus fw') - end - end - end - - def svg_logo - content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') - end - - def svg_logo_full - content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo-full'), 'viewBox' => '0 0 713.35878 175.8678') - end - - def account_badge(account, all: false) - if account.bot? - content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles') - elsif (Setting.show_staff_badge && account.user_staff?) || all - content_tag(:div, class: 'roles') do - if all && !account.user_staff? - content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role') - elsif account.user_admin? - content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin') - elsif account.user_moderator? - content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator') - end - end - end - end - def link_to_more(url) link_to t('statuses.show_more'), url, class: 'load-more load-gap' end @@ -88,27 +14,6 @@ module StatusesHelper end end - def account_description(account) - prepend_str = [ - [ - number_to_human(account.statuses_count, strip_insignificant_zeros: true), - I18n.t('accounts.posts', count: account.statuses_count), - ].join(' '), - - [ - number_to_human(account.following_count, strip_insignificant_zeros: true), - I18n.t('accounts.following', count: account.following_count), - ].join(' '), - - [ - number_to_human(account.followers_count, strip_insignificant_zeros: true), - I18n.t('accounts.followers', count: account.followers_count), - ].join(' '), - ].join(', ') - - [prepend_str, account.note].join(' · ') - end - def media_summary(status) attachments = { image: 0, video: 0 } @@ -154,14 +59,6 @@ module StatusesHelper embedded_view? ? '_blank' : nil end - def acct(account) - if account.local? - "@#{account.acct}@#{Rails.configuration.x.local_domain}" - else - "@#{account.acct}" - end - end - def style_classes(status, is_predecessor, is_successor, include_threads) classes = ['entry'] classes << 'entry-predecessor' if is_predecessor diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js new file mode 100644 index 000000000..544ed2ff2 --- /dev/null +++ b/app/javascript/mastodon/actions/bookmarks.js @@ -0,0 +1,90 @@ +import api, { getLinks } from '../api'; +import { importFetchedStatuses } from './importer'; + +export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; +export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; +export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL'; + +export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST'; +export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS'; +export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL'; + +export function fetchBookmarkedStatuses() { + return (dispatch, getState) => { + if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { + return; + } + + dispatch(fetchBookmarkedStatusesRequest()); + + api(getState).get('/api/v1/bookmarks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchBookmarkedStatusesFail(error)); + }); + }; +}; + +export function fetchBookmarkedStatusesRequest() { + return { + type: BOOKMARKED_STATUSES_FETCH_REQUEST, + }; +}; + +export function fetchBookmarkedStatusesSuccess(statuses, next) { + return { + type: BOOKMARKED_STATUSES_FETCH_SUCCESS, + statuses, + next, + }; +}; + +export function fetchBookmarkedStatusesFail(error) { + return { + type: BOOKMARKED_STATUSES_FETCH_FAIL, + error, + }; +}; + +export function expandBookmarkedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null); + + if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { + return; + } + + dispatch(expandBookmarkedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandBookmarkedStatusesFail(error)); + }); + }; +}; + +export function expandBookmarkedStatusesRequest() { + return { + type: BOOKMARKED_STATUSES_EXPAND_REQUEST, + }; +}; + +export function expandBookmarkedStatusesSuccess(statuses, next) { + return { + type: BOOKMARKED_STATUSES_EXPAND_SUCCESS, + statuses, + next, + }; +}; + +export function expandBookmarkedStatusesFail(error) { + return { + type: BOOKMARKED_STATUSES_EXPAND_FAIL, + error, + }; +}; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 8e7906c73..c3c6ff1a1 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -205,10 +205,11 @@ export function uploadCompose(files) { return function (dispatch, getState) { const uploadLimit = 4; const media = getState().getIn(['compose', 'media_attachments']); + const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); let total = Array.from(files).reduce((a, v) => a + v.size, 0); - if (files.length + media.size > uploadLimit) { + if (files.length + media.size + pending > uploadLimit) { dispatch(showAlert(undefined, messages.uploadErrorLimit)); return; } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index f7108fdb9..78f321da4 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -10,6 +10,12 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { return obj; }, {}); +export function searchTextFromRawStatus (status) { + const spoilerText = status.spoiler_text || ''; + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); + return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; +} + export function normalizeAccount(account) { account = { ...account }; diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 2dc4c574c..28c6b1a62 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -33,6 +33,14 @@ export const UNPIN_REQUEST = 'UNPIN_REQUEST'; export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; export const UNPIN_FAIL = 'UNPIN_FAIL'; +export const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST'; +export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS'; +export const BOOKMARK_FAIL = 'BOOKMARKED_FAIL'; + +export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; +export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; +export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; + export function reblog(status) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -187,6 +195,78 @@ export function unfavouriteFail(status, error) { }; }; +export function bookmark(status) { + return function (dispatch, getState) { + dispatch(bookmarkRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) { + dispatch(importFetchedStatus(response.data)); + dispatch(bookmarkSuccess(status, response.data)); + }).catch(function (error) { + dispatch(bookmarkFail(status, error)); + }); + }; +}; + +export function unbookmark(status) { + return (dispatch, getState) => { + dispatch(unbookmarkRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unbookmarkSuccess(status, response.data)); + }).catch(error => { + dispatch(unbookmarkFail(status, error)); + }); + }; +}; + +export function bookmarkRequest(status) { + return { + type: BOOKMARK_REQUEST, + status: status, + }; +}; + +export function bookmarkSuccess(status, response) { + return { + type: BOOKMARK_SUCCESS, + status: status, + response: response, + }; +}; + +export function bookmarkFail(status, error) { + return { + type: BOOKMARK_FAIL, + status: status, + error: error, + }; +}; + +export function unbookmarkRequest(status) { + return { + type: UNBOOKMARK_REQUEST, + status: status, + }; +}; + +export function unbookmarkSuccess(status, response) { + return { + type: UNBOOKMARK_SUCCESS, + status: status, + response: response, + }; +}; + +export function unbookmarkFail(status, error) { + return { + type: UNBOOKMARK_FAIL, + status: status, + error: error, + }; +}; + export function fetchReblogs(id) { return (dispatch, getState) => { dispatch(fetchReblogsRequest(id)); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 58803d1ae..798f9b37e 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -14,6 +14,7 @@ import { unescapeHTML } from '../utils/html'; import { getFiltersRegex } from '../selectors'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import compareId from 'mastodon/compare_id'; +import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -60,7 +61,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { if (notification.type === 'mention') { const dropRegex = filters[0]; const regex = filters[1]; - const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); + const searchIndex = searchTextFromRawStatus(notification.status); if (dropRegex && dropRegex.test(searchIndex)) { return; @@ -109,7 +110,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); const excludeTypesFromFilter = filter => { - const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']); + const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']); return allTypes.filterNot(item => item === filter).toJS(); }; diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js index 5dfa1464c..ebd696583 100644 --- a/app/javascript/mastodon/components/attachment_list.js +++ b/app/javascript/mastodon/components/attachment_list.js @@ -25,7 +25,7 @@ export default class AttachmentList extends ImmutablePureComponent { return (

  • - {filename(displayUrl)} + {filename(displayUrl)}
  • ); })} @@ -46,7 +46,7 @@ export default class AttachmentList extends ImmutablePureComponent { return (
  • - {filename(displayUrl)} + {filename(displayUrl)}
  • ); })} diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index d423378c1..a4f262285 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -143,7 +143,7 @@ class DropdownMenu extends React.PureComponent { return (
  • - + {text}
  • diff --git a/app/javascript/mastodon/components/error_boundary.js b/app/javascript/mastodon/components/error_boundary.js index 82543e118..800b1c270 100644 --- a/app/javascript/mastodon/components/error_boundary.js +++ b/app/javascript/mastodon/components/error_boundary.js @@ -58,7 +58,7 @@ export default class ErrorBoundary extends React.PureComponent {

    -

    Mastodon v{version} · ·

    +

    Mastodon v{version} · ·

    ); diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js deleted file mode 100644 index 009c0d559..000000000 --- a/app/javascript/mastodon/components/extended_video_player.js +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -export default class ExtendedVideoPlayer extends React.PureComponent { - - static propTypes = { - src: PropTypes.string.isRequired, - alt: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - time: PropTypes.number, - controls: PropTypes.bool.isRequired, - muted: PropTypes.bool.isRequired, - onClick: PropTypes.func, - }; - - handleLoadedData = () => { - if (this.props.time) { - this.video.currentTime = this.props.time; - } - } - - componentDidMount () { - this.video.addEventListener('loadeddata', this.handleLoadedData); - } - - componentWillUnmount () { - this.video.removeEventListener('loadeddata', this.handleLoadedData); - } - - setRef = (c) => { - this.video = c; - } - - handleClick = e => { - e.stopPropagation(); - const handler = this.props.onClick; - if (handler) handler(); - } - - render () { - const { src, muted, controls, alt } = this.props; - - return ( -
    -
    - ); - } - -} diff --git a/app/javascript/mastodon/components/gifv.js b/app/javascript/mastodon/components/gifv.js new file mode 100644 index 000000000..83cfae49c --- /dev/null +++ b/app/javascript/mastodon/components/gifv.js @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class GIFV extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + }; + + state = { + loading: true, + }; + + handleLoadedData = () => { + this.setState({ loading: false }); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.src !== this.props.src) { + this.setState({ loading: true }); + } + } + + handleClick = e => { + const { onClick } = this.props; + + if (onClick) { + e.stopPropagation(); + onClick(); + } + } + + render () { + const { src, width, height, alt } = this.props; + const { loading } = this.state; + + return ( +
    + {loading && ( + + )} + +
    + ); + } + +} diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 401675052..fd715bc3c 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -1,6 +1,4 @@ import React from 'react'; -import Motion from '../features/ui/util/optional_motion'; -import spring from 'react-motion/lib/spring'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; @@ -37,6 +35,21 @@ export default class IconButton extends React.PureComponent { tabIndex: '0', }; + state = { + activate: false, + deactivate: false, + } + + componentWillReceiveProps (nextProps) { + if (!nextProps.animate) return; + + if (this.props.active && !nextProps.active) { + this.setState({ activate: false, deactivate: true }); + } else if (!this.props.active && nextProps.active) { + this.setState({ activate: true, deactivate: false }); + } + } + handleClick = (e) => { e.preventDefault(); @@ -75,7 +88,6 @@ export default class IconButton extends React.PureComponent { const { active, - animate, className, disabled, expanded, @@ -87,57 +99,37 @@ export default class IconButton extends React.PureComponent { title, } = this.props; + const { + activate, + deactivate, + } = this.state; + const classes = classNames(className, 'icon-button', { active, disabled, inverted, + activate, + deactivate, overlayed: overlay, }); - if (!animate) { - // Perf optimization: avoid unnecessary components unless - // we actually need to animate. - return ( - - ); - } - return ( - - {({ rotate }) => ( - - )} - + ); } diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index e8dd79af9..12b7e5b66 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -6,7 +6,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; -import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state'; +import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state'; import { decode } from 'blurhash'; const messages = defineMessages({ @@ -159,7 +159,7 @@ class Item extends React.PureComponent { if (attachment.get('type') === 'unknown') { return (
    - +
    @@ -187,6 +187,7 @@ class Item extends React.PureComponent { href={attachment.get('remote_url') || originalUrl} onClick={this.handleClick} target='_blank' + rel='noopener noreferrer' > { - if (node /*&& this.isStandaloneEligible()*/) { + if (node) { // offsetWidth triggers a layout, so only calculate when we need to if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); @@ -290,13 +291,13 @@ class MediaGallery extends React.PureComponent { } } - isStandaloneEligible() { - const { media, standalone } = this.props; - return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); + isFullSizeEligible() { + const { media } = this.props; + return media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); } render () { - const { media, intl, sensitive, height, defaultWidth } = this.props; + const { media, intl, sensitive, height, defaultWidth, standalone } = this.props; const { visible } = this.state; const width = this.state.width || defaultWidth; @@ -305,7 +306,7 @@ class MediaGallery extends React.PureComponent { const style = {}; - if (this.isStandaloneEligible()) { + if (this.isFullSizeEligible() && (standalone || !cropImages)) { if (width) { style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); } @@ -318,7 +319,7 @@ class MediaGallery extends React.PureComponent { const size = media.take(4).size; const uncached = media.every(attachment => attachment.get('type') === 'unknown'); - if (this.isStandaloneEligible()) { + if (standalone && this.isFullSizeEligible()) { children = ; } else { children = media.take(4).map((attachment, i) => ); diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js index 5d4f4bbe1..fa4e59192 100644 --- a/app/javascript/mastodon/components/modal_root.js +++ b/app/javascript/mastodon/components/modal_root.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import 'wicg-inert'; export default class ModalRoot extends React.PureComponent { @@ -55,15 +56,21 @@ export default class ModalRoot extends React.PureComponent { } else if (!nextProps.children) { this.setState({ revealed: false }); } - if (!nextProps.children && !!this.props.children) { - this.activeElement.focus(); - this.activeElement = null; - } } componentDidUpdate (prevProps) { if (!this.props.children && !!prevProps.children) { this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); + + // Because of the wicg-inert polyfill, the activeElement may not be + // immediately selectable, we have to wait for observers to run, as + // described in https://github.com/WICG/inert#performance-and-gotchas + Promise.resolve().then(() => { + this.activeElement.focus(); + this.activeElement = null; + }).catch((error) => { + console.error(error); + }); } if (this.props.children) { requestAnimationFrame(() => { diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js index cdbcf8f70..0edd064e0 100644 --- a/app/javascript/mastodon/components/poll.js +++ b/app/javascript/mastodon/components/poll.js @@ -39,7 +39,8 @@ class Poll extends ImmutablePureComponent { static getDerivedStateFromProps (props, state) { const { poll, intl } = props; - const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now(); + const expires_at = poll.get('expires_at'); + const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now(); return (expired === state.expired) ? null : { expired }; } diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index b5606aca5..3176bda89 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -214,6 +214,22 @@ class Status extends ImmutablePureComponent { this.props.onOpenVideo(media, startTime); } + handleHotkeyOpenMedia = e => { + const { status, onOpenMedia, onOpenVideo } = this.props; + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + // TODO: toggle play/paused? + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(status.getIn(['media_attachments', 0]), 0); + } else { + onOpenMedia(status.get('media_attachments'), 0); + } + } + } + handleHotkeyReply = e => { e.preventDefault(); this.props.onReply(this._properStatus(), this.context.router.history); @@ -293,6 +309,7 @@ class Status extends ImmutablePureComponent { moveDown: this.handleHotkeyMoveDown, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, }; if (hidden) { @@ -437,9 +454,9 @@ class Status extends ImmutablePureComponent {
    - + - +
    {statusAvatar}
    diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 0bfbd8879..bd3bb16bb 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -1,5 +1,6 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import IconButton from './icon_button'; import DropdownMenuContainer from '../containers/dropdown_menu_container'; @@ -23,6 +24,8 @@ const messages = defineMessages({ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, @@ -33,6 +36,10 @@ const messages = defineMessages({ admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, + blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, }); const obfuscatedCount = count => { @@ -45,7 +52,12 @@ const obfuscatedCount = count => { } }; -export default @injectIntl +const mapStateToProps = (state, { status }) => ({ + relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), +}); + +export default @connect(mapStateToProps) +@injectIntl class StatusActionBar extends ImmutablePureComponent { static contextTypes = { @@ -54,6 +66,7 @@ class StatusActionBar extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, + relationship: ImmutablePropTypes.map, onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, @@ -61,11 +74,16 @@ class StatusActionBar extends ImmutablePureComponent { onDirect: PropTypes.func, onMention: PropTypes.func, onMute: PropTypes.func, + onUnmute: PropTypes.func, onBlock: PropTypes.func, + onUnblock: PropTypes.func, + onBlockDomain: PropTypes.func, + onUnblockDomain: PropTypes.func, onReport: PropTypes.func, onEmbed: PropTypes.func, onMuteConversation: PropTypes.func, onPin: PropTypes.func, + onBookmark: PropTypes.func, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -74,6 +92,7 @@ class StatusActionBar extends ImmutablePureComponent { // evaluate to false. See react-immutable-pure-component for usage. updateOnProps = [ 'status', + 'relationship', 'withDismiss', ] @@ -114,6 +133,10 @@ class StatusActionBar extends ImmutablePureComponent { window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); } + handleBookmarkClick = () => { + this.props.onBookmark(this.props.status); + } + handleDeleteClick = () => { this.props.onDelete(this.props.status, this.context.router.history); } @@ -135,11 +158,39 @@ class StatusActionBar extends ImmutablePureComponent { } handleMuteClick = () => { - this.props.onMute(this.props.status.get('account')); + const { status, relationship, onMute, onUnmute } = this.props; + const account = status.get('account'); + + if (relationship && relationship.get('muting')) { + onUnmute(account); + } else { + onMute(account); + } } handleBlockClick = () => { - this.props.onBlock(this.props.status); + const { status, relationship, onBlock, onUnblock } = this.props; + const account = status.get('account'); + + if (relationship && relationship.get('blocking')) { + onBlock(status); + } else { + onUnblock(account); + } + } + + handleBlockDomain = () => { + const { status, onBlockDomain } = this.props; + const account = status.get('account'); + + onBlockDomain(account.get('acct').split('@')[1]); + } + + handleUnblockDomain = () => { + const { status, onUnblockDomain } = this.props; + const account = status.get('account'); + + onUnblockDomain(account.get('acct').split('@')[1]); } handleOpen = () => { @@ -178,11 +229,12 @@ class StatusActionBar extends ImmutablePureComponent { } render () { - const { status, intl, withDismiss } = this.props; + const { status, relationship, intl, withDismiss } = this.props; const mutingConversation = status.get('muted'); const anonymousAccess = !me; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const account = status.get('account'); let menu = []; let reblogIcon = 'retweet'; @@ -196,6 +248,7 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); } + menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); menu.push(null); if (status.getIn(['account', 'id']) === me || withDismiss) { @@ -215,16 +268,39 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { - menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); - menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); menu.push(null); - menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); - menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); - menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + + if (relationship && relationship.get('muting')) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick }); + } + + if (relationship && relationship.get('blocking')) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick }); + } + + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport }); + + if (account.get('acct') !== account.get('username')) { + const domain = account.get('acct').split('@')[1]; + + menu.push(null); + + if (relationship && relationship.get('domain_blocking')) { + menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain }); + } else { + menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain }); + } + } if (isStaff) { menu.push(null); - menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` }); } } diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index c171e7a66..d13091325 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -59,7 +59,7 @@ export default class StatusContent extends React.PureComponent { } link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener'); + link.setAttribute('rel', 'noopener noreferrer'); } if ( @@ -216,14 +216,14 @@ export default class StatusContent extends React.PureComponent { return (
    {mentionsPlaceholder} -
    +
    {!hidden && !!status.get('poll') && }
    @@ -231,7 +231,7 @@ export default class StatusContent extends React.PureComponent { } else if (this.props.onClick) { const output = [
    -
    +
    {!!status.get('poll') && }
    , @@ -245,7 +245,7 @@ export default class StatusContent extends React.PureComponent { } else { return (
    -
    +
    {!!status.get('poll') && }
    diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js index f79b19202..ab1823194 100644 --- a/app/javascript/mastodon/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -1,4 +1,5 @@ import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu'; +import { fetchRelationships } from 'mastodon/actions/accounts'; import { openModal, closeModal } from '../actions/modal'; import { connect } from 'react-redux'; import DropdownMenu from '../components/dropdown_menu'; @@ -13,12 +14,17 @@ const mapStateToProps = state => ({ const mapDispatchToProps = (dispatch, { status, items }) => ({ onOpen(id, onItemClick, dropdownPlacement, keyboard) { + if (status) { + dispatch(fetchRelationships([status.getIn(['account', 'id'])])); + } + dispatch(isUserTouching() ? openModal('ACTIONS', { status, actions: items, onClick: onItemClick, }) : openDropdownMenu(id, dropdownPlacement, keyboard)); }, + onClose(id) { dispatch(closeModal('ACTIONS')); dispatch(closeDropdownMenu(id)); diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index fb22676e0..35c16a20c 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -1,3 +1,4 @@ +import React from 'react'; import { connect } from 'react-redux'; import Status from '../components/status'; import { makeGetStatus } from '../selectors'; @@ -9,8 +10,10 @@ import { import { reblog, favourite, + bookmark, unreblog, unfavourite, + unbookmark, pin, unpin, } from '../actions/interactions'; @@ -21,11 +24,19 @@ import { hideStatus, revealStatus, } from '../actions/statuses'; +import { + unmuteAccount, + unblockAccount, +} from '../actions/accounts'; +import { + blockDomain, + unblockDomain, +} from '../actions/domain_blocks'; import { initMuteModal } from '../actions/mutes'; import { initBlockModal } from '../actions/blocks'; import { initReport } from '../actions/reports'; import { openModal } from '../actions/modal'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { boostModal, deleteModal } from '../initial_state'; import { showAlertForError } from '../actions/alerts'; @@ -36,6 +47,7 @@ const messages = defineMessages({ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, }); const makeMapStateToProps = () => { @@ -90,6 +102,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onBookmark (status) { + if (status.get('bookmarked')) { + dispatch(unbookmark(status)); + } else { + dispatch(bookmark(status)); + } + }, + onPin (status) { if (status.get('pinned')) { dispatch(unpin(status)); @@ -138,6 +158,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(initBlockModal(account)); }, + onUnblock (account) { + dispatch(unblockAccount(account.get('id'))); + }, + onReport (status) { dispatch(initReport(status.get('account'), status)); }, @@ -146,6 +170,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(initMuteModal(account)); }, + onUnmute (account) { + dispatch(unmuteAccount(account.get('id'))); + }, + onMuteConversation (status) { if (status.get('muted')) { dispatch(unmuteStatus(status.get('id'))); @@ -162,6 +190,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onBlockDomain (domain) { + dispatch(openModal('CONFIRM', { + message: {domain} }} />, + confirm: intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => dispatch(blockDomain(domain)), + })); + }, + + onUnblockDomain (domain) { + dispatch(unblockDomain(domain)); + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index ac97bad71..dbb567e85 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -253,7 +253,7 @@ class Header extends ImmutablePureComponent {
    - + @@ -282,10 +282,10 @@ class Header extends ImmutablePureComponent {
    - + - +
    ))} diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index b6eec2243..617a45d16 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -1,12 +1,12 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import { decode } from 'blurhash'; +import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; import { autoPlayGif, displayMedia } from 'mastodon/initial_state'; -import classNames from 'classnames'; -import { decode } from 'blurhash'; import { isIOS } from 'mastodon/is_mobile'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; export default class MediaItem extends ImmutablePureComponent { @@ -151,7 +151,7 @@ export default class MediaItem extends ImmutablePureComponent { return (
    diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.js b/app/javascript/mastodon/features/bookmarked_statuses/index.js new file mode 100644 index 000000000..c37cb9176 --- /dev/null +++ b/app/javascript/mastodon/features/bookmarked_statuses/index.js @@ -0,0 +1,104 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks'; +import Column from '../ui/components/column'; +import ColumnHeader from '../../components/column_header'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import StatusList from '../../components/status_list'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { debounce } from 'lodash'; + +const messages = defineMessages({ + heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'bookmarks', 'items']), + isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), + hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Bookmarks extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchBookmarkedStatuses()); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('BOOKMARKS', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandBookmarkedStatuses()); + }, 300, { leading: true }) + + render () { + const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; + const pinned = !!columnId; + + const emptyMessage = ; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 30153cc15..b3cd39685 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -14,15 +14,16 @@ const messages = defineMessages({ title: { id: 'column.community', defaultMessage: 'Local timeline' }, }); -const mapStateToProps = (state, { onlyMedia, columnId }) => { +const mapStateToProps = (state, { columnId }) => { const uuid = columnId; const columns = state.getIn(['settings', 'columns']); const index = columns.findIndex(c => c.get('uuid') === uuid); + const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']); const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]); return { - hasUnread: !!timelineState && (timelineState.get('unread') > 0 || timelineState.get('pendingItems').size > 0), - onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']), + hasUnread: !!timelineState && timelineState.get('unread') > 0, + onlyMedia, }; }; diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js index 211601d52..f4cd71a76 100644 --- a/app/javascript/mastodon/features/compose/components/poll_form.js +++ b/app/javascript/mastodon/features/compose/components/poll_form.js @@ -142,11 +142,9 @@ class PollForm extends ImmutablePureComponent {
    - {options.size < 4 && ( - - )} + - diff --git a/app/javascript/mastodon/features/compose/containers/upload_button_container.js b/app/javascript/mastodon/features/compose/containers/upload_button_container.js index 1471e628b..221b98e31 100644 --- a/app/javascript/mastodon/features/compose/containers/upload_button_container.js +++ b/app/javascript/mastodon/features/compose/containers/upload_button_container.js @@ -3,7 +3,7 @@ import UploadButton from '../components/upload_button'; import { uploadCompose } from '../../../actions/compose'; const mapStateToProps = state => ({ - disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))), + disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))), unavailable: state.getIn(['compose', 'poll']) !== null, resetFileKey: state.getIn(['compose', 'resetFileKey']), }); diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js index 2cbaa0791..235cb7ad8 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.js +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.js @@ -12,6 +12,7 @@ import IconButton from 'mastodon/components/icon_button'; import RelativeTimestamp from 'mastodon/components/relative_timestamp'; import { HotKeys } from 'react-hotkeys'; import { autoPlayGif } from 'mastodon/initial_state'; +import classNames from 'classnames'; const messages = defineMessages({ more: { id: 'status.more', defaultMessage: 'More' }, @@ -158,7 +159,7 @@ class Conversation extends ImmutablePureComponent { return ( -
    +
    @@ -166,7 +167,7 @@ class Conversation extends ImmutablePureComponent {
    - + {unread && }
    diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 67ec7665b..adbc147d1 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -22,6 +22,7 @@ const messages = defineMessages({ settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' }, + bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, @@ -126,11 +127,12 @@ class GettingStarted extends ImmutablePureComponent { navItems.push( , + , , ); - height += 48*3; + height += 48*4; if (myAccount.get('locked') || unreadFollowRequests > 0) { navItems.push(); diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js index cdc138c8b..9c39b158a 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js +++ b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; -import AsyncSelect from 'react-select/lib/Async'; +import AsyncSelect from 'react-select/async'; const messages = defineMessages({ placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' }, diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.js b/app/javascript/mastodon/features/keyboard_shortcuts/index.js index 90dc87cbb..666baf621 100644 --- a/app/javascript/mastodon/features/keyboard_shortcuts/index.js +++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.js @@ -56,6 +56,10 @@ class KeyboardShortcuts extends ImmutablePureComponent { enter, o + + e + + x diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 60a86312a..8bd03fbda 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -57,6 +57,17 @@ export default class ColumnSettings extends React.PureComponent {
    +
    + + +
    + + {showPushSettings && } + + +
    +
    +
    diff --git a/app/javascript/mastodon/features/notifications/components/follow_request.js b/app/javascript/mastodon/features/notifications/components/follow_request.js new file mode 100644 index 000000000..a80cfb2fa --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/follow_request.js @@ -0,0 +1,59 @@ +import React, { Fragment } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from 'mastodon/components/avatar'; +import DisplayName from 'mastodon/components/display_name'; +import Permalink from 'mastodon/components/permalink'; +import IconButton from 'mastodon/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, +}); + +export default @injectIntl +class FollowRequest extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onAuthorize: PropTypes.func.isRequired, + onReject: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { intl, hidden, account, onAuthorize, onReject } = this.props; + + if (!account) { + return
    ; + } + + if (hidden) { + return ( + + {account.get('display_name')} + {account.get('username')} + + ); + } + + return ( +
    +
    + +
    + +
    + +
    + + +
    +
    +
    + ); + } + +} diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 41e9324e6..74065e5e2 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -1,13 +1,23 @@ import React from 'react'; -import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import StatusContainer from '../../../containers/status_container'; -import AccountContainer from '../../../containers/account_container'; -import { injectIntl, FormattedMessage } from 'react-intl'; -import Permalink from '../../../components/permalink'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; import { HotKeys } from 'react-hotkeys'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from 'mastodon/initial_state'; +import StatusContainer from 'mastodon/containers/status_container'; +import AccountContainer from 'mastodon/containers/account_container'; +import FollowRequestContainer from '../containers/follow_request_container'; import Icon from 'mastodon/components/icon'; +import Permalink from 'mastodon/components/permalink'; + +const messages = defineMessages({ + favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, + follow: { id: 'notification.follow', defaultMessage: '{name} followed you' }, + ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' }, + poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }, + reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, +}); const notificationForScreenReader = (intl, message, timestamp) => { const output = [message]; @@ -107,7 +117,7 @@ class Notification extends ImmutablePureComponent { return ( -
    +
    @@ -118,7 +128,29 @@ class Notification extends ImmutablePureComponent {
    -
    + + ); + } + + renderFollowRequest (notification, account, link) { + const { intl } = this.props; + + return ( + +
    +
    +
    + +
    + + + + +
    + +
    ); @@ -146,7 +178,7 @@ class Notification extends ImmutablePureComponent { return ( -
    +
    @@ -178,7 +210,7 @@ class Notification extends ImmutablePureComponent { return ( -
    +
    @@ -205,25 +237,31 @@ class Notification extends ImmutablePureComponent { ); } - renderPoll (notification) { + renderPoll (notification, account) { const { intl } = this.props; + const ownPoll = me === account.get('id'); + const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll); return ( -
    +
    - + {ownPoll ? ( + + ) : ( + + )}
    ); diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 0eff54411..2993fe29a 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -148,7 +148,7 @@ export default class Card extends React.PureComponent { const horizontal = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded; const interactive = card.get('type') !== 'link'; const className = classnames('status-card', { horizontal, compact, interactive }); - const title = interactive ? {card.get('title')} : {card.get('title')}; + const title = interactive ? {card.get('title')} : {card.get('title')}; const ratio = card.get('width') / card.get('height'); const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); @@ -180,7 +180,7 @@ export default class Card extends React.PureComponent {
    - {horizontal && } + {horizontal && }
    @@ -208,7 +208,7 @@ export default class Card extends React.PureComponent { } return ( - + {embed} {description} diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index e97f18f08..d5bc82735 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -156,7 +156,7 @@ export default class DetailedStatus extends ImmutablePureComponent { } if (status.get('application')) { - applicationLink = · {status.getIn(['application', 'name'])}; + applicationLink = · {status.getIn(['application', 'name'])}; } if (status.get('visibility') === 'direct') { @@ -220,7 +220,7 @@ export default class DetailedStatus extends ImmutablePureComponent { {media}
    - + {applicationLink} · {reblogLink} · {favouriteLink}
    diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 029057d40..ab468b5e8 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -13,6 +13,8 @@ import Column from '../ui/components/column'; import { favourite, unfavourite, + bookmark, + unbookmark, reblog, unreblog, pin, @@ -30,6 +32,14 @@ import { hideStatus, revealStatus, } from '../../actions/statuses'; +import { + unblockAccount, + unmuteAccount, +} from '../../actions/accounts'; +import { + blockDomain, + unblockDomain, +} from '../../actions/domain_blocks'; import { initMuteModal } from '../../actions/mutes'; import { initBlockModal } from '../../actions/blocks'; import { initReport } from '../../actions/reports'; @@ -39,7 +49,7 @@ import ColumnBackButton from '../../components/column_back_button'; import ColumnHeader from '../../components/column_header'; import StatusContainer from '../../containers/status_container'; import { openModal } from '../../actions/modal'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { HotKeys } from 'react-hotkeys'; import { boostModal, deleteModal } from '../../initial_state'; @@ -57,6 +67,7 @@ const messages = defineMessages({ detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, }); const makeMapStateToProps = () => { @@ -232,6 +243,14 @@ class Status extends ImmutablePureComponent { } } + handleBookmarkClick = (status) => { + if (status.get('bookmarked')) { + this.props.dispatch(unbookmark(status)); + } else { + this.props.dispatch(bookmark(status)); + } + } + handleDeleteClick = (status, history, withRedraft = false) => { const { dispatch, intl } = this.props; @@ -262,6 +281,22 @@ class Status extends ImmutablePureComponent { this.props.dispatch(openModal('VIDEO', { media, time })); } + handleHotkeyOpenMedia = e => { + const { status } = this.props; + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + // TODO: toggle play/paused? + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + this.handleOpenVideo(status.getIn(['media_attachments', 0]), 0); + } else { + this.handleOpenMedia(status.get('media_attachments'), 0); + } + } + } + handleMuteClick = (account) => { this.props.dispatch(initMuteModal(account)); } @@ -307,6 +342,27 @@ class Status extends ImmutablePureComponent { this.props.dispatch(openModal('EMBED', { url: status.get('url') })); } + handleUnmuteClick = account => { + this.props.dispatch(unmuteAccount(account.get('id'))); + } + + handleUnblockClick = account => { + this.props.dispatch(unblockAccount(account.get('id'))); + } + + handleBlockDomainClick = domain => { + this.props.dispatch(openModal('CONFIRM', { + message: {domain} }} />, + confirm: this.props.intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => this.props.dispatch(blockDomain(domain)), + })); + } + + handleUnblockDomainClick = domain => { + this.props.dispatch(unblockDomain(domain)); + } + + handleHotkeyMoveUp = () => { this.handleMoveUp(this.props.status.get('id')); } @@ -466,6 +522,7 @@ class Status extends ImmutablePureComponent { openProfile: this.handleHotkeyOpenProfile, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, }; return ( @@ -499,12 +556,17 @@ class Status extends ImmutablePureComponent { onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} + onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} onMute={this.handleMuteClick} + onUnmute={this.handleUnmuteClick} onMuteConversation={this.handleConversationMuteClick} onBlock={this.handleBlockClick} + onUnblock={this.handleUnblockClick} + onBlockDomain={this.handleBlockDomainClick} + onUnblockDomain={this.handleUnblockDomainClick} onReport={this.handleReport} onPin={this.handlePin} onEmbed={this.handleEmbed} diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js index 00280f7a6..875b2b75d 100644 --- a/app/javascript/mastodon/features/ui/components/actions_modal.js +++ b/app/javascript/mastodon/features/ui/components/actions_modal.js @@ -26,7 +26,7 @@ export default class ActionsModal extends ImmutablePureComponent { return (
  • - + {icon && }
    {text}
    @@ -42,7 +42,7 @@ export default class ActionsModal extends ImmutablePureComponent {
    diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js index 70f4a1282..0e79005f0 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.js +++ b/app/javascript/mastodon/features/ui/components/boost_modal.js @@ -61,7 +61,7 @@ class BoostModal extends ImmutablePureComponent {
    - +
    diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 8a4e89b3d..8bc4bfc0e 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -21,6 +21,7 @@ import { HashtagTimeline, DirectTimeline, FavouritedStatuses, + BookmarkedStatuses, ListTimeline, Directory, } from '../../ui/util/async-components'; @@ -40,6 +41,7 @@ const componentMap = { 'HASHTAG': HashtagTimeline, 'DIRECT': DirectTimeline, 'FAVOURITES': FavouritedStatuses, + 'BOOKMARKS': BookmarkedStatuses, 'LIST': ListTimeline, 'DIRECTORY': Directory, }; @@ -182,7 +184,7 @@ class ColumnsArea extends ImmutablePureComponent { const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : ; const content = columnIndex !== -1 ? ( - + {links.map(this.renderView)} ) : ( diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index 1ab79a21d..7d1509f71 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -16,6 +16,7 @@ import UploadProgress from 'mastodon/features/compose/components/upload_progress import CharacterCounter from 'mastodon/features/compose/components/character_counter'; import { length } from 'stringz'; import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components'; +import GIFV from 'mastodon/components/gifv'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -41,6 +42,36 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') const assetHost = process.env.CDN_HOST || ''; +class ImageLoader extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + }; + + state = { + loading: true, + }; + + componentDidMount() { + const image = new Image(); + image.addEventListener('load', () => this.setState({ loading: false })); + image.src = this.props.src; + } + + render () { + const { loading } = this.state; + + if (loading) { + return ; + } else { + return ; + } + } + +} + export default @connect(mapStateToProps, mapDispatchToProps) @injectIntl class FocalPointModal extends ImmutablePureComponent { @@ -60,6 +91,7 @@ class FocalPointModal extends ImmutablePureComponent { description: '', dirty: false, progress: 0, + loading: true, }; componentWillMount () { @@ -152,6 +184,15 @@ class FocalPointModal extends ImmutablePureComponent { this.setState({ description: e.target.value, dirty: true }); } + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + this.setState({ description: e.target.value, dirty: true }); + this.handleSubmit(); + } + } + handleSubmit = () => { this.props.onSave(this.state.description, this.state.focusX, this.state.focusY); this.props.onClose(); @@ -173,7 +214,7 @@ class FocalPointModal extends ImmutablePureComponent { langPath: `${assetHost}/ocr/lang-data`, }); - let media_url = media.get('file'); + let media_url = media.get('url'); if (window.URL && URL.createObjectURL) { try { @@ -203,6 +244,16 @@ class FocalPointModal extends ImmutablePureComponent { const previewWidth = 200; const previewHeight = previewWidth / previewRatio; + let descriptionLabel = null; + + if (media.get('type') === 'audio') { + descriptionLabel = ; + } else if (media.get('type') === 'video') { + descriptionLabel = ; + } else { + descriptionLabel = ; + } + return (
    @@ -214,7 +265,9 @@ class FocalPointModal extends ImmutablePureComponent {
    {focals &&

    } - +