diff --git a/.circleci/config.yml b/.circleci/config.yml index 2a1c84253..20688b8e9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,12 +64,17 @@ aliases: - run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version - *restore_ruby_dependencies - - run: bundle install --clean --jobs 16 --path ./vendor/bundle/ --retry 3 --with pam_authentication --without development production + - run: bundle install --clean --jobs 16 --path ./vendor/bundle/ --retry 3 --with pam_authentication --without development production && bundle clean - save_cache: key: v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }} paths: - ./.bundle/ - ./vendor/bundle/ + - persist_to_workspace: + root: ~/projects/ + paths: + - ./mastodon/.bundle/ + - ./mastodon/vendor/bundle/ - &test_steps steps: @@ -78,15 +83,6 @@ aliases: - *install_system_dependencies - run: sudo apt-get install -y ffmpeg - - run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version - - *restore_ruby_dependencies - - - restore_cache: - keys: - - precompiled-assets-{{ .Branch }}-{{ .Revision }} - - precompiled-assets-{{ .Branch }}- - - precompiled-assets- - - run: name: Prepare Tests command: ./bin/rails parallel:create parallel:load_schema parallel:prepare @@ -122,14 +118,12 @@ jobs: steps: - *attach_workspace - *install_system_dependencies - - run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version - - *restore_ruby_dependencies - run: ./bin/rails assets:precompile - - save_cache: - key: precompiled-assets-{{ .Branch }}-{{ .Revision }} + - persist_to_workspace: + root: ~/projects/ paths: - - ./public/assets - - ./public/packs-test/ + - ./mastodon/public/assets + - ./mastodon/public/packs-test/ test-ruby2.5: <<: *defaults @@ -176,8 +170,6 @@ jobs: <<: *defaults steps: - *attach_workspace - - run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version - - *restore_ruby_dependencies - run: bundle exec i18n-tasks check-normalized - run: bundle exec i18n-tasks unused @@ -192,9 +184,11 @@ workflows: - install-ruby2.4: requires: - install + - install-ruby2.5 - install-ruby2.3: requires: - install + - install-ruby2.5 - build: requires: - install-ruby2.5 diff --git a/.env.nanobox b/.env.nanobox index 8e0af6a8a..b60b6ee68 100644 --- a/.env.nanobox +++ b/.env.nanobox @@ -136,8 +136,8 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # Defaults to 60 seconds. Set to 0 to disable # SWIFT_CACHE_TTL= -# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front -# S3_CLOUDFRONT_HOST= +# Optional alias for S3 (e.g. to serve files on a custom domain, possibly using Cloudfront or Cloudflare) +# S3_ALIAS_HOST= # Streaming API integration # STREAMING_API_BASE_URL= diff --git a/.env.production.sample b/.env.production.sample index ebb078878..d1164efdc 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -134,8 +134,8 @@ SMTP_FROM_ADDRESS=notifications@example.com # Defaults to 60 seconds. Set to 0 to disable # SWIFT_CACHE_TTL= -# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front -# S3_CLOUDFRONT_HOST= +# Optional alias for S3 (e.g. to serve files on a custom domain, possibly using Cloudfront or Cloudflare) +# S3_ALIAS_HOST= # Streaming API integration # STREAMING_API_BASE_URL= @@ -162,6 +162,7 @@ STREAMING_CLUSTER_NUM=1 # LDAP_BIND_DN= # LDAP_PASSWORD= # LDAP_UID=cn +# LDAP_SEARCH_FILTER="%{uid}=%{email}" # PAM authentication (optional) # PAM authentication uses for the email generation the "email" pam variable diff --git a/.nvmrc b/.nvmrc index 1e8b31496..45a4fb75d 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -6 +8 diff --git a/.rubocop.yml b/.rubocop.yml index 6faeaca6f..4ba5903bd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,6 +11,7 @@ AllCops: - 'Vagrantfile' - 'vendor/**/*' - 'lib/json_ld/*' + - 'lib/templates/**/*' Bundler/OrderedGems: Enabled: false @@ -61,6 +62,9 @@ Metrics/ParameterLists: Metrics/PerceivedComplexity: Max: 20 +Naming/MemoizedInstanceVariableName: + Enabled: false + Rails: Enabled: true @@ -70,6 +74,9 @@ Rails/HasAndBelongsToMany: Rails/SkipsModelValidations: Enabled: false +Rails/HttpStatus: + Enabled: false + Style/ClassAndModuleChildren: Enabled: false diff --git a/Aptfile b/Aptfile index 5dac83607..60d24f8b3 100644 --- a/Aptfile +++ b/Aptfile @@ -5,6 +5,8 @@ libidn11 libidn11-dev libpq-dev libprotobuf-dev +libssl-dev libxdamage1 libxfixes3 protobuf-compiler +zlib1g-dev diff --git a/Dockerfile b/Dockerfile index fe1cea89a..b85d05047 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +FROM node:8.11.3-alpine as node FROM ruby:2.4.4-alpine3.6 LABEL maintainer="https://github.com/tootsuite/mastodon" \ @@ -11,8 +12,6 @@ ENV PATH=/mastodon/bin:$PATH \ RAILS_ENV=production \ NODE_ENV=production -ARG YARN_VERSION=1.3.2 -ARG YARN_DOWNLOAD_SHA256=6cfe82e530ef0837212f13e45c1565ba53f5199eec2527b85ecbcd88bf26821d ARG LIBICONV_VERSION=1.15 ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 @@ -20,6 +19,11 @@ EXPOSE 3000 4000 WORKDIR /mastodon +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +COPY --from=node /usr/local/bin/npm /usr/local/bin/npm +COPY --from=node /opt/yarn-* /opt/yarn + RUN apk -U upgrade \ && apk add -t build-dependencies \ build-base \ @@ -39,20 +43,13 @@ RUN apk -U upgrade \ imagemagick \ libidn \ libpq \ - nodejs \ - nodejs-npm \ protobuf \ tini \ tzdata \ && update-ca-certificates \ - && mkdir -p /tmp/src /opt \ - && wget -O yarn.tar.gz "https://github.com/yarnpkg/yarn/releases/download/v$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \ - && echo "$YARN_DOWNLOAD_SHA256 *yarn.tar.gz" | sha256sum -c - \ - && tar -xzf yarn.tar.gz -C /tmp/src \ - && rm yarn.tar.gz \ - && mv /tmp/src/yarn-v$YARN_VERSION /opt/yarn \ && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \ && ln -s /opt/yarn/bin/yarnpkg /usr/local/bin/yarnpkg \ + && mkdir -p /tmp/src /opt \ && wget -O libiconv.tar.gz "https://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \ && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \ && tar -xzf libiconv.tar.gz -C /tmp/src \ @@ -69,7 +66,7 @@ COPY Gemfile Gemfile.lock package.json yarn.lock .yarnclean /mastodon/ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \ && bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \ - && yarn --pure-lockfile \ + && yarn install --pure-lockfile --ignore-engines \ && yarn cache clean RUN addgroup -g ${GID} mastodon && adduser -h /mastodon -s /bin/sh -D -G mastodon -u ${UID} mastodon \ @@ -80,8 +77,10 @@ COPY . /mastodon RUN chown -R mastodon:mastodon /mastodon -VOLUME /mastodon/public/system /mastodon/public/assets /mastodon/public/packs +VOLUME /mastodon/public/system USER mastodon +RUN OTP_SECRET=precompile_placeholder SECRET_KEY_BASE=precompile_placeholder bundle exec rails assets:precompile + ENTRYPOINT ["/sbin/tini", "--"] diff --git a/Gemfile b/Gemfile index 15f2af41c..bdac00b48 100644 --- a/Gemfile +++ b/Gemfile @@ -6,16 +6,17 @@ ruby '>= 2.3.0', '< 2.6.0' gem 'pkg-config', '~> 1.3' gem 'puma', '~> 3.11' -gem 'rails', '~> 5.2.0' +gem 'rails', '~> 5.2.1' +gem 'thor', '~> 0.20' gem 'hamlit-rails', '~> 0.2' gem 'pg', '~> 1.0' +gem 'makara', '~> 0.4' gem 'pghero', '~> 2.1' gem 'dotenv-rails', '~> 2.2', '< 2.3' gem 'aws-sdk-s3', '~> 1.9', require: false gem 'fog-core', '~> 1.45' -gem 'fog-local', '~> 0.5', require: false gem 'fog-openstack', '~> 0.1', require: false gem 'paperclip', '~> 6.0' gem 'paperclip-av-transcoder', '~> 0.6' diff --git a/Gemfile.lock b/Gemfile.lock index 945c0776e..502d08efe 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,25 +15,25 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (5.2.0) - actionpack (= 5.2.0) + actioncable (5.2.1) + actionpack (= 5.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailer (5.2.0) - actionpack (= 5.2.0) - actionview (= 5.2.0) - activejob (= 5.2.0) + actionmailer (5.2.1) + actionpack (= 5.2.1) + actionview (= 5.2.1) + activejob (= 5.2.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.2.0) - actionview (= 5.2.0) - activesupport (= 5.2.0) + actionpack (5.2.1) + actionview (= 5.2.1) + activesupport (= 5.2.1) rack (~> 2.0) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.2.0) - activesupport (= 5.2.0) + actionview (5.2.1) + activesupport (= 5.2.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -44,20 +44,20 @@ GEM case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) active_record_query_trace (1.5.4) - activejob (5.2.0) - activesupport (= 5.2.0) + activejob (5.2.1) + activesupport (= 5.2.1) globalid (>= 0.3.6) - activemodel (5.2.0) - activesupport (= 5.2.0) - activerecord (5.2.0) - activemodel (= 5.2.0) - activesupport (= 5.2.0) + activemodel (5.2.1) + activesupport (= 5.2.1) + activerecord (5.2.1) + activemodel (= 5.2.1) + activesupport (= 5.2.1) arel (>= 9.0) - activestorage (5.2.0) - actionpack (= 5.2.0) - activerecord (= 5.2.0) + activestorage (5.2.1) + actionpack (= 5.2.1) + activerecord (= 5.2.1) marcel (~> 0.3.1) - activesupport (5.2.0) + activesupport (5.2.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) @@ -174,7 +174,7 @@ GEM devise (~> 4.0) railties (< 5.3) rotp (~> 2.0) - devise_pam_authenticatable2 (9.1.0) + devise_pam_authenticatable2 (9.1.1) devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.3) @@ -220,8 +220,6 @@ GEM fog-json (1.0.2) fog-core (~> 1.0) multi_json (~> 1.10) - fog-local (0.5.0) - fog-core (>= 1.27, < 3.0) fog-openstack (0.1.25) fog-core (~> 1.40) fog-json (>= 1.0) @@ -322,6 +320,8 @@ GEM nokogiri (>= 1.5.9) mail (2.7.0) mini_mime (>= 0.1.1) + makara (0.4.0) + activerecord (>= 3.0.0) marcel (0.3.2) mimemagic (~> 0.3.2) mario-redis-lock (1.2.1) @@ -346,7 +346,7 @@ GEM net-scp (1.2.1) net-ssh (>= 2.6.5) net-ssh (4.2.0) - nio4r (2.3.0) + nio4r (2.3.1) nokogiri (1.8.4) mini_portile2 (~> 2.3.0) nokogumbo (1.5.0) @@ -425,18 +425,18 @@ GEM rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (5.2.0) - actioncable (= 5.2.0) - actionmailer (= 5.2.0) - actionpack (= 5.2.0) - actionview (= 5.2.0) - activejob (= 5.2.0) - activemodel (= 5.2.0) - activerecord (= 5.2.0) - activestorage (= 5.2.0) - activesupport (= 5.2.0) + rails (5.2.1) + actioncable (= 5.2.1) + actionmailer (= 5.2.1) + actionpack (= 5.2.1) + actionview (= 5.2.1) + activejob (= 5.2.1) + activemodel (= 5.2.1) + activerecord (= 5.2.1) + activestorage (= 5.2.1) + activesupport (= 5.2.1) bundler (>= 1.3.0) - railties (= 5.2.0) + railties (= 5.2.1) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.2) actionpack (~> 5.x, >= 5.0.1) @@ -452,12 +452,12 @@ GEM railties (>= 5.0, < 6) rails-settings-cached (0.6.6) rails (>= 4.2.0) - railties (5.2.0) - actionpack (= 5.2.0) - activesupport (= 5.2.0) + railties (5.2.1) + actionpack (= 5.2.1) + activesupport (= 5.2.1) method_source rake (>= 0.8.7) - thor (>= 0.18.1, < 2.0) + thor (>= 0.19.0, < 2.0) rainbow (3.0.0) rake (12.3.1) rb-fsevent (0.10.3) @@ -677,7 +677,6 @@ DEPENDENCIES fast_blank (~> 1.0) fastimage fog-core (~> 1.45) - fog-local (~> 0.5) fog-openstack (~> 0.1) fuubar (~> 2.2) goldfinger (~> 2.1) @@ -697,6 +696,7 @@ DEPENDENCIES letter_opener_web (~> 1.3) link_header (~> 0.0) lograge (~> 0.10) + makara (~> 0.4) mario-redis-lock (~> 1.2) memory_profiler microformats (~> 4.0) @@ -725,7 +725,7 @@ DEPENDENCIES pundit (~> 1.1) rack-attack (~> 5.2) rack-cors (~> 1.0) - rails (~> 5.2.0) + rails (~> 5.2.1) rails-controller-testing (~> 1.0) rails-i18n (~> 5.1) rails-settings-cached (~> 0.6) @@ -752,6 +752,7 @@ DEPENDENCIES stoplight (~> 2.1.3) streamio-ffmpeg (~> 3.0) strong_migrations (~> 0.2) + thor (~> 0.20) tty-command (~> 0.8) tty-prompt (~> 0.16) twitter-text (~> 1.14) diff --git a/Vagrantfile b/Vagrantfile index ddcdf3510..57fec9e02 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -12,7 +12,7 @@ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main' # Add repo for NodeJS -curl -sL https://deb.nodesource.com/setup_6.x | sudo bash - +curl -sL https://deb.nodesource.com/setup_8.x | sudo bash - # Add firewall rule to redirect 80 to PORT and save sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port #{ENV["PORT"]} diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index 8bf5b4af7..d3104172c 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -31,7 +31,7 @@ class StatusesIndex < Chewy::Index }, } - define_type ::Status.without_reblogs do + define_type ::Status.unscoped.without_reblogs do crutch :mentions do |collection| data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id) data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 4ffdfb685..0dbf0283d 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -9,9 +9,13 @@ class AboutController < ApplicationController @initial_state_json = serializable_resource.to_json end - def more; end + def more + render layout: 'public' + end - def terms; end + def terms + render layout: 'public' + end private @@ -26,7 +30,7 @@ class AboutController < ApplicationController end def set_body_classes - @body_classes = 'about-body' + @body_classes = 'with-modals' end def initial_state_params diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 1152d4aca..f788a9078 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -10,7 +10,9 @@ class AccountsController < ApplicationController def show respond_to do |format| format.html do - @pinned_statuses = [] + @body_classes = 'with-modals' + @pinned_statuses = [] + @endorsed_accounts = @account.endorsed_accounts.to_a.sample(4) if current_account && @account.blocking?(current_account) @statuses = [] @@ -40,7 +42,7 @@ class AccountsController < ApplicationController format.json do skip_session! - render_cached_json(['activitypub', 'actor', @account.cache_key], content_type: 'application/activity+json') do + render_cached_json(['activitypub', 'actor', @account], content_type: 'application/activity+json') do ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter) end end @@ -50,7 +52,7 @@ class AccountsController < ApplicationController private def show_pinned_statuses? - [replies_requested?, media_requested?, params[:max_id].present?, params[:since_id].present?].none? + [replies_requested?, media_requested?, params[:max_id].present?, params[:min_id].present?].none? end def filtered_statuses diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb new file mode 100644 index 000000000..7be753c9b --- /dev/null +++ b/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require 'sidekiq/api' + +module Admin + class DashboardController < BaseController + def index + @users_count = User.count + @registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0 + @logins_week = Redis.current.pfcount("activity:logins:#{current_week}") + @interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0 + @relay_enabled = Relay.enabled.exists? + @single_user_mode = Rails.configuration.x.single_user_mode + @registrations_enabled = Setting.open_registrations + @deletions_enabled = Setting.open_deletion + @invites_enabled = Setting.min_invite_role == 'user' + @search_enabled = Chewy.enabled? + @version = Mastodon::Version.to_s + @database_version = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1] + @redis_version = redis_info['redis_version'] + @reports_count = Report.unresolved.count + @queue_backlog = Sidekiq::Stats.new.enqueued + @recent_users = User.confirmed.recent.includes(:account).limit(4) + @database_size = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size'] + @redis_size = redis_info['used_memory'] + @ldap_enabled = ENV['LDAP_ENABLED'] == 'true' + @cas_enabled = ENV['CAS_ENABLED'] == 'true' + @saml_enabled = ENV['SAML_ENABLED'] == 'true' + @pam_enabled = ENV['PAM_ENABLED'] == 'true' + @hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true' + @trending_hashtags = TrendingTags.get(7) + end + + private + + def current_week + @current_week ||= Time.now.utc.to_date.cweek + end + + def redis_info + @redis_info ||= Redis.current.info + end + end +end diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb index faccaa7c8..44a8eec77 100644 --- a/app/controllers/admin/invites_controller.rb +++ b/app/controllers/admin/invites_controller.rb @@ -30,6 +30,12 @@ module Admin redirect_to admin_invites_path end + def deactivate_all + authorize :invite, :deactivate_all? + Invite.available.in_batches.update_all(expires_at: Time.now.utc) + redirect_to admin_invites_path + end + private def resource_params diff --git a/app/controllers/admin/relays_controller.rb b/app/controllers/admin/relays_controller.rb new file mode 100644 index 000000000..1b02d3c36 --- /dev/null +++ b/app/controllers/admin/relays_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Admin + class RelaysController < BaseController + before_action :set_relay, except: [:index, :new, :create] + + def index + authorize :relay, :update? + @relays = Relay.all + end + + def new + authorize :relay, :update? + @relay = Relay.new(inbox_url: Relay::PRESET_RELAY) + end + + def create + authorize :relay, :update? + + @relay = Relay.new(resource_params) + + if @relay.save + @relay.enable! + redirect_to admin_relays_path + else + render action: :new + end + end + + def destroy + authorize :relay, :update? + @relay.destroy + redirect_to admin_relays_path + end + + def enable + authorize :relay, :update? + @relay.enable! + redirect_to admin_relays_path + end + + def disable + authorize :relay, :update? + @relay.disable! + redirect_to admin_relays_path + end + + private + + def set_relay + @relay = Relay.find(params[:id]) + end + + def resource_params + params.require(:relay).permit(:inbox_url) + end + end +end diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index d00b3d222..5d7f43e00 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -44,14 +44,8 @@ module Admin when 'resolve' @report.resolve!(current_account) log_action :resolve, @report - when 'suspend' - Admin::SuspensionWorker.perform_async(@report.target_account.id) - - log_action :resolve, @report - log_action :suspend, @report.target_account - - resolve_all_target_account_reports when 'silence' + @report.resolve!(current_account) @report.target_account.update!(silenced: true) log_action :resolve, @report diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index 75d00326c..7d38f76ae 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -6,6 +6,7 @@ module Admin site_contact_username site_contact_email site_title + site_short_description site_description site_extended_description site_terms @@ -15,6 +16,7 @@ module Admin timeline_preview show_staff_badge bootstrap_timeline_accounts + theme thumbnail hero min_invite_role @@ -22,6 +24,7 @@ module Admin peers_api_enabled show_known_fediverse_at_about_page preview_sensitive_media + custom_css ).freeze BOOLEAN_SETTINGS = %w( diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index 382bfc4a2..a69f12084 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -28,6 +28,10 @@ module Admin @form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button)) flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save + redirect_to admin_account_statuses_path(@account.id, current_params) + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.statuses.no_status_selected') + redirect_to admin_account_statuses_path(@account.id, current_params) end diff --git a/app/controllers/admin/suspensions_controller.rb b/app/controllers/admin/suspensions_controller.rb index 5f222e125..f9bbf36fb 100644 --- a/app/controllers/admin/suspensions_controller.rb +++ b/app/controllers/admin/suspensions_controller.rb @@ -4,11 +4,24 @@ module Admin class SuspensionsController < BaseController before_action :set_account + def new + @suspension = Form::AdminSuspensionConfirmation.new(report_id: params[:report_id]) + end + def create authorize @account, :suspend? - Admin::SuspensionWorker.perform_async(@account.id) - log_action :suspend, @account - redirect_to admin_accounts_path + + @suspension = Form::AdminSuspensionConfirmation.new(suspension_params) + + if suspension_params[:acct] == @account.acct + resolve_report! if suspension_params[:report_id].present? + perform_suspend! + mark_reports_resolved! + redirect_to admin_accounts_path + else + flash.now[:alert] = I18n.t('admin.suspensions.bad_acct_msg') + render :new + end end def destroy @@ -23,5 +36,25 @@ module Admin def set_account @account = Account.find(params[:account_id]) end + + def suspension_params + params.require(:form_admin_suspension_confirmation).permit(:acct, :report_id) + end + + def resolve_report! + report = Report.find(suspension_params[:report_id]) + report.resolve!(current_account) + log_action :resolve, report + end + + def perform_suspend! + @account.suspend! + Admin::SuspensionWorker.perform_async(@account.id) + log_action :suspend, @account + end + + def mark_reports_resolved! + Report.where(target_account: @account).unresolved.update_all(action_taken: true, action_taken_by_account_id: current_account.id) + end end end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 770a69921..0b3735087 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -7,6 +7,8 @@ class Api::BaseController < ApplicationController include RateLimitHeaders skip_before_action :store_current_location + skip_before_action :check_user_permissions + protect_from_forgery with: :null_session rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| diff --git a/app/controllers/api/v1/accounts/pins_controller.rb b/app/controllers/api/v1/accounts/pins_controller.rb new file mode 100644 index 000000000..0a0239c42 --- /dev/null +++ b/app/controllers/api/v1/accounts/pins_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::PinsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:accounts' } + before_action :require_user! + before_action :set_account + + respond_to :json + + def create + AccountPin.create!(account: current_account, target_account: @account) + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter + end + + def destroy + pin = AccountPin.find_by(account: current_account, target_account: @account) + pin&.destroy! + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def relationships_presenter + AccountRelationshipsPresenter.new([@account.id], current_user.account_id) + end +end diff --git a/app/controllers/api/v1/endorsements_controller.rb b/app/controllers/api/v1/endorsements_controller.rb new file mode 100644 index 000000000..0f04b488f --- /dev/null +++ b/app/controllers/api/v1/endorsements_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class Api::V1::EndorsementsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:accounts' } + before_action :require_user! + after_action :insert_pagination_headers + + respond_to :json + + def index + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def load_accounts + if unlimited? + endorsed_accounts.all + else + endorsed_accounts.paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + end + + def endorsed_accounts + current_account.endorsed_accounts + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + if records_continue? + api_v1_endorsements_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + return if unlimited? + + unless @accounts.empty? + api_v1_endorsements_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb index 19de56732..ec4477034 100644 --- a/app/controllers/api/v1/lists/accounts_controller.rb +++ b/app/controllers/api/v1/lists/accounts_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::Lists::AccountsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] before_action :require_user! diff --git a/app/controllers/api/v1/lists_controller.rb b/app/controllers/api/v1/lists_controller.rb index b42b8b971..054172bee 100644 --- a/app/controllers/api/v1/lists_controller.rb +++ b/app/controllers/api/v1/lists_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::ListsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show] before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show] before_action :require_user! diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index faa7d16cd..df6c8e86c 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -15,19 +15,17 @@ class Api::V1::MutesController < Api::BaseController private def load_accounts - default_accounts.merge(paginated_mutes).to_a - end - - def default_accounts - Account.includes(:muted_by).references(:muted_by) + paginated_mutes.map(&:target_account) end def paginated_mutes - Mute.where(account: current_account).paginate_by_max_id( - limit_param(DEFAULT_ACCOUNTS_LIMIT), - params[:max_id], - params[:since_id] - ) + @paginated_mutes ||= Mute.eager_load(:target_account) + .where(account: current_account) + .paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) end def insert_pagination_headers @@ -41,21 +39,21 @@ class Api::V1::MutesController < Api::BaseController end def prev_path - unless @accounts.empty? + unless paginated_mutes.empty? api_v1_mutes_url pagination_params(since_id: pagination_since_id) end end def pagination_max_id - @accounts.last.muted_by_ids.last + paginated_mutes.last.id end def pagination_since_id - @accounts.first.muted_by_ids.first + paginated_mutes.first.id end def records_continue? - @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + paginated_mutes.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end def pagination_params(core_params) diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index c6925d462..49a52f7a6 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -17,8 +17,7 @@ class Api::V1::StatusesController < Api::BaseController CONTEXT_LIMIT = 4_096 def show - cached = Rails.cache.read(@status.cache_key) - @status = cached unless cached.nil? + @status = cache_collection([@status], Status).first render json: @status, serializer: REST::StatusSerializer end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 29ba6cad6..d266fa1bd 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -24,7 +24,7 @@ class ApplicationController < ActionController::Base rescue_from Mastodon::NotPermittedError, with: :forbidden before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? - before_action :check_suspension, if: :user_signed_in? + before_action :check_user_permissions, if: :user_signed_in? def raise_not_found raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}" @@ -48,8 +48,8 @@ class ApplicationController < ActionController::Base forbidden unless current_user&.staff? end - def check_suspension - forbidden if current_user.account.suspended? + def check_user_permissions + forbidden if current_user.disabled? || current_user.account.suspended? end def after_sign_out_path_for(_resource_or_scope) @@ -95,7 +95,7 @@ class ApplicationController < ActionController::Base end def current_theme - return Setting.default_settings['theme'] unless Themes.instance.names.include? current_user&.setting_theme + return Setting.theme unless Themes.instance.names.include? current_user&.setting_theme current_user.setting_theme end @@ -103,12 +103,8 @@ class ApplicationController < ActionController::Base return raw unless klass.respond_to?(:with_includes) raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation) - uncached_ids = [] - cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key)) - - raw.each do |item| - uncached_ids << item.id unless cached_keys_with_value.key?(item.cache_key) - end + cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id) + uncached_ids = raw.map(&:id) - cached_keys_with_value.keys klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!) @@ -116,11 +112,11 @@ class ApplicationController < ActionController::Base uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h uncached.each_value do |item| - Rails.cache.write(item.cache_key, item) + Rails.cache.write(item, item) end end - raw.map { |item| cached_keys_with_value[item.cache_key] || uncached[item.id] }.compact + raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact end def respond_with_error(code) @@ -135,7 +131,6 @@ class ApplicationController < ActionController::Base def render_cached_json(cache_key, **options) options[:expires_in] ||= 3.minutes - cache_key = cache_key.join(':') if cache_key.is_a?(Enumerable) cache_public = options.key?(:public) ? options.delete(:public) : true content_type = options.delete(:content_type) || 'application/json' diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index 068e71cad..7af9cbe81 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -3,6 +3,7 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController layout 'auth' + before_action :set_body_classes before_action :set_user, only: [:finish_signup] # GET/PATCH /users/:id/finish_signup @@ -23,6 +24,10 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController @user = current_user end + def set_body_classes + @body_classes = 'lighter' + end + def user_params params.require(:user).permit(:email) end diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index 171b997dc..34b98da53 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -2,6 +2,7 @@ class Auth::PasswordsController < Devise::PasswordsController before_action :check_validity_of_reset_password_token, only: :edit + before_action :set_body_classes layout 'auth' @@ -14,6 +15,10 @@ class Auth::PasswordsController < Devise::PasswordsController end end + def set_body_classes + @body_classes = 'lighter' + end + def reset_password_token_is_valid? resource_class.with_reset_password_token(params[:reset_password_token]).present? end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 58961554e..a19f4e62e 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -8,6 +8,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :configure_sign_up_params, only: [:create] before_action :set_sessions, only: [:edit, :update] before_action :set_instance_presenter, only: [:new, :create, :update] + before_action :set_body_classes, only: [:new, :create] def destroy not_found @@ -79,6 +80,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController @instance_presenter = InstancePresenter.new end + def set_body_classes + @body_classes = 'lighter' + end + def set_invite @invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index c1ebe760c..62b4a6377 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -6,9 +6,10 @@ class Auth::SessionsController < Devise::SessionsController layout 'auth' skip_before_action :require_no_authentication, only: [:create] - skip_before_action :check_suspension, only: [:destroy] + skip_before_action :check_user_permissions, only: [:destroy] prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] before_action :set_instance_presenter, only: [:new] + before_action :set_body_classes def new Devise.omniauth_configs.each do |provider, config| @@ -109,6 +110,10 @@ class Auth::SessionsController < Devise::SessionsController @instance_presenter = InstancePresenter.new end + def set_body_classes + @body_classes = 'lighter' + end + def home_paths(resource) paths = [about_path] if single_user_mode? && resource.is_a?(User) diff --git a/app/controllers/authorize_follows_controller.rb b/app/controllers/authorize_follows_controller.rb deleted file mode 100644 index 775d5f23f..000000000 --- a/app/controllers/authorize_follows_controller.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -class AuthorizeFollowsController < ApplicationController - layout 'modal' - - before_action :authenticate_user! - before_action :set_body_classes - - def show - @account = located_account || render(:error) - end - - def create - @account = follow_attempt.try(:target_account) - - if @account.nil? - render :error - else - render :success - end - rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError - render :error - end - - private - - def follow_attempt - FollowService.new.call(current_account, acct_without_prefix) - end - - def located_account - if acct_param_is_url? - account_from_remote_fetch - else - account_from_remote_follow - end - end - - def account_from_remote_fetch - FetchRemoteAccountService.new.call(acct_without_prefix) - end - - def account_from_remote_follow - ResolveAccountService.new.call(acct_without_prefix) - end - - def acct_param_is_url? - parsed_uri.path && %w(http https).include?(parsed_uri.scheme) - end - - def parsed_uri - Addressable::URI.parse(acct_without_prefix).normalize - end - - def acct_without_prefix - acct_params.gsub(/\Aacct:/, '') - end - - def acct_params - params.fetch(:acct, '') - end - - def set_body_classes - @body_classes = 'modal-layout' - end -end diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb new file mode 100644 index 000000000..e27366ea3 --- /dev/null +++ b/app/controllers/authorize_interactions_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class AuthorizeInteractionsController < ApplicationController + include Authorization + + layout 'modal' + + before_action :authenticate_user! + before_action :set_body_classes + before_action :set_resource + + def show + if @resource.is_a?(Account) + render :show + elsif @resource.is_a?(Status) + redirect_to web_url("statuses/#{@resource.id}") + else + render :error + end + end + + def create + if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource) + render :success + else + render :error + end + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + render :error + end + + private + + def set_resource + @resource = located_resource || render(:error) + authorize(@resource, :show?) if @resource.is_a?(Status) + end + + def located_resource + if uri_param_is_url? + ResolveURLService.new.call(uri_param) + else + account_from_remote_follow + end + end + + def account_from_remote_follow + ResolveAccountService.new.call(uri_param) + end + + def uri_param_is_url? + parsed_uri.path && %w(http https).include?(parsed_uri.scheme) + end + + def parsed_uri + Addressable::URI.parse(uri_param).normalize + end + + def uri_param + params[:uri] || params.fetch(:acct, '').gsub(/\Aacct:/, '') + end + + def set_body_classes + @body_classes = 'modal-layout' + end +end diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index 5b9981aa2..6c27ef330 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -8,6 +8,7 @@ module AccountControllerConcern included do layout 'public' before_action :set_account + before_action :set_instance_presenter before_action :set_link_headers before_action :check_account_suspension end @@ -18,6 +19,10 @@ module AccountControllerConcern @account = Account.find_local!(params[:account_username]) end + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + def set_link_headers response.headers['Link'] = LinkHeader.new( [ diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb new file mode 100644 index 000000000..31e501609 --- /dev/null +++ b/app/controllers/custom_css_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class CustomCssController < ApplicationController + before_action :set_cache_headers + + def show + skip_session! + render plain: Setting.custom_css || '', content_type: 'text/css' + end +end diff --git a/app/controllers/emojis_controller.rb b/app/controllers/emojis_controller.rb index c9725ccc0..5d306e600 100644 --- a/app/controllers/emojis_controller.rb +++ b/app/controllers/emojis_controller.rb @@ -9,7 +9,7 @@ class EmojisController < ApplicationController format.json do skip_session! - render_cached_json(['activitypub', 'emoji', @emoji.cache_key], content_type: 'application/activity+json') do + render_cached_json(['activitypub', 'emoji', @emoji], content_type: 'application/activity+json') do ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter) end end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 03403a1ba..175dbab07 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -52,6 +52,6 @@ class FiltersController < ApplicationController end def resource_params - params.require(:custom_filter).permit(:phrase, :expires_in, :irreversible, context: []) + params.require(:custom_filter).permit(:phrase, :expires_in, :irreversible, :whole_word, context: []) end end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index b71424107..b5d6460f9 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -58,7 +58,7 @@ class HomeController < ApplicationController if request.path.start_with?('/web') new_user_session_path elsif single_user_mode? - short_account_path(Account.first) + short_account_path(Account.local.where(suspended: false).first) else about_path end diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb index 56129d69a..9f41cf48a 100644 --- a/app/controllers/intents_controller.rb +++ b/app/controllers/intents_controller.rb @@ -8,7 +8,7 @@ class IntentsController < ApplicationController if uri.scheme == 'web+mastodon' case uri.host when 'follow' - return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, '')) + return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].gsub(/\Aacct:/, '')) when 'share' return redirect_to share_path(text: uri.query_values['text']) end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 5c30313f4..3aaa2776f 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -38,7 +38,7 @@ class InvitesController < ApplicationController private def invites - Invite.where(user: current_user) + Invite.where(user: current_user).order(id: :desc) end def resource_params diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb index cd61fd763..8ba331cd1 100644 --- a/app/controllers/remote_follow_controller.rb +++ b/app/controllers/remote_follow_controller.rb @@ -42,5 +42,6 @@ class RemoteFollowController < ApplicationController def set_body_classes @body_classes = 'modal-layout' + @hide_header = true end end diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb new file mode 100644 index 000000000..6299a1e13 --- /dev/null +++ b/app/controllers/remote_interaction_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class RemoteInteractionController < ApplicationController + include Authorization + + layout 'modal' + + before_action :set_status + before_action :set_body_classes + + def new + @remote_follow = RemoteFollow.new(session_params) + end + + def create + @remote_follow = RemoteFollow.new(resource_params) + + if @remote_follow.valid? + session[:remote_follow] = @remote_follow.acct + redirect_to @remote_follow.interact_address_for(@status) + else + render :new + end + end + + private + + def resource_params + params.require(:remote_follow).permit(:acct) + end + + def session_params + { acct: session[:remote_follow] } + end + + def set_status + @status = Status.find(params[:id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + # Reraise in order to get a 404 + raise ActiveRecord::RecordNotFound + end + + def set_body_classes + @body_classes = 'modal-layout' + @hide_header = true + end +end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index b85341822..24824aabb 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -12,6 +12,7 @@ class StatusesController < ApplicationController before_action :set_account before_action :set_status + before_action :set_instance_presenter before_action :set_link_headers before_action :check_account_suspension before_action :redirect_to_original, only: [:show] @@ -21,6 +22,8 @@ class StatusesController < ApplicationController def show respond_to do |format| format.html do + @body_classes = 'with-modals' + set_ancestors set_descendants @@ -30,7 +33,7 @@ class StatusesController < ApplicationController format.json do skip_session! unless @stream_entry.hidden? - render_cached_json(['activitypub', 'note', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do + render_cached_json(['activitypub', 'note', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter) end end @@ -40,7 +43,7 @@ class StatusesController < ApplicationController def activity skip_session! - render_cached_json(['activitypub', 'activity', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do + render_cached_json(['activitypub', 'activity', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter) end end @@ -148,6 +151,10 @@ class StatusesController < ApplicationController raise ActiveRecord::RecordNotFound end + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + def check_account_suspension gone if @account.suspended? end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 014a5c9b8..8772509d5 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -37,7 +37,7 @@ class TagsController < ApplicationController private def set_body_classes - @body_classes = 'tag-body' + @body_classes = 'with-modals' end def set_instance_presenter diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 4c663211e..c28f0be6b 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -33,8 +33,12 @@ module Admin::ActionLogsHelper when 'DomainBlock', 'EmailDomainBlock' link_to attributes['domain'], "https://#{attributes['domain']}" when 'Status' - tmp_status = Status.new(attributes) - link_to tmp_status.account&.acct || "##{tmp_status.account_id}", TagManager.instance.url_for(tmp_status) + tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count')) + if tmp_status.account + link_to tmp_status.account&.acct || "##{tmp_status.account_id}", admin_account_path(tmp_status.account_id) + else + I18n.t('admin.action_logs.deleted_status') + end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 327901e4e..758f864d7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -27,11 +27,6 @@ module ApplicationHelper Setting.open_deletion end - def add_rtl_body_class(other_classes) - other_classes = "#{other_classes} rtl" if locale_direction == 'rtl' - other_classes - end - def locale_direction if [:ar, :fa, :he].include?(I18n.locale) 'rtl' @@ -77,4 +72,13 @@ module ApplicationHelper def react_component(name, props = {}) content_tag(:div, nil, data: { component: name.to_s.camelcase, props: Oj.dump(props) }) end + + def body_classes + output = (@body_classes || '').split(' ') + output << "theme-#{current_theme.parameterize}" + output << 'system-font' if current_account&.user&.setting_system_font_ui + output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion') + output << 'rtl' if locale_direction == 'rtl' + output.reject(&:blank?).join(' ') + end end diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index d3c6b13a6..f5b501235 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -6,4 +6,46 @@ module HomeHelper locale: I18n.locale, } end + + def account_link_to(account, button = '') + content_tag(:div, class: 'account') do + content_tag(:div, class: 'account__wrapper') do + section = if account.nil? + content_tag(:div, class: 'account__display-name') do + content_tag(:div, class: 'account__avatar-wrapper') do + content_tag(:div, '', class: 'account__avatar', style: "background-image: url(#{full_asset_url('avatars/original/missing.png', skip_pipeline: true)})") + end + + content_tag(:span, class: 'display-name') do + content_tag(:strong, t('about.contact_missing')) + + content_tag(:span, t('about.contact_unavailable'), class: 'display-name__account') + end + end + else + link_to(TagManager.instance.url_for(account), class: 'account__display-name') do + content_tag(:div, class: 'account__avatar-wrapper') do + content_tag(:div, '', class: 'account__avatar', style: "background-image: url(#{account.avatar.url})") + end + + content_tag(:span, class: 'display-name') do + content_tag(:bdi) do + content_tag(:strong, display_name(account, custom_emojify: true), class: 'display-name__html emojify') + end + + content_tag(:span, "@#{account.acct}", class: 'display-name__account') + end + end + end + + section + button + end + end + end + + def obscured_counter(count) + if count <= 0 + 0 + elsif count == 1 + 1 + else + '1+' + end + end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 740f7bf77..14ca2333e 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -4,6 +4,7 @@ module SettingsHelper HUMAN_LOCALES = { en: 'English', ar: 'العربية', + ast: 'l\'asturianu', bg: 'Български', ca: 'Català', co: 'Corsu', @@ -25,6 +26,7 @@ module SettingsHelper io: 'Ido', it: 'Italiano', ja: '日本語', + ka: 'ქართული', ko: '한국어', nl: 'Nederlands', no: 'Norsk', @@ -33,7 +35,7 @@ module SettingsHelper pt: 'Português', 'pt-BR': 'Português do Brasil', ru: 'Русский', - sk: 'Slovensky', + sk: 'Slovenčina', sl: 'Slovenščina', sr: 'Српски', 'sr-Latn': 'Srpski (latinica)', diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index a91a28935..187bfe4a0 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -12,21 +12,67 @@ module StreamEntriesHelper 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([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), 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([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.unfollow')]) + end + else + link_to account_follow_path(account), class: 'button logo-button', data: { method: :post } do + safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.follow')]) + end + end + else + link_to account_remote_follow_path(account), class: 'button logo-button modal-button', target: '_new' do + safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.follow')]) + end + end + end + + def account_badge(account) + 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? + content_tag(:div, class: 'roles') do + if 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 + + def nothing_here(extra_classes = '') + content_tag(:div, class: "nothing-here #{extra_classes}") do + t('accounts.nothing_here') + end + end + def account_description(account) prepend_str = [ [ number_to_human(account.statuses_count, strip_insignificant_zeros: true), - I18n.t('accounts.posts'), + I18n.t('accounts.posts', count: account.statuses_count), ].join(' '), [ number_to_human(account.following_count, strip_insignificant_zeros: true), - I18n.t('accounts.following'), + I18n.t('accounts.following', count: account.following_count), ].join(' '), [ number_to_human(account.followers_count, strip_insignificant_zeros: true), - I18n.t('accounts.followers'), + I18n.t('accounts.followers', count: account.followers_count), ].join(' '), ].join(', ') @@ -67,7 +113,7 @@ module StreamEntriesHelper end def acct(account) - if embedded_view? && account.local? + if account.local? "@#{account.acct}@#{Rails.configuration.x.local_domain}" else "@#{account.acct}" diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index c9e4afcfc..cbae62a0f 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -30,6 +30,14 @@ export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; +export const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST'; +export const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS'; +export const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL'; + +export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; +export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS'; +export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; + export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; @@ -694,3 +702,69 @@ export function rejectFollowRequestFail(id, error) { error, }; }; + +export function pinAccount(id) { + return (dispatch, getState) => { + dispatch(pinAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { + dispatch(pinAccountSuccess(response.data)); + }).catch(error => { + dispatch(pinAccountFail(error)); + }); + }; +}; + +export function unpinAccount(id) { + return (dispatch, getState) => { + dispatch(unpinAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { + dispatch(unpinAccountSuccess(response.data)); + }).catch(error => { + dispatch(unpinAccountFail(error)); + }); + }; +}; + +export function pinAccountRequest(id) { + return { + type: ACCOUNT_PIN_REQUEST, + id, + }; +}; + +export function pinAccountSuccess(relationship) { + return { + type: ACCOUNT_PIN_SUCCESS, + relationship, + }; +}; + +export function pinAccountFail(error) { + return { + type: ACCOUNT_PIN_FAIL, + error, + }; +}; + +export function unpinAccountRequest(id) { + return { + type: ACCOUNT_UNPIN_REQUEST, + id, + }; +}; + +export function unpinAccountSuccess(relationship) { + return { + type: ACCOUNT_UNPIN_SUCCESS, + relationship, + }; +}; + +export function unpinAccountFail(error) { + return { + type: ACCOUNT_UNPIN_FAIL, + error, + }; +}; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index fe3e831d5..6d975cd1e 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -130,7 +130,7 @@ export function submitCompose() { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, }).then(function (response) { - dispatch(insertIntoTagHistory(response.data.tags)); + dispatch(insertIntoTagHistory(response.data.tags, status)); dispatch(submitComposeSuccess({ ...response.data })); // To make the app more responsive, immediately get the status into the columns @@ -390,13 +390,13 @@ export function hydrateCompose() { }; } -function insertIntoTagHistory(tags) { +function insertIntoTagHistory(recognizedTags, text) { return (dispatch, getState) => { const state = getState(); const oldHistory = state.getIn(['compose', 'tagHistory']); const me = state.getIn(['meta', 'me']); - const names = tags.map(({ name }) => name); - const intersectedOldHistory = oldHistory.filter(name => !names.includes(name)); + const names = recognizedTags.map(tag => text.match(new RegExp(`#${tag.name}`, 'i'))[0].slice(1)); + const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1); names.push(...intersectedOldHistory.toJS()); diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js index 47e2df76b..0445a5e10 100644 --- a/app/javascript/mastodon/actions/domain_blocks.js +++ b/app/javascript/mastodon/actions/domain_blocks.js @@ -128,7 +128,7 @@ export function expandDomainBlocks() { return (dispatch, getState) => { const url = getState().getIn(['domain_lists', 'blocks', 'next']); - if (url === null) { + if (!url) { return; } diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 3e1e5f270..8d5e72bec 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -140,7 +140,7 @@ export function redraft(status) { }; }; -export function deleteStatus(id, withRedraft = false) { +export function deleteStatus(id, router, withRedraft = false) { return (dispatch, getState) => { const status = getState().getIn(['statuses', id]); @@ -153,6 +153,10 @@ export function deleteStatus(id, withRedraft = false) { if (withRedraft) { dispatch(redraft(status)); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } } }).catch(error => { dispatch(deleteStatusFail(id, error)); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 11a199db6..e8fd441e1 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -55,7 +55,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { return; } - if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) { + if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) { params.since_id = timeline.getIn(['items', 0]); } diff --git a/app/javascript/mastodon/common.js b/app/javascript/mastodon/common.js new file mode 100644 index 000000000..2b10b8c30 --- /dev/null +++ b/app/javascript/mastodon/common.js @@ -0,0 +1,8 @@ +import Rails from 'rails-ujs'; + +export function start() { + require('font-awesome/css/font-awesome.css'); + require.context('../images/', true); + + Rails.start(); +}; diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js index e81236d26..d45387463 100644 --- a/app/javascript/mastodon/components/column.js +++ b/app/javascript/mastodon/components/column.js @@ -7,6 +7,7 @@ export default class Column extends React.PureComponent { static propTypes = { children: PropTypes.node, + label: PropTypes.string, }; scrollTop () { @@ -40,10 +41,10 @@ export default class Column extends React.PureComponent { } render () { - const { children } = this.props; + const { label, children } = this.props; return ( -
+
{children}
); diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 0a6e7c627..a5cf6479b 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -137,7 +137,7 @@ class DropdownMenu extends React.PureComponent { // It should not be transformed when mounting because the resulting // size will be used to determine the coordinate of the menu by // react-overlays -
+
    @@ -226,6 +226,12 @@ export default class Dropdown extends React.PureComponent { return this.target; } + componentWillUnmount = () => { + if (this.state.id === this.props.openDropdownId) { + this.handleClose(); + } + } + render () { const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId } = this.props; const open = this.state.id === openDropdownId; diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js index 9e2f6835a..009c0d559 100644 --- a/app/javascript/mastodon/components/extended_video_player.js +++ b/app/javascript/mastodon/components/extended_video_player.js @@ -50,6 +50,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { role='button' tabIndex='0' aria-label={alt} + title={alt} muted={muted} controls={controls} loop={!controls} diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js index e2ce9ec96..de2203a4b 100644 --- a/app/javascript/mastodon/components/intersection_observer_article.js +++ b/app/javascript/mastodon/components/intersection_observer_article.js @@ -109,7 +109,7 @@ export default class IntersectionObserverArticle extends React.Component { return (
    +
    {children && React.cloneElement(children, { hidden: false })}
    ); diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 1d351279f..6e1310cd6 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -50,7 +50,7 @@ class Item extends React.PureComponent { handleClick = (e) => { const { index, onClick } = this.props; - if (e.button === 0) { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); onClick(index); } @@ -154,6 +154,7 @@ class Item extends React.PureComponent {
); + rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: status.getIn(['account', 'acct']) }); + account = status.get('account'); status = status.get('reblog'); } @@ -210,6 +230,7 @@ export default class Status extends ImmutablePureComponent { -
+
{prepend}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 0ae21e3f0..6d44a4b45 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -32,6 +32,16 @@ const messages = defineMessages({ embed: { id: 'status.embed', defaultMessage: 'Embed' }, }); +const obfuscatedCount = count => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + @injectIntl export default class StatusActionBar extends ImmutablePureComponent { @@ -86,11 +96,11 @@ export default class StatusActionBar extends ImmutablePureComponent { } handleDeleteClick = () => { - this.props.onDelete(this.props.status); + this.props.onDelete(this.props.status, this.context.router.history); } handleRedraftClick = () => { - this.props.onDelete(this.props.status, true); + this.props.onDelete(this.props.status, this.context.router.history, true); } handlePinClick = () => { @@ -194,7 +204,7 @@ export default class StatusActionBar extends ImmutablePureComponent { return (
- +
{obfuscatedCount(status.get('replies_count'))}
{shareButton} diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 9b86592f6..81013747e 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -64,7 +64,7 @@ export default class StatusContent extends React.PureComponent { } onMentionClick = (mention, e) => { - if (this.context.router && e.button === 0) { + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); this.context.router.history.push(`/accounts/${mention.get('id')}`); } @@ -73,7 +73,7 @@ export default class StatusContent extends React.PureComponent { onHashtagClick = (hashtag, e) => { hashtag = hashtag.replace(/^#/, '').toLowerCase(); - if (this.context.router && e.button === 0) { + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); this.context.router.history.push(`/timelines/tag/${hashtag}`); } diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 68c9eef54..37f21fb44 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -1,12 +1,12 @@ import { debounce } from 'lodash'; import React from 'react'; +import { FormattedMessage } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import StatusContainer from '../containers/status_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import LoadGap from './load_gap'; import ScrollableList from './scrollable_list'; -import { FormattedMessage } from 'react-intl'; export default class StatusList extends ImmutablePureComponent { @@ -71,7 +71,7 @@ export default class StatusList extends ImmutablePureComponent { } render () { - const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props; + const { statusIds, featuredStatusIds, shouldUpdateScroll, onLoadMore, timelineId, ...other } = this.props; const { isLoading, isPartial } = other; if (isPartial) { @@ -122,7 +122,7 @@ export default class StatusList extends ImmutablePureComponent { } return ( - + {scrollableContent} ); diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index b29898d3b..b2b0265aa 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -38,13 +38,6 @@ export default class Mastodon extends React.PureComponent { window.setTimeout(() => Notification.requestPermission(), 60 * 1000); } - // Protocol handler - // Ask after 5 minutes - if (typeof navigator.registerProtocolHandler !== 'undefined') { - const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s'; - window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000); - } - store.dispatch(showOnboardingOnce()); } diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index 1700fba05..43bb39403 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -29,19 +29,19 @@ export default class MediaContainer extends PureComponent { }; handleOpenMedia = (media, index) => { - document.body.classList.add('media-standalone__body'); + document.body.classList.add('with-modals--active'); this.setState({ media, index }); } handleOpenVideo = (video, time) => { const media = ImmutableList([video]); - document.body.classList.add('media-standalone__body'); + document.body.classList.add('with-modals--active'); this.setState({ media, time }); } handleCloseMedia = () => { - document.body.classList.remove('media-standalone__body'); + document.body.classList.remove('with-modals--active'); this.setState({ media: null, index: null, time: null }); } diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index eb6329fdc..bbc0d5e96 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -34,7 +34,7 @@ const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, - redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' }, + 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.' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, }); @@ -93,14 +93,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); }, - onDelete (status, withRedraft = false) { + onDelete (status, history, withRedraft = false) { if (!deleteModal) { - dispatch(deleteStatus(status.get('id'), withRedraft)); + dispatch(deleteStatus(status.get('id'), history, withRedraft)); } else { dispatch(openModal('CONFIRM', { message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), - onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)), + onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)), })); } }, diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index 69726a416..bc6f86628 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -32,6 +32,8 @@ const messages = defineMessages({ blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, + endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, + unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, }); @injectIntl @@ -48,6 +50,7 @@ export default class ActionBar extends React.PureComponent { onMute: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired, onUnblockDomain: PropTypes.func.isRequired, + onEndorseToggle: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; @@ -93,6 +96,9 @@ export default class ActionBar extends React.PureComponent { } else { menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); } + + menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle }); + menu.push(null); } if (account.getIn(['relationship', 'muting'])) { @@ -141,18 +147,18 @@ export default class ActionBar extends React.PureComponent {
- - + + {shortNumberFormat(account.get('statuses_count'))} - - + + {shortNumberFormat(account.get('following_count'))} - - + + {shortNumberFormat(account.get('followers_count'))}
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index dd2cd406b..eb9236aeb 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -104,7 +104,9 @@ export default class Header extends ImmutablePureComponent { } if (me !== account.get('id')) { - if (account.getIn(['relationship', 'requested'])) { + if (!account.get('relationship')) { // Wait until the relationship is loaded + actionBtn = ''; + } else if (account.getIn(['relationship', 'requested'])) { actionBtn = (
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 5f564d3a9..a6c464aff 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -23,6 +23,7 @@ const mapStateToProps = (state, props) => ({ class LoadMoreMedia extends ImmutablePureComponent { static propTypes = { + shouldUpdateScroll: PropTypes.func, maxId: PropTypes.string, onLoadMore: PropTypes.func.isRequired, }; @@ -90,7 +91,7 @@ export default class AccountGallery extends ImmutablePureComponent { } render () { - const { medias, isLoading, hasMore } = this.props; + const { medias, shouldUpdateScroll, isLoading, hasMore } = this.props; let loadOlder = null; @@ -110,7 +111,7 @@ export default class AccountGallery extends ImmutablePureComponent { - +
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index 1ae5126e6..ab29e4bdf 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -22,6 +22,7 @@ export default class Header extends ImmutablePureComponent { onMute: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired, onUnblockDomain: PropTypes.func.isRequired, + onEndorseToggle: PropTypes.func.isRequired, hideTabs: PropTypes.bool, }; @@ -73,6 +74,10 @@ export default class Header extends ImmutablePureComponent { this.props.onUnblockDomain(domain); } + handleEndorseToggle = () => { + this.props.onEndorseToggle(this.props.account); + } + render () { const { account, hideTabs } = this.props; @@ -100,6 +105,7 @@ export default class Header extends ImmutablePureComponent { onMute={this.handleMute} onBlockDomain={this.handleBlockDomain} onUnblockDomain={this.handleUnblockDomain} + onEndorseToggle={this.handleEndorseToggle} /> {!hideTabs && ( diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index 7681430b7..02803893d 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -8,6 +8,8 @@ import { blockAccount, unblockAccount, unmuteAccount, + pinAccount, + unpinAccount, } from '../../../actions/accounts'; import { mentionCompose, @@ -82,6 +84,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onEndorseToggle (account) { + if (account.getIn(['relationship', 'endorsed'])) { + dispatch(unpinAccount(account.get('id'))); + } else { + dispatch(pinAccount(account.get('id'))); + } + }, + onReport (account) { dispatch(initReport(account)); }, diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index d329bac5c..934513cd7 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -29,6 +29,7 @@ export default class AccountTimeline extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, statusIds: ImmutablePropTypes.list, featuredStatusIds: ImmutablePropTypes.list, isLoading: PropTypes.bool, @@ -61,7 +62,7 @@ export default class AccountTimeline extends ImmutablePureComponent { } render () { - const { statusIds, featuredStatusIds, isLoading, hasMore } = this.props; + const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore } = this.props; if (!statusIds && isLoading) { return ( @@ -83,6 +84,7 @@ export default class AccountTimeline extends ImmutablePureComponent { isLoading={isLoading} hasMore={hasMore} onLoadMore={this.handleLoadMore} + shouldUpdateScroll={shouldUpdateScroll} /> ); diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js index 14a512ae8..68661a37c 100644 --- a/app/javascript/mastodon/features/blocks/index.js +++ b/app/javascript/mastodon/features/blocks/index.js @@ -1,15 +1,16 @@ import React from 'react'; import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import PropTypes from 'prop-types'; import LoadingIndicator from '../../components/loading_indicator'; -import { ScrollContainer } from 'react-router-scroll-4'; import Column from '../ui/components/column'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import AccountContainer from '../../containers/account_container'; import { fetchBlocks, expandBlocks } from '../../actions/blocks'; -import { defineMessages, injectIntl } from 'react-intl'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from '../../components/scrollable_list'; const messages = defineMessages({ heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, @@ -26,6 +27,7 @@ export default class Blocks extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, }; @@ -34,16 +36,12 @@ export default class Blocks extends ImmutablePureComponent { this.props.dispatch(fetchBlocks()); } - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - - if (scrollTop === scrollHeight - clientHeight) { - this.props.dispatch(expandBlocks()); - } - } + handleLoadMore = debounce(() => { + this.props.dispatch(expandBlocks()); + }, 300, { leading: true }); render () { - const { intl, accountIds } = this.props; + const { intl, accountIds, shouldUpdateScroll } = this.props; if (!accountIds) { return ( @@ -53,16 +51,21 @@ export default class Blocks extends ImmutablePureComponent { ); } + const emptyMessage = ; + return ( - -
- {accountIds.map(id => - - )} -
-
+ + {accountIds.map(id => + + )} +
); } diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index eb9ad97a2..48d2b3f68 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -39,6 +39,7 @@ export default class CommunityTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, columnId: PropTypes.string, intl: PropTypes.object.isRequired, hasUnread: PropTypes.bool, @@ -100,11 +101,11 @@ export default class CommunityTimeline extends React.PureComponent { } render () { - const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props; + const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn, onlyMedia } = this.props; const pinned = !!columnId; return ( - + } + shouldUpdateScroll={shouldUpdateScroll} /> ); diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index a772c1c95..e19778fd2 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -28,6 +28,7 @@ class PrivacyDropdownMenu extends React.PureComponent { style: PropTypes.object, items: PropTypes.array.isRequired, value: PropTypes.string.isRequired, + placement: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, }; @@ -119,7 +120,7 @@ class PrivacyDropdownMenu extends React.PureComponent { render () { const { mounted } = this.state; - const { style, items, value } = this.props; + const { style, items, placement, value } = this.props; return ( @@ -127,7 +128,7 @@ class PrivacyDropdownMenu extends React.PureComponent { // It should not be transformed when mounting because the resulting // size will be used to determine the coordinate of the menu by // react-overlays -
+
{items.map(item => (
@@ -226,7 +227,7 @@ export default class PrivacyDropdown extends React.PureComponent { const valueOption = this.options.find(item => item.value === value); return ( -
+
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js index 5b4b81eac..6f358a98b 100644 --- a/app/javascript/mastodon/features/compose/components/reply_indicator.js +++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js @@ -30,7 +30,7 @@ export default class ReplyIndicator extends ImmutablePureComponent { } handleAccountClick = (e) => { - if (e.button === 0) { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); } diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index bfa2b4727..3d09217dc 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -20,6 +20,7 @@ export default class Upload extends ImmutablePureComponent { onUndo: PropTypes.func.isRequired, onDescriptionChange: PropTypes.func.isRequired, onOpenFocalPoint: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, }; state = { @@ -28,6 +29,17 @@ export default class Upload extends ImmutablePureComponent { dirtyDescription: null, }; + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + } + + handleSubmit = () => { + this.handleInputBlur(); + this.props.onSubmit(); + } + handleUndoClick = () => { this.props.onUndo(this.props.media.get('id')); } @@ -93,6 +105,7 @@ export default class Upload extends ImmutablePureComponent { onFocus={this.handleInputFocus} onChange={this.handleInputChange} onBlur={this.handleInputBlur} + onKeyDown={this.handleKeyDown} />
diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js index 70b28a2ba..2f38f5148 100644 --- a/app/javascript/mastodon/features/compose/components/upload_button.js +++ b/app/javascript/mastodon/features/compose/components/upload_button.js @@ -7,7 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePropTypes from 'react-immutable-proptypes'; const messages = defineMessages({ - upload: { id: 'upload_button.label', defaultMessage: 'Add media' }, + upload: { id: 'upload_button.label', defaultMessage: 'Add media (JPEG, PNG, GIF, WebM, MP4)' }, }); const makeMapStateToProps = () => { diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js index d6b57e5ff..9f3aab4bc 100644 --- a/app/javascript/mastodon/features/compose/containers/upload_container.js +++ b/app/javascript/mastodon/features/compose/containers/upload_container.js @@ -2,6 +2,7 @@ import { connect } from 'react-redux'; import Upload from '../components/upload'; import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose'; import { openModal } from '../../../actions/modal'; +import { submitCompose } from '../../../actions/compose'; const mapStateToProps = (state, { id }) => ({ media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), @@ -21,6 +22,10 @@ const mapDispatchToProps = dispatch => ({ dispatch(openModal('FOCAL_POINT', { id })); }, + onSubmit () { + dispatch(submitCompose()); + }, + }); export default connect(mapStateToProps, mapDispatchToProps)(Upload); diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index 813731918..1a897d887 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -22,6 +22,7 @@ const messages = defineMessages({ community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, + compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' }, }); const mapStateToProps = (state, ownProps) => ({ @@ -95,7 +96,7 @@ export default class Compose extends React.PureComponent { } return ( -
+
{header} {(multiColumn || isSearchPage) && } diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js index 63dc41d9e..dd289ce56 100644 --- a/app/javascript/mastodon/features/direct_timeline/index.js +++ b/app/javascript/mastodon/features/direct_timeline/index.js @@ -23,6 +23,7 @@ export default class DirectTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, columnId: PropTypes.string, intl: PropTypes.object.isRequired, hasUnread: PropTypes.bool, @@ -71,11 +72,11 @@ export default class DirectTimeline extends React.PureComponent { } render () { - const { intl, hasUnread, columnId, multiColumn } = this.props; + const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props; const pinned = !!columnId; return ( - + } + shouldUpdateScroll={shouldUpdateScroll} /> ); diff --git a/app/javascript/mastodon/features/domain_blocks/index.js b/app/javascript/mastodon/features/domain_blocks/index.js index b8a942d6c..2c40bf72d 100644 --- a/app/javascript/mastodon/features/domain_blocks/index.js +++ b/app/javascript/mastodon/features/domain_blocks/index.js @@ -1,15 +1,15 @@ import React from 'react'; import { connect } from 'react-redux'; -import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import DomainContainer from '../../containers/domain_container'; import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks'; -import { defineMessages, injectIntl } from 'react-intl'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { debounce } from 'lodash'; import ScrollableList from '../../components/scrollable_list'; const messages = defineMessages({ @@ -28,6 +28,7 @@ export default class Blocks extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, domains: ImmutablePropTypes.orderedSet, intl: PropTypes.object.isRequired, }; @@ -41,7 +42,7 @@ export default class Blocks extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { intl, domains } = this.props; + const { intl, domains, shouldUpdateScroll } = this.props; if (!domains) { return ( @@ -51,10 +52,17 @@ export default class Blocks extends ImmutablePureComponent { ); } + const emptyMessage = ; + return ( - + {domains.map(domain => )} diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index 6f1c863b4..499530cd9 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -7,7 +7,7 @@ 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 } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { debounce } from 'lodash'; @@ -27,6 +27,7 @@ export default class Favourites extends ImmutablePureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, statusIds: ImmutablePropTypes.list.isRequired, intl: PropTypes.object.isRequired, columnId: PropTypes.string, @@ -67,11 +68,13 @@ export default class Favourites extends ImmutablePureComponent { }, 300, { leading: true }) render () { - const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; + const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; const pinned = !!columnId; + const emptyMessage = ; + return ( - + ); diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js index 6f113beb4..74a683ccc 100644 --- a/app/javascript/mastodon/features/favourites/index.js +++ b/app/javascript/mastodon/features/favourites/index.js @@ -1,14 +1,15 @@ import React from 'react'; import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import LoadingIndicator from '../../components/loading_indicator'; import { fetchFavourites } from '../../actions/interactions'; -import { ScrollContainer } from 'react-router-scroll-4'; +import { FormattedMessage } from 'react-intl'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from '../../components/scrollable_list'; const mapStateToProps = (state, props) => ({ accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]), @@ -20,6 +21,7 @@ export default class Favourites extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, }; @@ -34,7 +36,7 @@ export default class Favourites extends ImmutablePureComponent { } render () { - const { accountIds } = this.props; + const { shouldUpdateScroll, accountIds } = this.props; if (!accountIds) { return ( @@ -44,15 +46,21 @@ export default class Favourites extends ImmutablePureComponent { ); } + const emptyMessage = ; + return ( - -
- {accountIds.map(id => )} -
-
+ + {accountIds.map(id => + + )} +
); } diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js index eae821f92..cb574e08d 100644 --- a/app/javascript/mastodon/features/follow_requests/index.js +++ b/app/javascript/mastodon/features/follow_requests/index.js @@ -1,15 +1,16 @@ import React from 'react'; import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import LoadingIndicator from '../../components/loading_indicator'; -import { ScrollContainer } from 'react-router-scroll-4'; import Column from '../ui/components/column'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import AccountAuthorizeContainer from './containers/account_authorize_container'; import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts'; -import { defineMessages, injectIntl } from 'react-intl'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from '../../components/scrollable_list'; const messages = defineMessages({ heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }, @@ -26,6 +27,7 @@ export default class FollowRequests extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, }; @@ -34,16 +36,12 @@ export default class FollowRequests extends ImmutablePureComponent { this.props.dispatch(fetchFollowRequests()); } - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - - if (scrollTop === scrollHeight - clientHeight) { - this.props.dispatch(expandFollowRequests()); - } - } + handleLoadMore = debounce(() => { + this.props.dispatch(expandFollowRequests()); + }, 300, { leading: true }); render () { - const { intl, accountIds } = this.props; + const { intl, shouldUpdateScroll, accountIds } = this.props; if (!accountIds) { return ( @@ -53,17 +51,21 @@ export default class FollowRequests extends ImmutablePureComponent { ); } + const emptyMessage = ; + return ( - - -
- {accountIds.map(id => - - )} -
-
+ + {accountIds.map(id => + + )} +
); } diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js index 919a89332..5eb05367e 100644 --- a/app/javascript/mastodon/features/followers/index.js +++ b/app/javascript/mastodon/features/followers/index.js @@ -1,20 +1,21 @@ import React from 'react'; import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import LoadingIndicator from '../../components/loading_indicator'; import { fetchAccount, fetchFollowers, expandFollowers, } from '../../actions/accounts'; -import { ScrollContainer } from 'react-router-scroll-4'; +import { FormattedMessage } from 'react-intl'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; import HeaderContainer from '../account_timeline/containers/header_container'; -import LoadMore from '../../components/load_more'; import ColumnBackButton from '../../components/column_back_button'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from '../../components/scrollable_list'; const mapStateToProps = (state, props) => ({ accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']), @@ -27,6 +28,7 @@ export default class Followers extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, hasMore: PropTypes.bool, }; @@ -43,23 +45,12 @@ export default class Followers extends ImmutablePureComponent { } } - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - - if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) { - this.props.dispatch(expandFollowers(this.props.params.accountId)); - } - } - - handleLoadMore = (e) => { - e.preventDefault(); + handleLoadMore = debounce(() => { this.props.dispatch(expandFollowers(this.props.params.accountId)); - } + }, 300, { leading: true }); render () { - const { accountIds, hasMore } = this.props; - - let loadMore = null; + const { shouldUpdateScroll, accountIds, hasMore } = this.props; if (!accountIds) { return ( @@ -69,23 +60,25 @@ export default class Followers extends ImmutablePureComponent { ); } - if (hasMore) { - loadMore = ; - } + const emptyMessage = ; return ( - -
-
- - {accountIds.map(id => )} - {loadMore} -
-
-
+ + + + {accountIds.map(id => + + )} +
); } diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js index 5719259d1..95e786882 100644 --- a/app/javascript/mastodon/features/following/index.js +++ b/app/javascript/mastodon/features/following/index.js @@ -1,20 +1,21 @@ import React from 'react'; import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import LoadingIndicator from '../../components/loading_indicator'; import { fetchAccount, fetchFollowing, expandFollowing, } from '../../actions/accounts'; -import { ScrollContainer } from 'react-router-scroll-4'; +import { FormattedMessage } from 'react-intl'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; import HeaderContainer from '../account_timeline/containers/header_container'; -import LoadMore from '../../components/load_more'; import ColumnBackButton from '../../components/column_back_button'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from '../../components/scrollable_list'; const mapStateToProps = (state, props) => ({ accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']), @@ -27,6 +28,7 @@ export default class Following extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, hasMore: PropTypes.bool, }; @@ -43,23 +45,12 @@ export default class Following extends ImmutablePureComponent { } } - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - - if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) { - this.props.dispatch(expandFollowing(this.props.params.accountId)); - } - } - - handleLoadMore = (e) => { - e.preventDefault(); + handleLoadMore = debounce(() => { this.props.dispatch(expandFollowing(this.props.params.accountId)); - } + }, 300, { leading: true }); render () { - const { accountIds, hasMore } = this.props; - - let loadMore = null; + const { shouldUpdateScroll, accountIds, hasMore } = this.props; if (!accountIds) { return ( @@ -69,23 +60,25 @@ export default class Following extends ImmutablePureComponent { ); } - if (hasMore) { - loadMore = ; - } + const emptyMessage = ; return ( - -
-
- - {accountIds.map(id => )} - {loadMore} -
-
-
+ + + + {accountIds.map(id => + + )} +
); } diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 99642c911..f34ac6b8a 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -7,7 +7,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { me, invitesEnabled } from '../../initial_state'; +import { me, invitesEnabled, version } from '../../initial_state'; import { fetchFollowRequests } from '../../actions/accounts'; import { List as ImmutableList } from 'immutable'; import { Link } from 'react-router-dom'; @@ -31,6 +31,7 @@ const messages = defineMessages({ discover: { id: 'navigation_bar.discover', defaultMessage: 'Discover' }, personal: { id: 'navigation_bar.personal', defaultMessage: 'Personal' }, security: { id: 'navigation_bar.security', defaultMessage: 'Security' }, + menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, }); const mapStateToProps = state => ({ @@ -115,7 +116,7 @@ export default class GettingStarted extends ImmutablePureComponent { } return ( - + {multiColumn &&

diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index 374615ac7..b67486f07 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -20,6 +20,7 @@ export default class HashtagTimeline extends React.PureComponent { params: PropTypes.object.isRequired, columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -83,12 +84,12 @@ export default class HashtagTimeline extends React.PureComponent { } render () { - const { hasUnread, columnId, multiColumn } = this.props; + const { shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props; const { id } = this.props.params; const pinned = !!columnId; return ( - + } + shouldUpdateScroll={shouldUpdateScroll} /> ); diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index db6bbdec1..12dab0e44 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -25,6 +25,7 @@ export default class HomeTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, intl: PropTypes.object.isRequired, hasUnread: PropTypes.bool, isPartial: PropTypes.bool, @@ -93,11 +94,11 @@ export default class HomeTimeline extends React.PureComponent { } render () { - const { intl, hasUnread, columnId, multiColumn } = this.props; + const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props; const pinned = !!columnId; return ( - + }} />} + shouldUpdateScroll={shouldUpdateScroll} /> ); diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.js b/app/javascript/mastodon/features/keyboard_shortcuts/index.js index 5ae7b34a2..2d86953c6 100644 --- a/app/javascript/mastodon/features/keyboard_shortcuts/index.js +++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.js @@ -40,6 +40,10 @@ export default class KeyboardShortcuts extends ImmutablePureComponent { m + + p + + f @@ -49,7 +53,7 @@ export default class KeyboardShortcuts extends ImmutablePureComponent { - enter + enter, o @@ -57,11 +61,11 @@ export default class KeyboardShortcuts extends ImmutablePureComponent { - up + up, k - down + down, j @@ -88,6 +92,54 @@ export default class KeyboardShortcuts extends ImmutablePureComponent { esc + + g+h + + + + g+n + + + + g+l + + + + g+t + + + + g+d + + + + g+s + + + + g+f + + + + g+p + + + + g+u + + + + g+b + + + + g+m + + + + g+r + + ? diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js index f08e77b7a..164669e89 100644 --- a/app/javascript/mastodon/features/list_timeline/index.js +++ b/app/javascript/mastodon/features/list_timeline/index.js @@ -35,6 +35,7 @@ export default class ListTimeline extends React.PureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, columnId: PropTypes.string, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, @@ -112,7 +113,7 @@ export default class ListTimeline extends React.PureComponent { } render () { - const { hasUnread, columnId, multiColumn, list } = this.props; + const { shouldUpdateScroll, hasUnread, columnId, multiColumn, list } = this.props; const { id } = this.props.params; const pinned = !!columnId; const title = list ? list.get('title') : id; @@ -136,7 +137,7 @@ export default class ListTimeline extends React.PureComponent { } return ( - + } + shouldUpdateScroll={shouldUpdateScroll} /> ); diff --git a/app/javascript/mastodon/features/lists/index.js b/app/javascript/mastodon/features/lists/index.js index 018e5a9e3..127347730 100644 --- a/app/javascript/mastodon/features/lists/index.js +++ b/app/javascript/mastodon/features/lists/index.js @@ -6,12 +6,13 @@ import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import { fetchLists } from '../../actions/lists'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import ColumnLink from '../ui/components/column_link'; import ColumnSubheading from '../ui/components/column_subheading'; import NewListForm from './components/new_list_form'; import { createSelector } from 'reselect'; +import ScrollableList from '../../components/scrollable_list'; const messages = defineMessages({ heading: { id: 'column.lists', defaultMessage: 'Lists' }, @@ -46,7 +47,7 @@ export default class Lists extends ImmutablePureComponent { } render () { - const { intl, lists } = this.props; + const { intl, shouldUpdateScroll, lists } = this.props; if (!lists) { return ( @@ -56,19 +57,24 @@ export default class Lists extends ImmutablePureComponent { ); } + const emptyMessage = ; + return ( -
- - + + {lists.map(list => )} -
+
); } diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js index bb351ece2..7bf9c1464 100644 --- a/app/javascript/mastodon/features/mutes/index.js +++ b/app/javascript/mastodon/features/mutes/index.js @@ -1,15 +1,16 @@ import React from 'react'; import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; import LoadingIndicator from '../../components/loading_indicator'; -import { ScrollContainer } from 'react-router-scroll-4'; import Column from '../ui/components/column'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import AccountContainer from '../../containers/account_container'; import { fetchMutes, expandMutes } from '../../actions/mutes'; -import { defineMessages, injectIntl } from 'react-intl'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from '../../components/scrollable_list'; const messages = defineMessages({ heading: { id: 'column.mutes', defaultMessage: 'Muted users' }, @@ -26,6 +27,7 @@ export default class Mutes extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, }; @@ -34,16 +36,12 @@ export default class Mutes extends ImmutablePureComponent { this.props.dispatch(fetchMutes()); } - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - - if (scrollTop === scrollHeight - clientHeight) { - this.props.dispatch(expandMutes()); - } - } + handleLoadMore = debounce(() => { + this.props.dispatch(expandMutes()); + }, 300, { leading: true }); render () { - const { intl, accountIds } = this.props; + const { intl, shouldUpdateScroll, accountIds } = this.props; if (!accountIds) { return ( @@ -53,16 +51,21 @@ export default class Mutes extends ImmutablePureComponent { ); } + const emptyMessage = ; + return ( - -
- {accountIds.map(id => - - )} -
-
+ + {accountIds.map(id => + + )} +
); } diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index f58224a8b..07fec84b2 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -3,11 +3,20 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import StatusContainer from '../../../containers/status_container'; import AccountContainer from '../../../containers/account_container'; -import { FormattedMessage } from 'react-intl'; +import { injectIntl, FormattedMessage } from 'react-intl'; import Permalink from '../../../components/permalink'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { HotKeys } from 'react-hotkeys'; +const notificationForScreenReader = (intl, message, timestamp) => { + const output = [message]; + + output.push(intl.formatDate(timestamp, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' })); + + return output.join(', '); +}; + +@injectIntl export default class Notification extends ImmutablePureComponent { static contextTypes = { @@ -20,6 +29,7 @@ export default class Notification extends ImmutablePureComponent { onMoveUp: PropTypes.func.isRequired, onMoveDown: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, }; handleMoveUp = () => { @@ -65,10 +75,12 @@ export default class Notification extends ImmutablePureComponent { }; } - renderFollow (account, link) { + renderFollow (notification, account, link) { + const { intl } = this.props; + return ( -
+
@@ -97,9 +109,11 @@ export default class Notification extends ImmutablePureComponent { } renderFavourite (notification, link) { + const { intl } = this.props; + return ( -
+
@@ -114,9 +128,11 @@ export default class Notification extends ImmutablePureComponent { } renderReblog (notification, link) { + const { intl } = this.props; + return ( -
+
@@ -138,7 +154,7 @@ export default class Notification extends ImmutablePureComponent { switch(notification.get('type')) { case 'follow': - return this.renderFollow(account, link); + return this.renderFollow(notification, account, link); case 'mention': return this.renderMention(notification); case 'favourite': diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 94a46b833..b7d7f361c 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -165,7 +165,7 @@ export default class Notifications extends React.PureComponent { ); return ( - + @@ -51,6 +52,7 @@ export default class PinnedStatuses extends ImmutablePureComponent { statusIds={statusIds} scrollKey='pinned_statuses' hasMore={hasMore} + shouldUpdateScroll={shouldUpdateScroll} /> ); diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index 2d5bb3baf..6d5c4118d 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -39,6 +39,7 @@ export default class PublicTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, intl: PropTypes.object.isRequired, columnId: PropTypes.string, multiColumn: PropTypes.bool, @@ -107,11 +108,11 @@ export default class PublicTimeline extends React.PureComponent { } render () { - const { intl, columnId, hasUnread, multiColumn, onlyMedia } = this.props; + const { intl, shouldUpdateScroll, columnId, hasUnread, multiColumn, onlyMedia } = this.props; const pinned = !!columnId; return ( - + } + shouldUpdateScroll={shouldUpdateScroll} /> ); diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js index 579d6aaa0..acb9b40f9 100644 --- a/app/javascript/mastodon/features/reblogs/index.js +++ b/app/javascript/mastodon/features/reblogs/index.js @@ -1,14 +1,15 @@ import React from 'react'; import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import LoadingIndicator from '../../components/loading_indicator'; import { fetchReblogs } from '../../actions/interactions'; -import { ScrollContainer } from 'react-router-scroll-4'; +import { FormattedMessage } from 'react-intl'; import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import ScrollableList from '../../components/scrollable_list'; const mapStateToProps = (state, props) => ({ accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]), @@ -20,6 +21,7 @@ export default class Reblogs extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, }; @@ -34,7 +36,7 @@ export default class Reblogs extends ImmutablePureComponent { } render () { - const { accountIds } = this.props; + const { shouldUpdateScroll, accountIds } = this.props; if (!accountIds) { return ( @@ -44,15 +46,21 @@ export default class Reblogs extends ImmutablePureComponent { ); } + const emptyMessage = ; + return ( - -
- {accountIds.map(id => )} -
-
+ + {accountIds.map(id => + + )} +
); } diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js index 9ff75a082..2552d94d8 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.js +++ b/app/javascript/mastodon/features/report/components/status_check_box.js @@ -36,6 +36,7 @@ export default class StatusCheckBox extends React.PureComponent { + + { - this.props.onDelete(this.props.status); + this.props.onDelete(this.props.status, this.context.router.history); } handleRedraftClick = () => { - this.props.onDelete(this.props.status, true); + this.props.onDelete(this.props.status, this.context.router.history, true); } handleDirectClick = () => { diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 417719004..b4bbda161 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -26,7 +26,7 @@ export default class DetailedStatus extends ImmutablePureComponent { }; handleAccountClick = (e) => { - if (e.button === 0) { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); } @@ -60,6 +60,7 @@ export default class DetailedStatus extends ImmutablePureComponent {