diff --git a/.circleci/config.yml b/.circleci/config.yml index 2355d9d7d..8ab08ebae 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2 aliases: - &defaults docker: - - image: circleci/ruby:2.6.0-stretch-node + - image: circleci/ruby:2.6-stretch-node environment: &ruby_environment BUNDLE_APP_CONFIG: ./.bundle/ DB_HOST: localhost @@ -105,14 +105,14 @@ jobs: install-ruby2.5: <<: *defaults docker: - - image: circleci/ruby:2.5.3-stretch-node + - image: circleci/ruby:2.5-stretch-node environment: *ruby_environment <<: *install_ruby_dependencies install-ruby2.4: <<: *defaults docker: - - image: circleci/ruby:2.4.5-stretch-node + - image: circleci/ruby:2.4-stretch-node environment: *ruby_environment <<: *install_ruby_dependencies @@ -131,40 +131,40 @@ jobs: test-ruby2.6: <<: *defaults docker: - - image: circleci/ruby:2.6.0-stretch-node + - image: circleci/ruby:2.6-stretch-node environment: *ruby_environment - image: circleci/postgres:10.6-alpine environment: POSTGRES_USER: root - - image: circleci/redis:5.0.3-alpine3.8 + - image: circleci/redis:5-alpine <<: *test_steps test-ruby2.5: <<: *defaults docker: - - image: circleci/ruby:2.5.3-stretch-node + - image: circleci/ruby:2.5-stretch-node environment: *ruby_environment - image: circleci/postgres:10.6-alpine environment: POSTGRES_USER: root - - image: circleci/redis:4.0.12-alpine + - image: circleci/redis:5-alpine <<: *test_steps test-ruby2.4: <<: *defaults docker: - - image: circleci/ruby:2.4.5-stretch-node + - image: circleci/ruby:2.4-stretch-node environment: *ruby_environment - image: circleci/postgres:10.6-alpine environment: POSTGRES_USER: root - - image: circleci/redis:4.0.12-alpine + - image: circleci/redis:5-alpine <<: *test_steps test-webui: <<: *defaults docker: - - image: circleci/node:8.15.0-stretch + - image: circleci/node:12.9-stretch steps: - *attach_workspace - run: ./bin/retry yarn test:jest diff --git a/Dockerfile b/Dockerfile index d8c7e0f0c..b5904ad95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM ubuntu:18.04 as build-dep SHELL ["bash", "-c"] # Install Node -ENV NODE_VER="8.15.0" +ENV NODE_VER="12.9.1" RUN echo "Etc/UTC" > /etc/localtime && \ apt update && \ apt -y install wget make gcc g++ python && \ @@ -17,7 +17,7 @@ RUN echo "Etc/UTC" > /etc/localtime && \ make install # Install jemalloc -ENV JE_VER="5.1.0" +ENV JE_VER="5.2.1" RUN apt update && \ apt -y install autoconf && \ cd ~ && \ @@ -30,7 +30,7 @@ RUN apt update && \ make install_bin install_include install_lib # Install ruby -ENV RUBY_VER="2.6.1" +ENV RUBY_VER="2.6.4" ENV CPPFLAGS="-I/opt/jemalloc/include" ENV LDFLAGS="-L/opt/jemalloc/lib/" RUN apt update && \ diff --git a/Gemfile b/Gemfile index d9b6e1049..9fb126bc5 100644 --- a/Gemfile +++ b/Gemfile @@ -12,7 +12,7 @@ gem 'thor', '~> 0.20' gem 'hamlit-rails', '~> 0.2' gem 'pg', '~> 1.1' gem 'makara', '~> 0.4' -gem 'pghero', '~> 2.2' +gem 'pghero', '~> 2.3' gem 'dotenv-rails', '~> 2.7' gem 'aws-sdk-s3', '~> 1.46', require: false @@ -31,7 +31,7 @@ gem 'charlock_holmes', '~> 0.7.6' gem 'iso-639' gem 'chewy', '~> 5.0' gem 'cld3', '~> 3.2.4' -gem 'devise', '~> 4.6' +gem 'devise', '~> 4.7' gem 'devise-two-factor', '~> 3.1' group :pam_authentication, optional: true do @@ -43,6 +43,7 @@ gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.9' +gem 'discard', '~> 1.1' gem 'doorkeeper', '~> 5.1' gem 'fast_blank', '~> 1.0' gem 'fastimage' @@ -61,12 +62,12 @@ gem 'mime-types', '~> 3.2', require: 'mime/types/columnar' gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532' gem 'nokogiri', '~> 1.10' gem 'nsa', '~> 0.2' -gem 'oj', '~> 3.8' +gem 'oj', '~> 3.9' gem 'ostatus2', '~> 2.0' gem 'ox', '~> 2.11' gem 'parslet' gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c' -gem 'pundit', '~> 2.0' +gem 'pundit', '~> 2.1' gem 'premailer-rails' gem 'rack-attack', '~> 6.1' gem 'rack-cors', '~> 1.0', require: 'rack/cors' @@ -80,7 +81,7 @@ gem 'sidekiq', '~> 5.2' gem 'sidekiq-scheduler', '~> 3.0' gem 'sidekiq-unique-jobs', '~> 6.0' gem 'sidekiq-bulk', '~>0.2.0' -gem 'simple-navigation', '~> 4.0' +gem 'simple-navigation', '~> 4.1' gem 'simple_form', '~> 4.1' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'stoplight', '~> 2.1.3' @@ -92,7 +93,7 @@ gem 'tzinfo-data', '~> 1.2019' gem 'webpacker', '~> 4.0' gem 'webpush' -gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: '345b7a5733308af827e8491d284dbafa9128d7a2' +gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: 'e742697a0906e74e8bb777ef98137bc3955d981d' gem 'json-ld-preloaded', '~> 3.0' gem 'rdf-normalize', '~> 0.3' @@ -133,7 +134,7 @@ group :development do gem 'letter_opener_web', '~> 1.3' gem 'memory_profiler' gem 'rubocop', '~> 0.74', require: false - gem 'rubocop-rails', '~> 2.2', require: false + gem 'rubocop-rails', '~> 2.3', require: false gem 'brakeman', '~> 4.6', require: false gem 'bundler-audit', '~> 0.6', require: false diff --git a/Gemfile.lock b/Gemfile.lock index ff7862285..3ca371a7a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,8 +7,8 @@ GIT GIT remote: https://github.com/ruby-rdf/json-ld.git - revision: 345b7a5733308af827e8491d284dbafa9128d7a2 - ref: 345b7a5733308af827e8491d284dbafa9128d7a2 + revision: e742697a0906e74e8bb777ef98137bc3955d981d + ref: e742697a0906e74e8bb777ef98137bc3955d981d specs: json-ld (3.0.2) htmlentities (~> 4.3) @@ -127,7 +127,7 @@ GEM brakeman (4.6.1) browser (2.6.1) builder (3.2.3) - bullet (6.0.1) + bullet (6.0.2) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundler-audit (0.6.1) @@ -188,10 +188,10 @@ GEM rack (>= 1) rake (> 10, < 13) thor (~> 0.19) - devise (4.6.2) + devise (4.7.0) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0, < 6.0) + railties (>= 4.1.0) responders warden (~> 1.2.3) devise-two-factor (3.1.0) @@ -204,6 +204,8 @@ GEM devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.3) + discard (1.1.0) + activerecord (>= 4.2, < 7) docile (1.3.2) domain_name (0.5.20180417) unf (>= 0.0.5, < 1.0.0) @@ -385,7 +387,7 @@ GEM concurrent-ruby (~> 1.0, >= 1.0.2) sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.8.1) + oj (3.9.0) omniauth (1.9.0) hashie (>= 3.4.6, < 3.7.0) rack (>= 1.6.2, < 3) @@ -421,9 +423,9 @@ GEM equatable (~> 0.5.0) tty-color (~> 0.4.0) pg (1.1.4) - pghero (2.2.1) - activerecord - pkg-config (1.3.7) + pghero (2.3.0) + activerecord (>= 5) + pkg-config (1.3.8) premailer (1.11.1) addressable css_parser (>= 1.6.0) @@ -443,7 +445,7 @@ GEM public_suffix (3.1.1) puma (4.1.0) nio4r (~> 2.0) - pundit (2.0.1) + pundit (2.1.0) activesupport (>= 3.0.0) raabro (1.1.6) rack (2.0.7) @@ -553,7 +555,7 @@ GEM rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 1.7) - rubocop-rails (2.2.1) + rubocop-rails (2.3.1) rack (>= 1.1) rubocop (>= 0.72.0) ruby-progressbar (1.10.1) @@ -582,7 +584,7 @@ GEM concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 4.0, < 7.0) thor (~> 0) - simple-navigation (4.0.5) + simple-navigation (4.1.0) activesupport (>= 2.3.2) simple_form (4.1.0) actionpack (>= 5.0) @@ -690,9 +692,10 @@ DEPENDENCIES concurrent-ruby connection_pool derailed_benchmarks - devise (~> 4.6) + devise (~> 4.7) devise-two-factor (~> 3.1) devise_pam_authenticatable2 (~> 9.2) + discard (~> 1.1) doorkeeper (~> 5.1) dotenv-rails (~> 2.7) fabrication (~> 2.20) @@ -729,7 +732,7 @@ DEPENDENCIES nilsimsa! nokogiri (~> 1.10) nsa (~> 0.2) - oj (~> 3.8) + oj (~> 3.9) omniauth (~> 1.9) omniauth-cas (~> 1.1) omniauth-saml (~> 1.10) @@ -740,7 +743,7 @@ DEPENDENCIES parallel_tests (~> 2.29) parslet pg (~> 1.1) - pghero (~> 2.2) + pghero (~> 2.3) pkg-config (~> 1.3) posix-spawn! premailer-rails @@ -748,7 +751,7 @@ DEPENDENCIES pry-byebug (~> 3.7) pry-rails (~> 0.3) puma (~> 4.1) - pundit (~> 2.0) + pundit (~> 2.1) rack-attack (~> 6.1) rack-cors (~> 1.0) rails (~> 5.2.3) @@ -764,13 +767,13 @@ DEPENDENCIES rspec-rails (~> 3.8) rspec-sidekiq (~> 3.0) rubocop (~> 0.74) - rubocop-rails (~> 2.2) + rubocop-rails (~> 2.3) sanitize (~> 5.0) sidekiq (~> 5.2) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 3.0) sidekiq-unique-jobs (~> 6.0) - simple-navigation (~> 4.0) + simple-navigation (~> 4.1) simple_form (~> 4.1) simplecov (~> 0.17) sprockets-rails (~> 3.2) diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index a2cea461e..ea56fa0ac 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -5,7 +5,7 @@ module Admin before_action :set_account def new - @account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true) + @account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true) @warning_presets = AccountWarningPreset.all end @@ -30,7 +30,7 @@ module Admin end def resource_params - params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification) + params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses) end end end diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 39aca2a4b..25d9b7d3d 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -37,7 +37,8 @@ module Admin def set_usage_by_domain @usage_by_domain = @tag.statuses - .where(visibility: :public) + .with_public_visibility + .excluding_silenced_accounts .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day))) .joins(:account) .group('accounts.domain') diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index de8fff30e..33df75b37 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -36,6 +36,14 @@ class Api::BaseController < ApplicationController render json: { error: 'This action is not allowed' }, status: 403 end + rescue_from Mastodon::RaceConditionError do + render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from ActionController::ParameterMissing do |e| + render json: { error: e.to_s }, status: 400 + end + def doorkeeper_unauthorized_render_options(error: nil) { json: { error: (error.try(:description) || 'Not authorized') } } end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 13cb4caf1..0787cd636 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -29,14 +29,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def account_statuses statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses - statuses = statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id)) statuses.merge!(only_media_scope) if truthy_param?(:only_media) statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) statuses.merge!(hashtag_scope) if params[:tagged].present? - statuses + statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id)) end def permitted_account_statuses diff --git a/app/controllers/api/v1/directories_controller.rb b/app/controllers/api/v1/directories_controller.rb new file mode 100644 index 000000000..c91543e3a --- /dev/null +++ b/app/controllers/api/v1/directories_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::DirectoriesController < Api::BaseController + before_action :require_enabled! + before_action :set_accounts + + def show + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def require_enabled! + return not_found unless Setting.profile_directory + end + + def set_accounts + @accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT)) + end + + def accounts_scope + Account.discoverable.tap do |scope| + scope.merge!(Account.local) if truthy_param?(:local) + scope.merge!(Account.by_recent_status) if params[:order].blank? || params[:order] == 'active' + scope.merge!(Account.order(id: :desc)) if params[:order] == 'new' + scope.merge!(Account.not_excluded_by_account(current_account)) if current_account + scope.merge!(Account.not_domain_blocked_by_account(current_account)) if current_account && !truthy_param?(:local) + end + end +end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index e182a9c6c..1b0b4b05b 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController private def reported_status_ids - reported_account.statuses.find(status_ids).pluck(:id) + reported_account.statuses.with_discarded.find(status_ids).pluck(:id) end def status_ids diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index ed4f55100..42381a37f 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController @reblogs_map = { @status.id => false } authorize status_for_destroy, :unreblog? + status_for_destroy.discard RemovalWorker.perform_async(status_for_destroy.id) render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map) @@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController end def status_for_destroy - current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! + @status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! end def reblog_params diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 71a505c26..bba3c0651 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -53,7 +53,8 @@ class Api::V1::StatusesController < Api::BaseController @status = Status.where(account_id: current_user.account).find(params[:id]) authorize @status, :destroy? - RemovalWorker.perform_async(@status.id) + @status.discard + RemovalWorker.perform_async(@status.id, redraft: true) render json: @status, serializer: REST::StatusSerializer, source_requested: true end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1caaa20f7..5b343a276 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,11 +21,13 @@ class ApplicationController < ActionController::Base helper_method :whitelist_mode? rescue_from ActionController::RoutingError, with: :not_found - rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity rescue_from ActionController::UnknownFormat, with: :not_acceptable + rescue_from ActionController::ParameterMissing, with: :bad_request + rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from Mastodon::NotPermittedError, with: :forbidden rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error + rescue_from Mastodon::RaceConditionError, with: :service_unavailable before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :require_functional!, if: :user_signed_in? @@ -96,10 +98,18 @@ class ApplicationController < ActionController::Base respond_with_error(406) end + def bad_request + respond_with_error(400) + end + def internal_server_error respond_with_error(500) end + def service_unavailable + respond_with_error(503) + end + def single_user_mode? @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? end diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb index a5c47b515..7da975a23 100644 --- a/app/controllers/directories_controller.rb +++ b/app/controllers/directories_controller.rb @@ -7,7 +7,6 @@ class DirectoriesController < ApplicationController before_action :require_enabled! before_action :set_instance_presenter before_action :set_tag, only: :show - before_action :set_tags before_action :set_accounts def index @@ -28,13 +27,10 @@ class DirectoriesController < ApplicationController @tag = Tag.discoverable.find_normalized!(params[:id]) end - def set_tags - @tags = Tag.discoverable.limit(30).reject { |tag| tag.cached_sample_accounts.empty? } - end - def set_accounts - @accounts = Account.discoverable.by_recent_status.page(params[:page]).per(40).tap do |query| + @accounts = Account.local.discoverable.by_recent_status.page(params[:page]).per(20).tap do |query| query.merge!(Account.tagged_with(@tag.id)) if @tag + query.merge!(Account.not_excluded_by_account(current_account)) if current_account end end diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb index 0fb71d335..ba963a7a0 100644 --- a/app/controllers/remote_follow_controller.rb +++ b/app/controllers/remote_follow_controller.rb @@ -29,7 +29,7 @@ class RemoteFollowController < ApplicationController end def session_params - { acct: session[:remote_follow] } + { acct: session[:remote_follow] || current_account&.username } end def set_body_classes diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb index de5616e25..15224e853 100644 --- a/app/controllers/remote_interaction_controller.rb +++ b/app/controllers/remote_interaction_controller.rb @@ -32,7 +32,7 @@ class RemoteInteractionController < ApplicationController end def session_params - { acct: session[:remote_follow] } + { acct: session[:remote_follow] || current_account&.username } end def set_status diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 50bace217..d60bf98ab 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -11,7 +11,7 @@ module WellKnown expires_in 3.days, public: true render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json' - rescue ActiveRecord::RecordNotFound + rescue ActiveRecord::RecordNotFound, ActionController::ParameterMissing head 404 end diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index e067380f6..8380b3c42 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -34,6 +34,26 @@ module StatusesHelper end end + def minimal_account_action_button(account) + if user_signed_in? + return if account.id == current_user.account_id + + if current_account.following?(account) || current_account.requested?(account) + link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do + fa_icon('user-times fw') + end + elsif !(account.memorial? || account.moved?) + link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do + fa_icon('user-plus fw') + end + end + elsif !(account.memorial? || account.moved?) + link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do + fa_icon('user-plus fw') + end + end + end + def svg_logo content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') end diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js index ef2500e7b..cd36d8007 100644 --- a/app/javascript/mastodon/actions/alerts.js +++ b/app/javascript/mastodon/actions/alerts.js @@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl'; const messages = defineMessages({ unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, + rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' }, + rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' }, }); export const ALERT_SHOW = 'ALERT_SHOW'; @@ -23,23 +25,29 @@ export function clearAlert() { }; }; -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) { +export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) { return { type: ALERT_SHOW, title, message, + message_values, }; }; export function showAlertForError(error) { if (error.response) { - const { data, status, statusText } = error.response; + const { data, status, statusText, headers } = error.response; if (status === 404 || status === 410) { // Skip these errors as they are reflected in the UI return { type: ALERT_NOOP }; } + if (status === 429 && headers['x-ratelimit-reset']) { + const reset_date = new Date(headers['x-ratelimit-reset']); + return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date }); + } + let message = statusText; let title = `${status}`; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index c27c53df0..061a36bb8 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -356,6 +356,8 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => { cancelFetchComposeSuggestionsTags(); } + dispatch(updateSuggestionTags(token)); + api(getState).get('/api/v2/search', { cancelToken: new CancelToken(cancel => { cancelFetchComposeSuggestionsTags = cancel; diff --git a/app/javascript/mastodon/actions/directory.js b/app/javascript/mastodon/actions/directory.js new file mode 100644 index 000000000..4b2b6dd56 --- /dev/null +++ b/app/javascript/mastodon/actions/directory.js @@ -0,0 +1,61 @@ +import api from '../api'; +import { importFetchedAccounts } from './importer'; +import { fetchRelationships } from './accounts'; + +export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +export const fetchDirectory = params => (dispatch, getState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); +}; + +export const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +export const fetchDirectorySuccess = accounts => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +export const fetchDirectoryFail = error => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +export const expandDirectory = params => (dispatch, getState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; + + api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); +}; + +export const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +export const expandDirectorySuccess = accounts => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +export const expandDirectoryFail = error => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.js b/app/javascript/mastodon/components/autosuggest_hashtag.js index eabb8b178..e2f4e320d 100644 --- a/app/javascript/mastodon/components/autosuggest_hashtag.js +++ b/app/javascript/mastodon/components/autosuggest_hashtag.js @@ -9,18 +9,18 @@ export default class AutosuggestHashtag extends React.PureComponent { tag: PropTypes.shape({ name: PropTypes.string.isRequired, url: PropTypes.string, - history: PropTypes.array.isRequired, + history: PropTypes.array, }).isRequired, }; render () { const { tag } = this.props; - const weeklyUses = shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); + const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); return (
#{tag.name}
-
+ {tag.history !== undefined &&
}
); } diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js index cc0e5c07c..d97622705 100644 --- a/app/javascript/mastodon/components/column_back_button.js +++ b/app/javascript/mastodon/components/column_back_button.js @@ -35,7 +35,19 @@ export default class ColumnBackButton extends React.PureComponent { if (multiColumn) { return component; } else { - return createPortal(component, document.getElementById('tabs-bar__portal')); + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } } } diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 89c5fe723..8a26742b5 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -178,7 +178,19 @@ class ColumnHeader extends React.PureComponent { if (multiColumn || placeholder) { return component; } else { - return createPortal(component, document.getElementById('tabs-bar__portal')); + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } } } diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 9cd71b7c9..55e0e77d6 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -315,15 +315,22 @@ class MediaGallery extends React.PureComponent { style.height = height; } - const size = media.take(4).size; + const size = media.take(4).size; + const uncached = media.every(attachment => attachment.get('type') === 'unknown'); if (this.isStandaloneEligible()) { children = ; } else { - children = media.take(4).map((attachment, i) => ); + children = media.take(4).map((attachment, i) => ); } - if (visible) { + if (uncached) { + spoilerButton = ( + + ); + } else if (visible) { spoilerButton = ; } else { spoilerButton = ( @@ -335,7 +342,7 @@ class MediaGallery extends React.PureComponent { return (
-
+
{spoilerButton}
diff --git a/app/javascript/mastodon/components/radio_button.js b/app/javascript/mastodon/components/radio_button.js new file mode 100644 index 000000000..0496fa286 --- /dev/null +++ b/app/javascript/mastodon/components/radio_button.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class RadioButton extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.node.isRequired, + }; + + render () { + const { name, value, checked, onChange, label } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 9b1035649..b5606aca5 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -12,7 +12,7 @@ import AttachmentList from './attachment_list'; import Card from '../features/status/components/card'; import { injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { MediaGallery, Video } from '../features/ui/util/async-components'; +import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { HotKeys } from 'react-hotkeys'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; @@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent { }; renderLoadingMediaGallery () { - return
; + return
; } renderLoadingVideoPlayer () { - return
; + return
; + } + + renderLoadingAudioPlayer () { + return
; } handleOpenVideo = (media, startTime) => { @@ -278,12 +282,27 @@ class Status extends ImmutablePureComponent { return null; } + const handlers = this.props.muted ? {} : { + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleHidden: this.handleHotkeyToggleHidden, + toggleSensitive: this.handleHotkeyToggleSensitive, + }; + if (hidden) { return ( -
- {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} - {status.get('content')} -
+ +
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {status.get('content')} +
+
); } @@ -333,7 +352,23 @@ class Status extends ImmutablePureComponent { media={status.get('media_attachments')} /> ); - } else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) { + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + + {Component => ( + + )} + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const attachment = status.getIn(['media_attachments', 0]); media = ( @@ -394,19 +429,6 @@ class Status extends ImmutablePureComponent { statusAvatar = ; } - const handlers = this.props.muted ? {} : { - reply: this.handleHotkeyReply, - favourite: this.handleHotkeyFavourite, - boost: this.handleHotkeyBoost, - mention: this.handleHotkeyMention, - open: this.handleHotkeyOpen, - openProfile: this.handleHotkeyOpenProfile, - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - toggleHidden: this.handleHotkeyToggleHidden, - toggleSensitive: this.handleHotkeyToggleSensitive, - }; - return (
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 6aa0bfcc2..c171e7a66 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -230,7 +230,7 @@ export default class StatusContent extends React.PureComponent { ); } else if (this.props.onClick) { const output = [ -
+
{!!status.get('poll') && } diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index 8fddb6f54..db340032a 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -8,6 +8,7 @@ import Video from '../features/video'; import Card from '../features/status/components/card'; import Poll from 'mastodon/components/poll'; import Hashtag from 'mastodon/components/hashtag'; +import Audio from 'mastodon/features/audio'; import ModalRoot from '../components/modal_root'; import { getScrollbarWidth } from '../features/ui/components/modal_root'; import MediaModal from '../features/ui/components/media_modal'; @@ -16,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag }; +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; export default class MediaContainer extends PureComponent { diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index 2609b96ff..d1b3c3bd4 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -96,6 +96,12 @@ export default class MediaItem extends ImmutablePureComponent { if (attachment.get('type') === 'unknown') { // Skip + } else if (attachment.get('type') === 'audio') { + thumbnail = ( + + + + ); } else if (attachment.get('type') === 'image') { const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index f1a665d8f..f3bf7a2bd 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -100,7 +100,7 @@ class AccountGallery extends ImmutablePureComponent { } handleOpenMedia = attachment => { - if (attachment.get('type') === 'video') { + if (['video', 'audio'].includes(attachment.get('type'))) { this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') })); } else { const media = attachment.getIn(['status', 'media_attachments']); diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js new file mode 100644 index 000000000..95e5675f3 --- /dev/null +++ b/app/javascript/mastodon/features/audio/index.js @@ -0,0 +1,226 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import WaveSurfer from 'wavesurfer.js'; +import { defineMessages, injectIntl } from 'react-intl'; +import { formatTime } from 'mastodon/features/video'; +import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; +import { throttle } from 'lodash'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, +}); + +export default @injectIntl +class Audio extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + duration: PropTypes.number, + peaks: PropTypes.arrayOf(PropTypes.number), + height: PropTypes.number, + preload: PropTypes.bool, + editable: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + state = { + currentTime: 0, + duration: null, + paused: true, + muted: false, + volume: 0.5, + }; + + // hard coded in components.scss + // any way to get ::before values programatically? + + volWidth = 50; + + volOffset = 70; + + volHandleOffset = v => { + const offset = v * this.volWidth + this.volOffset; + return (offset > 110) ? 110 : offset; + } + + setVolumeRef = c => { + this.volume = c; + } + + setWaveformRef = c => { + this.waveform = c; + } + + componentDidMount () { + if (this.waveform) { + this._updateWaveform(); + } + } + + componentDidUpdate (prevProps) { + if (this.waveform && prevProps.src !== this.props.src) { + this._updateWaveform(); + } + } + + componentWillUnmount () { + if (this.wavesurfer) { + this.wavesurfer.destroy(); + this.wavesurfer = null; + } + } + + _updateWaveform () { + const { src, height, duration, peaks, preload } = this.props; + + const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color'); + const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color'); + + if (this.wavesurfer) { + this.wavesurfer.destroy(); + this.loaded = false; + } + + const wavesurfer = WaveSurfer.create({ + container: this.waveform, + height, + barWidth: 3, + cursorWidth: 0, + progressColor, + waveColor, + backend: 'MediaElement', + interact: preload, + }); + + wavesurfer.setVolume(this.state.volume); + + if (preload) { + wavesurfer.load(src); + this.loaded = true; + } else { + wavesurfer.load(src, peaks, 'none', duration); + this.loaded = false; + } + + wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) })); + wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) })); + wavesurfer.on('pause', () => this.setState({ paused: true })); + wavesurfer.on('play', () => this.setState({ paused: false })); + wavesurfer.on('volume', volume => this.setState({ volume })); + wavesurfer.on('mute', muted => this.setState({ muted })); + + this.wavesurfer = wavesurfer; + } + + togglePlay = () => { + if (this.state.paused) { + if (!this.props.preload && !this.loaded) { + this.wavesurfer.createBackend(); + this.wavesurfer.createPeakCache(); + this.wavesurfer.load(this.props.src); + this.wavesurfer.toggleInteraction(); + this.loaded = true; + } + + this.wavesurfer.play(); + this.setState({ paused: false }); + } else { + this.wavesurfer.pause(); + this.setState({ paused: true }); + } + } + + toggleMute = () => { + this.wavesurfer.setMute(!this.state.muted); + } + + handleVolumeMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseVolSlide, true); + document.addEventListener('mouseup', this.handleVolumeMouseUp, true); + document.addEventListener('touchmove', this.handleMouseVolSlide, true); + document.addEventListener('touchend', this.handleVolumeMouseUp, true); + + this.handleMouseVolSlide(e); + + e.preventDefault(); + e.stopPropagation(); + } + + handleVolumeMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseVolSlide, true); + document.removeEventListener('mouseup', this.handleVolumeMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseVolSlide, true); + document.removeEventListener('touchend', this.handleVolumeMouseUp, true); + } + + handleMouseVolSlide = throttle(e => { + const rect = this.volume.getBoundingClientRect(); + const x = (e.clientX - rect.left) / this.volWidth; // x position within the element. + + if(!isNaN(x)) { + let slideamt = x; + + if (x > 1) { + slideamt = 1; + } else if(x < 0) { + slideamt = 0; + } + + this.wavesurfer.setVolume(slideamt); + } + }, 60); + + render () { + const { height, intl, alt, editable } = this.props; + const { paused, muted, volume, currentTime } = this.state; + + const volumeWidth = muted ? 0 : volume * this.volWidth; + const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume); + + return ( +
+
+
+ +
+ +
+
+
+ + + +
+
+ + +
+ + + {formatTime(currentTime)} + / + {formatTime(this.state.duration || Math.floor(this.props.duration))} + +
+
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js index d0303dbfb..dd2632796 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.js +++ b/app/javascript/mastodon/features/compose/components/action_bar.js @@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, + onLogout: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; + handleLogout = () => { + this.props.onLogout(); + } + render () { const { intl } = this.props; @@ -44,7 +49,7 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' }); menu.push(null); - menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' }); + menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout }); return (
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js index d8d49cb95..840d0a3da 100644 --- a/app/javascript/mastodon/features/compose/components/navigation_bar.js +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js @@ -12,6 +12,7 @@ export default class NavigationBar extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, + onLogout: PropTypes.func.isRequired, onClose: PropTypes.func, }; @@ -33,7 +34,7 @@ export default class NavigationBar extends ImmutablePureComponent {
- +
); diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js index eb9f3ea45..8606a642e 100644 --- a/app/javascript/mastodon/features/compose/containers/navigation_container.js +++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js @@ -1,11 +1,29 @@ import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; import NavigationBar from '../components/navigation_bar'; +import { logOut } from 'mastodon/utils/log_out'; +import { openModal } from 'mastodon/actions/modal'; import { me } from '../../../initial_state'; +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); + const mapStateToProps = state => { return { account: state.getIn(['accounts', me]), }; }; -export default connect(mapStateToProps)(NavigationBar); +const mapDispatchToProps = (dispatch, { intl }) => ({ + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + }, +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar)); diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index b19b1b2e4..474527a4b 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -12,9 +12,11 @@ import Motion from '../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; import SearchResultsContainer from './containers/search_results_container'; import { changeComposing } from '../../actions/compose'; +import { openModal } from 'mastodon/actions/modal'; import elephantUIPlane from '../../../images/elephant_ui_plane.svg'; import { mascot } from '../../initial_state'; import Icon from 'mastodon/components/icon'; +import { logOut } from 'mastodon/utils/log_out'; import AnnouncementsContainer from './containers/announcements_container'; const messages = defineMessages({ @@ -26,6 +28,8 @@ const messages = defineMessages({ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' }, + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, }); const mapStateToProps = (state, ownProps) => ({ @@ -62,6 +66,21 @@ class Compose extends React.PureComponent { } } + handleLogoutClick = e => { + const { dispatch, intl } = this.props; + + e.preventDefault(); + e.stopPropagation(); + + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + + return false; + } + onFocus = () => { this.props.dispatch(changeComposing(true)); } @@ -93,7 +112,7 @@ class Compose extends React.PureComponent { )} - + ); } diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js new file mode 100644 index 000000000..50ad74450 --- /dev/null +++ b/app/javascript/mastodon/features/directory/components/account_card.js @@ -0,0 +1,190 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'mastodon/selectors'; +import Avatar from 'mastodon/components/avatar'; +import DisplayName from 'mastodon/components/display_name'; +import Permalink from 'mastodon/components/permalink'; +import RelativeTimestamp from 'mastodon/components/relative_timestamp'; +import IconButton from 'mastodon/components/icon_button'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state'; +import { shortNumberFormat } from 'mastodon/utils/numbers'; +import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts'; +import { openModal } from 'mastodon/actions/modal'; +import { initMuteModal } from 'mastodon/actions/mutes'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onFollow (account) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(blockAccount(account.get('id'))); + } + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + +}); + +export default @injectIntl +@connect(makeMapStateToProps, mapDispatchToProps) +class AccountCard extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + }; + + _updateEmojis () { + const node = this.node; + + if (!node || autoPlayGif) { + return; + } + + const emojis = node.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + if (emoji.classList.contains('status-emoji')) { + continue; + } + emoji.classList.add('status-emoji'); + + emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); + emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); + } + } + + componentDidMount () { + this._updateEmojis(); + } + + componentDidUpdate () { + this._updateEmojis(); + } + + handleEmojiMouseEnter = ({ target }) => { + target.src = target.getAttribute('data-original'); + } + + handleEmojiMouseLeave = ({ target }) => { + target.src = target.getAttribute('data-static'); + } + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + setRef = (c) => { + this.node = c; + } + + render () { + const { account, intl } = this.props; + + let buttons; + + if (account.get('id') !== me && account.get('relationship', null) !== null) { + const following = account.getIn(['relationship', 'following']); + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); + + if (requested) { + buttons = ; + } else if (blocking) { + buttons = ; + } else if (muting) { + buttons = ; + } else if (!account.get('moved') || following) { + buttons = ; + } + } + + return ( +
+
+ +
+ +
+ + + + + +
+ {buttons} +
+
+ +
+
+
+ +
+
{shortNumberFormat(account.get('statuses_count'))}
+
{shortNumberFormat(account.get('followers_count'))}
+
{account.get('last_status_at') === null ? : }
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/directory/index.js b/app/javascript/mastodon/features/directory/index.js new file mode 100644 index 000000000..2f91e759b --- /dev/null +++ b/app/javascript/mastodon/features/directory/index.js @@ -0,0 +1,171 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from 'mastodon/components/column'; +import ColumnHeader from 'mastodon/components/column_header'; +import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns'; +import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory'; +import { List as ImmutableList } from 'immutable'; +import AccountCard from './components/account_card'; +import RadioButton from 'mastodon/components/radio_button'; +import classNames from 'classnames'; +import LoadMore from 'mastodon/components/load_more'; +import { ScrollContainer } from 'react-router-scroll-4'; + +const messages = defineMessages({ + title: { id: 'column.directory', defaultMessage: 'Browse profiles' }, + recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' }, + newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' }, + local: { id: 'directory.local', defaultMessage: 'From {domain} only' }, + federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()), + isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true), + domain: state.getIn(['meta', 'domain']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Directory extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + isLoading: PropTypes.bool, + accountIds: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + domain: PropTypes.string.isRequired, + params: PropTypes.shape({ + order: PropTypes.string, + local: PropTypes.bool, + }), + }; + + state = { + order: null, + local: null, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state))); + } + } + + getParams = (props, state) => ({ + order: state.order === null ? (props.params.order || 'active') : state.order, + local: state.local === null ? (props.params.local || false) : state.local, + }); + + handleMove = dir => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchDirectory(this.getParams(this.props, this.state))); + } + + componentDidUpdate (prevProps, prevState) { + const { dispatch } = this.props; + const paramsOld = this.getParams(prevProps, prevState); + const paramsNew = this.getParams(this.props, this.state); + + if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) { + dispatch(fetchDirectory(paramsNew)); + } + } + + setRef = c => { + this.column = c; + } + + handleChangeOrder = e => { + const { dispatch, columnId } = this.props; + + if (columnId) { + dispatch(changeColumnParams(columnId, ['order'], e.target.value)); + } else { + this.setState({ order: e.target.value }); + } + } + + handleChangeLocal = e => { + const { dispatch, columnId } = this.props; + + if (columnId) { + dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1')); + } else { + this.setState({ local: e.target.value === '1' }); + } + } + + handleLoadMore = () => { + const { dispatch } = this.props; + dispatch(expandDirectory(this.getParams(this.props, this.state))); + } + + render () { + const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props; + const { order, local } = this.getParams(this.props, this.state); + const pinned = !!columnId; + + const scrollableArea = ( +
+
+
+ + +
+ +
+ + +
+
+ +
+ {accountIds.map(accountId => )} +
+ + +
+ ); + + return ( + + + + {multiColumn && !pinned ? {scrollableArea} : scrollableArea} + + ); + } + +} diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js index 1dcacc8b3..3b9a3075f 100644 --- a/app/javascript/mastodon/features/getting_started/components/trends.js +++ b/app/javascript/mastodon/features/getting_started/components/trends.js @@ -3,6 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Hashtag from 'mastodon/components/hashtag'; +import { FormattedMessage } from 'react-intl'; export default class Trends extends ImmutablePureComponent { @@ -17,7 +18,7 @@ export default class Trends extends ImmutablePureComponent { componentDidMount () { this.props.fetchTrends(); - this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000); + this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000); } componentWillUnmount () { @@ -35,6 +36,8 @@ export default class Trends extends ImmutablePureComponent { return (
+

+ {trends.take(3).map(hashtag => )}
); diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 3c6dc7194..f20a752fc 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -109,7 +109,7 @@ class GettingStarted extends ImmutablePureComponent { if (profile_directory) { navItems.push( - , + , , ); @@ -123,7 +123,7 @@ class GettingStarted extends ImmutablePureComponent { height += 34; } else if (profile_directory) { navItems.push( - , + , , ); diff --git a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js index c5098052c..5914bbeaf 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js @@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({ }, onLoad (value) { - return api().get('/api/v2/search', { params: { q: value } }).then(response => { + return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => { return (response.data.hashtags || []).map((tag) => { return { value: tag.name, label: `#${tag.name}` }; }); diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 4af157af1..e97f18f08 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl'; import Card from './card'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Video from '../../video'; +import Audio from '../../audio'; import scheduleIdleTask from '../../ui/util/schedule_idle_task'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; @@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent { } if (status.get('media_attachments').size > 0) { - if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) { + if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( +
)} - {['audio', 'video'].includes(media.get('type')) && ( + {media.get('type') === 'video' && (