diff --git a/.circleci/config.yml b/.circleci/config.yml index 70d03f6b9..8791965f0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -11,6 +11,8 @@ aliases: RAILS_ENV: test PARALLEL_TEST_PROCESSORS: 4 ALLOW_NOPAM: true + CONTINUOUS_INTEGRATION: true + DISABLE_SIMPLECOV: true working_directory: ~/projects/mastodon/ - &attach_workspace @@ -90,7 +92,7 @@ aliases: command: ./bin/rails parallel:create parallel:load_schema parallel:prepare - run: name: Run Tests - command: bundle exec parallel_test ./spec/ --group-by filesize --type rspec + command: ./bin/retry bundle exec parallel_test ./spec/ --group-by filesize --type rspec jobs: install: @@ -150,7 +152,7 @@ jobs: - image: circleci/node:8.11.1-stretch steps: - *attach_workspace - - run: yarn test:jest + - run: ./bin/retry yarn test:jest check-i18n: <<: *defaults diff --git a/.env.production.sample b/.env.production.sample index eddd38d90..d88af6007 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -88,6 +88,10 @@ SMTP_FROM_ADDRESS=notifications@example.com # CDN_HOST=https://assets.example.com # S3 (optional) +# The attachment host must allow cross origin request from WEB_DOMAIN or +# LOCAL_DOMAIN if WEB_DOMAIN is not set. For example, the server may have the +# following header field: +# Access-Control-Allow-Origin: https://192.168.1.123:9000/ # S3_ENABLED=true # S3_BUCKET= # AWS_ACCESS_KEY_ID= @@ -97,6 +101,8 @@ SMTP_FROM_ADDRESS=notifications@example.com # S3_HOSTNAME=192.168.1.123:9000 # S3 (Minio Config (optional) Please check Minio instance for details) +# The attachment host must allow cross origin request - see the description +# above. # S3_ENABLED=true # S3_BUCKET= # AWS_ACCESS_KEY_ID= @@ -108,11 +114,15 @@ SMTP_FROM_ADDRESS=notifications@example.com # S3_SIGNATURE_VERSION= # Swift (optional) +# The attachment host must allow cross origin request - see the description +# above. # SWIFT_ENABLED=true # SWIFT_USERNAME= # For Keystone V3, the value for SWIFT_TENANT should be the project name # SWIFT_TENANT= # SWIFT_PASSWORD= +# Some OpenStack V3 providers require PROJECT_ID (optional) +# SWIFT_PROJECT_ID= # Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid # issues with token rate-limiting during high load. # SWIFT_AUTH_URL= diff --git a/.eslintrc.yml b/.eslintrc.yml index 33115853d..da12088b4 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -7,6 +7,9 @@ env: es6: true jest: true +globals: + ATTACHMENT_HOST: false + parser: babel-eslint plugins: diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/bug_report.md similarity index 80% rename from .github/ISSUE_TEMPLATE.md rename to .github/ISSUE_TEMPLATE/bug_report.md index c78bcb492..602530db0 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,3 +1,9 @@ +--- +name: Bug Report +about: Create a report to help us improve + +--- + [Issue text goes here]. * * * * diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..46602fd2c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,11 @@ +--- +name: Feature Request +about: Suggest an idea for this project + +--- + +[Issue text goes here]. + +* * * * + +- [ ] I searched or browsed the repo’s other issues to ensure this is not a duplicate. diff --git a/.travis.yml b/.travis.yml index 238b9a3f6..1529c81fc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -37,7 +37,7 @@ addons: rvm: - 2.4.3 - - 2.5.0 + - 2.5.1 services: - redis-server @@ -47,6 +47,10 @@ install: - bundle install --path=vendor/bundle --with pam_authentication --without development production --retry=3 --jobs=16 - yarn install +# https://github.com/travis-ci/travis-ci/issues/9333 +before_install: + - gem install bundler + before_script: - travis_wait ./bin/rails parallel:create parallel:load_schema parallel:prepare assets:precompile diff --git a/Gemfile b/Gemfile index 500628ad1..ecf9d2230 100644 --- a/Gemfile +++ b/Gemfile @@ -19,7 +19,6 @@ gem 'fog-local', '~> 0.5', require: false gem 'fog-openstack', '~> 0.1', require: false gem 'paperclip', '~> 6.0' gem 'paperclip-av-transcoder', '~> 0.6' -gem 'posix-spawn', '~> 0.3' gem 'streamio-ffmpeg', '~> 3.0' gem 'active_model_serializers', '~> 0.10' @@ -42,7 +41,7 @@ gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.2' -gem 'doorkeeper', '~> 4.3' +gem 'doorkeeper', '~> 4.2', '< 4.3' gem 'fast_blank', '~> 1.0' gem 'fastimage' gem 'goldfinger', '~> 2.1' @@ -52,6 +51,7 @@ gem 'html2text' gem 'htmlentities', '~> 4.3' gem 'http', '~> 3.2' gem 'http_accept_language', '~> 2.1' +gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2' gem 'httplog', '~> 1.0' gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.1' @@ -62,6 +62,7 @@ gem 'nsa', '~> 0.2' gem 'oj', '~> 3.5' gem 'ostatus2', '~> 2.0' gem 'ox', '~> 2.9' +gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c' gem 'pundit', '~> 1.1' gem 'premailer-rails' gem 'rack-attack', '~> 5.2' @@ -113,7 +114,6 @@ group :test do gem 'microformats', '~> 4.0' gem 'rails-controller-testing', '~> 1.0' gem 'rspec-sidekiq', '~> 3.0' - gem 'rspec-retry', '~> 0.5', require: false gem 'simplecov', '~> 0.16', require: false gem 'webmock', '~> 3.3' gem 'parallel_tests', '~> 2.21' diff --git a/Gemfile.lock b/Gemfile.lock index 36554c1ed..5c1cee0a5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,17 @@ +GIT + remote: https://github.com/rtomayko/posix-spawn + revision: 58465d2e213991f8afb13b984854a49fcdcc980c + ref: 58465d2e213991f8afb13b984854a49fcdcc980c + specs: + posix-spawn (0.3.13) + +GIT + remote: https://github.com/tmm1/http_parser.rb + revision: 54b17ba8c7d8d20a16dfc65d1775241833219cf2 + ref: 54b17ba8c7d8d20a16dfc65d1775241833219cf2 + specs: + http_parser.rb (0.6.1) + GEM remote: https://rubygems.org/ specs: @@ -167,7 +181,7 @@ GEM docile (1.3.0) domain_name (0.5.20180417) unf (>= 0.0.5, < 1.0.0) - doorkeeper (4.3.2) + doorkeeper (4.2.6) railties (>= 4.2) dotenv (2.2.2) dotenv-rails (2.2.2) @@ -254,7 +268,6 @@ GEM domain_name (~> 0.5) http-form_data (2.1.0) http_accept_language (2.1.1) - http_parser.rb (0.6.0) httplog (1.0.2) colorize (~> 0.8) rack (>= 1.0) @@ -383,7 +396,6 @@ GEM pghero (2.1.0) activerecord pkg-config (1.3.0) - posix-spawn (0.3.13) powerpack (0.1.1) premailer (1.11.1) addressable @@ -503,8 +515,6 @@ GEM rspec-expectations (~> 3.7.0) rspec-mocks (~> 3.7.0) rspec-support (~> 3.7.0) - rspec-retry (0.5.7) - rspec-core (> 3.3) rspec-sidekiq (3.0.3) rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) @@ -663,7 +673,7 @@ DEPENDENCIES devise (~> 4.4) devise-two-factor (~> 3.0) devise_pam_authenticatable2 (~> 9.1) - doorkeeper (~> 4.3) + doorkeeper (~> 4.2, < 4.3) dotenv-rails (~> 2.2, < 2.3) fabrication (~> 2.20) faker (~> 1.8) @@ -680,6 +690,7 @@ DEPENDENCIES htmlentities (~> 4.3) http (~> 3.2) http_accept_language (~> 2.1) + http_parser.rb (~> 0.6)! httplog (~> 1.0) i18n-tasks (~> 0.9) idn-ruby @@ -709,7 +720,7 @@ DEPENDENCIES pg (~> 1.0) pghero (~> 2.1) pkg-config (~> 1.3) - posix-spawn (~> 0.3) + posix-spawn! premailer-rails private_address_check (~> 0.4.1) pry-byebug (~> 3.6) @@ -729,7 +740,6 @@ DEPENDENCIES redis-rails (~> 5.0) rqrcode (~> 0.10) rspec-rails (~> 3.7) - rspec-retry (~> 0.5) rspec-sidekiq (~> 3.0) rubocop (~> 0.55) ruby-progressbar (~> 1.4) diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb index 34dfb458e..8d3477e66 100644 --- a/app/controllers/admin/confirmations_controller.rb +++ b/app/controllers/admin/confirmations_controller.rb @@ -3,6 +3,7 @@ module Admin class ConfirmationsController < BaseController before_action :set_user + before_action :check_confirmation, only: [:resend] def create authorize @user, :confirm? @@ -11,10 +12,28 @@ module Admin redirect_to admin_accounts_path end + def resend + authorize @user, :confirm? + + @user.resend_confirmation_instructions + + log_action :confirm, @user + + flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success') + redirect_to admin_accounts_path + end + private def set_user @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) end + + def check_confirmation + if @user.confirmed? + flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') + redirect_to admin_accounts_path + end + end end end diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb index 522f68c98..d3c2f5e9e 100644 --- a/app/controllers/admin/reported_statuses_controller.rb +++ b/app/controllers/admin/reported_statuses_controller.rb @@ -3,7 +3,6 @@ module Admin class ReportedStatusesController < BaseController before_action :set_report - before_action :set_status, only: [:update, :destroy] def create authorize :status, :update? @@ -14,20 +13,6 @@ module Admin redirect_to admin_report_path(@report) end - def update - authorize @status, :update? - @status.update!(status_params) - log_action :update, @status - redirect_to admin_report_path(@report) - end - - def destroy - authorize @status, :destroy? - RemovalWorker.perform_async(@status.id) - log_action :destroy, @status - render json: @status - end - private def status_params @@ -51,9 +36,5 @@ module Admin def set_report @report = Report.find(params[:report_id]) end - - def set_status - @status = @report.statuses.find(params[:id]) - end end end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index d5787acfb..382bfc4a2 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -5,7 +5,6 @@ module Admin helper_method :current_params before_action :set_account - before_action :set_status, only: [:update, :destroy] PER_PAGE = 20 @@ -26,40 +25,18 @@ module Admin def create authorize :status, :update? - @form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account)) + @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) end - def update - authorize @status, :update? - @status.update!(status_params) - log_action :update, @status - redirect_to admin_account_statuses_path(@account.id, current_params) - end - - def destroy - authorize @status, :destroy? - RemovalWorker.perform_async(@status.id) - log_action :destroy, @status - render json: @status - end - private - def status_params - params.require(:status).permit(:sensitive) - end - def form_status_batch_params params.require(:form_status_batch).permit(:action, status_ids: []) end - def set_status - @status = @account.statuses.find(params[:id]) - end - def set_account @account = Account.find(params[:account_id]) end @@ -72,5 +49,15 @@ module Admin page: page > 1 && page, }.select { |_, value| value.present? } end + + def action_from_button + if params[:nsfw_on] + 'nsfw_on' + elsif params[:nsfw_off] + 'nsfw_off' + elsif params[:delete] + 'delete' + end + end end end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index a3c4008e6..259d07be8 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -21,7 +21,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController private def account_params - params.permit(:display_name, :note, :avatar, :header, :locked, fields_attributes: [:name, :value]) + params.permit(:display_name, :note, :avatar, :header, :locked, :bot, fields_attributes: [:name, :value]) end def user_settings_params diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index c4f600c54..4578cf6ca 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -19,6 +19,8 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController end def load_accounts + return [] if @account.user_hides_network? && current_account.id != @account.id + default_accounts.merge(paginated_follows).to_a end diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 90b1f7fc5..ce2bbda85 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -19,6 +19,8 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController end def load_accounts + return [] if @account.user_hides_network? && current_account.id != @account.id + default_accounts.merge(paginated_follows).to_a end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index cbcc7ef04..c40155cb5 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -27,19 +27,17 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def account_statuses - default_statuses.tap do |statuses| - statuses.merge!(only_media_scope) if truthy_param?(:only_media) - statuses.merge!(pinned_scope) if truthy_param?(:pinned) - statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) - end - end - - def default_statuses - permitted_account_statuses.paginate_by_max_id( + statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses + statuses = statuses.paginate_by_max_id( limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id] ) + + statuses.merge!(only_media_scope) if truthy_param?(:only_media) + statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) + + statuses end def permitted_account_statuses diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb new file mode 100644 index 000000000..1a19bd0ef --- /dev/null +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class Api::V1::Push::SubscriptionsController < Api::BaseController + before_action -> { doorkeeper_authorize! :push } + before_action :require_user! + before_action :set_web_push_subscription + + def create + @web_subscription&.destroy! + + @web_subscription = ::Web::PushSubscription.create!( + endpoint: subscription_params[:endpoint], + key_p256dh: subscription_params[:keys][:p256dh], + key_auth: subscription_params[:keys][:auth], + data: data_params, + user_id: current_user.id, + access_token_id: doorkeeper_token.id + ) + + render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer + end + + def show + raise ActiveRecord::RecordNotFound if @web_subscription.nil? + + render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer + end + + def update + raise ActiveRecord::RecordNotFound if @web_subscription.nil? + + @web_subscription.update!(data: data_params) + + render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer + end + + def destroy + @web_subscription&.destroy! + render_empty + end + + private + + def set_web_push_subscription + @web_subscription = ::Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id) + end + + def subscription_params + params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) + end + + def data_params + return {} if params[:data].blank? + params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention]) + end +end diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb index bba6a6f48..54f8be667 100644 --- a/app/controllers/api/v1/statuses/pins_controller.rb +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -39,7 +39,7 @@ class Api::V1::Statuses::PinsController < Api::BaseController adapter: ActivityPub::Adapter ).as_json - ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account) + ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id) end def distribute_remove_activity! @@ -49,6 +49,6 @@ class Api::V1::Statuses::PinsController < Api::BaseController adapter: ActivityPub::Adapter ).as_json - ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account) + ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id) end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 01880565c..289d91045 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -10,6 +10,12 @@ class Api::V1::StatusesController < Api::BaseController respond_to :json + # This API was originally unlimited, pagination cannot be introduced without + # breaking backwards-compatibility. Arbitrarily high number to cover most + # conversations as quasi-unlimited, it would be too much work to render more + # than this anyway + CONTEXT_LIMIT = 4_096 + def show cached = Rails.cache.read(@status.cache_key) @status = cached unless cached.nil? @@ -17,8 +23,8 @@ class Api::V1::StatusesController < Api::BaseController end def context - ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(DEFAULT_STATUSES_LIMIT, current_account) - descendants_results = @status.descendants(DEFAULT_STATUSES_LIMIT, current_account) + ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(CONTEXT_LIMIT, current_account) + descendants_results = @status.descendants(CONTEXT_LIMIT, current_account) loaded_ancestors = cache_collection(ancestors_results, Status) loaded_descendants = cache_collection(descendants_results, Status) diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb index d455227eb..ef64078be 100644 --- a/app/controllers/api/v1/timelines/direct_controller.rb +++ b/app/controllers/api/v1/timelines/direct_controller.rb @@ -23,15 +23,18 @@ class Api::V1::Timelines::DirectController < Api::BaseController end def direct_statuses - direct_timeline_statuses.paginate_by_max_id( - limit_param(DEFAULT_STATUSES_LIMIT), - params[:max_id], - params[:since_id] - ) + direct_timeline_statuses end def direct_timeline_statuses - Status.as_direct_timeline(current_account) + # this query requires built in pagination. + Status.as_direct_timeline( + current_account, + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id], + true # returns array of cache_ids object + ) end def insert_pagination_headers diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb new file mode 100644 index 000000000..bcea9857e --- /dev/null +++ b/app/controllers/api/v1/trends_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::V1::TrendsController < Api::BaseController + before_action :set_tags + + respond_to :json + + def index + render json: @tags, each_serializer: REST::TagSerializer + end + + private + + def set_tags + @tags = TrendingTags.get(limit_param(10)) + end +end diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb new file mode 100644 index 000000000..2e91d68ee --- /dev/null +++ b/app/controllers/api/v2/search_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Api::V2::SearchController < Api::V1::SearchController + def index + @search = Search.new(search) + render json: @search, serializer: REST::V2::SearchSerializer + end +end diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 249e7c186..fe8e42580 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -31,22 +31,23 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController endpoint: subscription_params[:endpoint], key_p256dh: subscription_params[:keys][:p256dh], key_auth: subscription_params[:keys][:auth], - data: data + data: data, + user_id: active_session.user_id, + access_token_id: active_session.access_token_id ) active_session.update!(web_push_subscription: web_subscription) - render json: web_subscription.as_payload + render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer end def update params.require([:id]) web_subscription = ::Web::PushSubscription.find(params[:id]) - web_subscription.update!(data: data_params) - render json: web_subscription.as_payload + render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer end private @@ -56,6 +57,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController end def data_params - @data_params ||= params.require(:data).permit(:alerts) + @data_params ||= params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention]) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 158c0c10e..cc92894a5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base include Localized include UserTrackingConcern + include SessionTrackingConcern helper_method :current_account helper_method :current_session @@ -20,6 +21,7 @@ class ApplicationController < ActionController::Base 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 Mastodon::NotPermittedError, with: :forbidden before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? @@ -142,6 +144,10 @@ class ApplicationController < ActionController::Base respond_with_error(422) end + def not_acceptable + respond_with_error(406) + end + def single_user_mode? @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists? end diff --git a/app/controllers/concerns/session_tracking_concern.rb b/app/controllers/concerns/session_tracking_concern.rb new file mode 100644 index 000000000..45361b019 --- /dev/null +++ b/app/controllers/concerns/session_tracking_concern.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module SessionTrackingConcern + extend ActiveSupport::Concern + + UPDATE_SIGN_IN_HOURS = 24 + + included do + before_action :set_session_activity + end + + private + + def set_session_activity + return unless session_needs_update? + current_session.touch + end + + def session_needs_update? + !current_session.nil? && current_session.updated_at < UPDATE_SIGN_IN_HOURS.hours.ago + end +end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index f289228d3..41aa1c8a6 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -107,9 +107,7 @@ module SignatureVerification def incompatible_signature?(signature_params) signature_params['keyId'].blank? || - signature_params['signature'].blank? || - signature_params['algorithm'].blank? || - signature_params['algorithm'] != 'rsa-sha256' + signature_params['signature'].blank? end def account_from_key_id(key_id) diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index c74d3f86d..f5670c6bf 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -4,16 +4,19 @@ class FollowerAccountsController < ApplicationController include AccountControllerConcern def index - @follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) - respond_to do |format| format.html do use_pack 'public' - @relationships = AccountRelationshipsPresenter.new(@follows.map(&:account_id), current_user.account_id) if user_signed_in? + next if @account.user_hides_network? + + follows + @relationships = AccountRelationshipsPresenter.new(follows.map(&:account_id), current_user.account_id) if user_signed_in? end format.json do + raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, @@ -24,28 +27,31 @@ class FollowerAccountsController < ApplicationController private + def follows + @follows ||= Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) + end + def page_url(page) account_followers_url(@account, page: page) unless page.nil? end def collection_presenter - page = ActivityPub::CollectionPresenter.new( - id: account_followers_url(@account, page: params.fetch(:page, 1)), - type: :ordered, - size: @account.followers_count, - items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, - part_of: account_followers_url(@account), - next: page_url(@follows.next_page), - prev: page_url(@follows.prev_page) - ) if params[:page].present? - page + ActivityPub::CollectionPresenter.new( + id: account_followers_url(@account, page: params.fetch(:page, 1)), + type: :ordered, + size: @account.followers_count, + items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, + part_of: account_followers_url(@account), + next: page_url(follows.next_page), + prev: page_url(follows.prev_page) + ) else ActivityPub::CollectionPresenter.new( id: account_followers_url(@account), type: :ordered, size: @account.followers_count, - first: page + first: page_url(1) ) end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 4c1e3f327..098b2a20c 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -4,16 +4,19 @@ class FollowingAccountsController < ApplicationController include AccountControllerConcern def index - @follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) - respond_to do |format| format.html do use_pack 'public' - @relationships = AccountRelationshipsPresenter.new(@follows.map(&:target_account_id), current_user.account_id) if user_signed_in? + next if @account.user_hides_network? + + follows + @relationships = AccountRelationshipsPresenter.new(follows.map(&:target_account_id), current_user.account_id) if user_signed_in? end format.json do + raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, @@ -24,28 +27,31 @@ class FollowingAccountsController < ApplicationController private + def follows + @follows ||= Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) + end + def page_url(page) account_following_index_url(@account, page: page) unless page.nil? end def collection_presenter - page = ActivityPub::CollectionPresenter.new( - id: account_following_index_url(@account, page: params.fetch(:page, 1)), - type: :ordered, - size: @account.following_count, - items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }, - part_of: account_following_index_url(@account), - next: page_url(@follows.next_page), - prev: page_url(@follows.prev_page) - ) if params[:page].present? - page + ActivityPub::CollectionPresenter.new( + id: account_following_index_url(@account, page: params.fetch(:page, 1)), + type: :ordered, + size: @account.following_count, + items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }, + part_of: account_following_index_url(@account), + next: page_url(follows.next_page), + prev: page_url(follows.prev_page) + ) else ActivityPub::CollectionPresenter.new( id: account_following_index_url(@account), type: :ordered, size: @account.following_count, - first: page + first: page_url(1) ) end end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 189e4072e..70818610c 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -11,7 +11,7 @@ class InvitesController < ApplicationController def index authorize :invite, :create? - @invites = Invite.where(user: current_user) + @invites = invites @invite = Invite.new(expires_in: 1.day.to_i) end @@ -24,13 +24,13 @@ class InvitesController < ApplicationController if @invite.save redirect_to invites_path else - @invites = Invite.where(user: current_user) + @invites = invites render :index end end def destroy - @invite = Invite.where(user: current_user).find(params[:id]) + @invite = invites.find(params[:id]) authorize @invite, :destroy? @invite.expire! redirect_to invites_path @@ -42,6 +42,10 @@ class InvitesController < ApplicationController use_pack 'settings' end + def invites + Invite.where(user: current_user) + end + def resource_params params.require(:invite).permit(:max_uses, :expires_in) end diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index 155670837..d820b257e 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -8,6 +8,8 @@ class MediaProxyController < ApplicationController if lock.acquired? @media_attachment = MediaAttachment.remote.find(params[:id]) redownload! if @media_attachment.needs_redownload? && !reject_media? + else + raise Mastodon::RaceConditionError end end diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index f95d672ec..1e420b3e7 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -9,6 +9,11 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio include Localized + def destroy + Web::PushSubscription.unsubscribe_for(params[:id], current_resource_owner) + super + end + private def store_current_location diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb new file mode 100644 index 000000000..fa6d58f25 --- /dev/null +++ b/app/controllers/oauth/tokens_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Oauth::TokensController < Doorkeeper::TokensController + def revoke + unsubscribe_for_token if authorized? && token.accessible? + super + end + + private + + def unsubscribe_for_token + Web::PushSubscription.where(access_token_id: token.id).delete_all + end +end diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb index 35a6f7f9e..03c890a50 100644 --- a/app/controllers/settings/applications_controller.rb +++ b/app/controllers/settings/applications_controller.rb @@ -6,7 +6,7 @@ class Settings::ApplicationsController < Settings::BaseController before_action :prepare_scopes, only: [:create, :update] def index - @applications = current_user.applications.page(params[:page]) + @applications = current_user.applications.order(id: :desc).page(params[:page]) end def new diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index c853b5ab7..425664d49 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -40,6 +40,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_reduce_motion, :setting_system_font_ui, :setting_noindex, + :setting_hide_network, notification_emails: %i(follow follow_request reblog favourite mention digest), interactions: %i(must_be_follower must_be_following) ) diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 2b8330f2e..918dbc6c6 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -17,6 +17,7 @@ class Settings::ProfilesController < Settings::BaseController ActivityPub::UpdateDistributionWorker.perform_async(@account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') else + @account.build_fields render :show end end @@ -24,7 +25,7 @@ class Settings::ProfilesController < Settings::BaseController private def account_params - params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, fields_attributes: [:name, :value]) + params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, fields_attributes: [:name, :value]) end def set_account diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index 3cbaccb35..4624c29a6 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -16,6 +16,7 @@ class SharesController < ApplicationController def initial_state_params text = [params[:title], params[:text], params[:url]].compact.join(' ') + { settings: Web::Setting.find_by(user: current_user)&.data || {}, push_subscription: current_account.user.web_push_subscription(current_session), diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb index fdfadef08..49e764cef 100644 --- a/app/helpers/admin/account_moderation_notes_helper.rb +++ b/app/helpers/admin/account_moderation_notes_helper.rb @@ -10,10 +10,16 @@ module Admin::AccountModerationNotesHelper end end + def admin_account_inline_link_to(account) + link_to admin_account_path(account.id), class: name_tag_classes(account, true) do + content_tag(:span, account.acct, class: 'username') + end + end + private - def name_tag_classes(account) - classes = ['name-tag'] + def name_tag_classes(account, inline = false) + classes = [inline ? 'inline-name-tag' : 'name-tag'] classes << 'suspended' if account.suspended? classes.join(' ') end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index e9056166c..9d2b6cf00 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -52,18 +52,22 @@ module JsonLdHelper graph.dump(:normalize) end - def fetch_resource(uri, id) + def fetch_resource(uri, id, on_behalf_of = nil) unless id - json = fetch_resource_without_id_validation(uri) + json = fetch_resource_without_id_validation(uri, on_behalf_of) return unless json uri = json['id'] end - json = fetch_resource_without_id_validation(uri) + json = fetch_resource_without_id_validation(uri, on_behalf_of) json.present? && json['id'] == uri ? json : nil end - def fetch_resource_without_id_validation(uri) + def fetch_resource_without_id_validation(uri, on_behalf_of = nil) + build_request(uri, on_behalf_of).perform do |response| + return body_to_json(response.body_with_limit) if response.code == 200 + end + # If request failed, retry without doing it on behalf of a user build_request(uri).perform do |response| response.code == 200 ? body_to_json(response.body_with_limit) : nil end @@ -85,8 +89,9 @@ module JsonLdHelper private - def build_request(uri) + def build_request(uri, on_behalf_of = nil) request = Request.new(:get, uri) + request.on_behalf_of(on_behalf_of) if on_behalf_of request.add_headers('Accept' => 'application/activity+json, application/ld+json') request end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index f78e5fbc3..ba728eb32 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -6,6 +6,7 @@ module SettingsHelper ar: 'العربية', bg: 'Български', ca: 'Català', + co: 'Corsu', de: 'Deutsch', el: 'Ελληνικά', eo: 'Esperanto', @@ -32,6 +33,7 @@ module SettingsHelper 'pt-BR': 'Português do Brasil', ru: 'Русский', sk: 'Slovensky', + sl: 'Slovenščina', sr: 'Српски', 'sr-Latn': 'Srpski (latinica)', sv: 'Svenska', diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index c6f12ecd4..a91a28935 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -4,8 +4,12 @@ module StreamEntriesHelper EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' - def display_name(account) - account.display_name.presence || account.username + def display_name(account, **options) + if options[:custom_emojify] + Formatter.instance.format_display_name(account, options) + else + account.display_name.presence || account.username + end end def account_description(account) diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 8a6ca3699..b7f706a83 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -3,14 +3,9 @@ import { CancelToken } from 'axios'; import { throttle } from 'lodash'; import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light'; import { useEmoji } from './emojis'; +import resizeImage from 'flavours/glitch/util/resize_image'; -import { - updateTimeline, - refreshHomeTimeline, - refreshCommunityTimeline, - refreshPublicTimeline, - refreshDirectTimeline, -} from './timelines'; +import { updateTimeline } from './timelines'; let cancelFetchComposeSuggestionsAccounts; @@ -21,6 +16,7 @@ export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; +export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_RESET = 'COMPOSE_RESET'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; @@ -102,6 +98,19 @@ export function mentionCompose(account, router) { }; }; +export function directCompose(account, router) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_DIRECT, + account: account, + }); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } + }; +}; + export function submitCompose() { return function (dispatch, getState) { let status = getState().getIn(['compose', 'text'], ''); @@ -136,21 +145,19 @@ export function submitCompose() { // To make the app more responsive, immediately get the status into the columns - const insertOrRefresh = (timelineId, refreshAction) => { - if (getState().getIn(['timelines', timelineId, 'online'])) { + const insertIfOnline = (timelineId) => { + if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) { dispatch(updateTimeline(timelineId, { ...response.data })); - } else if (getState().getIn(['timelines', timelineId, 'loaded'])) { - dispatch(refreshAction()); } }; - insertOrRefresh('home', refreshHomeTimeline); + insertIfOnline('home'); if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { - insertOrRefresh('community', refreshCommunityTimeline); - insertOrRefresh('public', refreshPublicTimeline); + insertIfOnline('community'); + insertIfOnline('public'); } else if (response.data.visibility === 'direct') { - insertOrRefresh('direct', refreshDirectTimeline); + insertIfOnline('direct'); } }).catch(function (error) { dispatch(submitComposeFail(error)); @@ -193,18 +200,14 @@ export function uploadCompose(files) { dispatch(uploadComposeRequest()); - let data = new FormData(); - data.append('file', files[0]); + resizeImage(files[0]).then(file => { + const data = new FormData(); + data.append('file', file); - api(getState).post('/api/v1/media', data, { - onUploadProgress: function (e) { - dispatch(uploadComposeProgress(e.loaded, e.total)); - }, - }).then(function (response) { - dispatch(uploadComposeSuccess(response.data)); - }).catch(function (error) { - dispatch(uploadComposeFail(error)); - }); + return api(getState).post('/api/v1/media', data, { + onUploadProgress: ({ loaded, total }) => dispatch(uploadComposeProgress(loaded, total)), + }).then(({ data }) => dispatch(uploadComposeSuccess(data))); + }).catch(error => dispatch(uploadComposeFail(error))); }; }; diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index cf27eff90..68c46a732 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -1,8 +1,8 @@ import api, { getLinks } from 'flavours/glitch/util/api'; -import { List as ImmutableList } from 'immutable'; import IntlMessageFormat from 'intl-messageformat'; import { fetchRelationships } from './accounts'; import { defineMessages } from 'react-intl'; +import { unescapeHTML } from 'flavours/glitch/util/html'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; @@ -17,10 +17,6 @@ export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR // Mark one for delete export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE'; -export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST'; -export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS'; -export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL'; - export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; @@ -40,13 +36,6 @@ const fetchRelatedRelationships = (dispatch, notifications) => { } }; -const unescapeHTML = (html) => { - const wrapper = document.createElement('div'); - html = html.replace(/
|
|\n/g, ' '); - wrapper.innerHTML = html; - return wrapper.textContent; -}; - export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); @@ -78,84 +67,37 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); -export function refreshNotifications() { + +const noOp = () => {}; + +export function expandNotifications({ maxId } = {}, done = noOp) { return (dispatch, getState) => { - const params = {}; - const ids = getState().getIn(['notifications', 'items']); + const notifications = getState().get('notifications'); - let skipLoading = false; - - if (ids.size > 0) { - params.since_id = ids.first().get('id'); - } - - if (getState().getIn(['notifications', 'loaded'])) { - skipLoading = true; - } - - params.exclude_types = excludeTypesFromSettings(getState()); - - dispatch(refreshNotificationsRequest(skipLoading)); - - api(getState).get('/api/v1/notifications', { params }).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - - dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null)); - fetchRelatedRelationships(dispatch, response.data); - }).catch(error => { - dispatch(refreshNotificationsFail(error, skipLoading)); - }); - }; -}; - -export function refreshNotificationsRequest(skipLoading) { - return { - type: NOTIFICATIONS_REFRESH_REQUEST, - skipLoading, - }; -}; - -export function refreshNotificationsSuccess(notifications, skipLoading, next) { - return { - type: NOTIFICATIONS_REFRESH_SUCCESS, - notifications, - accounts: notifications.map(item => item.account), - statuses: notifications.map(item => item.status).filter(status => !!status), - skipLoading, - next, - }; -}; - -export function refreshNotificationsFail(error, skipLoading) { - return { - type: NOTIFICATIONS_REFRESH_FAIL, - error, - skipLoading, - }; -}; - -export function expandNotifications() { - return (dispatch, getState) => { - const items = getState().getIn(['notifications', 'items'], ImmutableList()); - - if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) { + if (notifications.get('isLoading')) { + done(); return; } const params = { - max_id: items.last().get('id'), - limit: 20, + max_id: maxId, exclude_types: excludeTypesFromSettings(getState()), }; + if (!maxId && notifications.get('items').size > 0) { + params.since_id = notifications.getIn(['items', 0]); + } + dispatch(expandNotificationsRequest()); api(getState).get('/api/v1/notifications', { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); fetchRelatedRelationships(dispatch, response.data); + done(); }).catch(error => { dispatch(expandNotificationsFail(error)); + done(); }); }; }; diff --git a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js index 5ad11f73f..91f442415 100644 --- a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js +++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js @@ -56,13 +56,6 @@ export function register () { dispatch(setBrowserSupport(supportsPushNotifications)); const me = getState().getIn(['meta', 'me']); - if (me && !pushNotificationsSetting.get(me)) { - const alerts = getState().getIn(['push_notifications', 'alerts']); - if (alerts) { - pushNotificationsSetting.set(me, { alerts: alerts }); - } - } - if (supportsPushNotifications) { if (!getApplicationServerKey()) { console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.'); diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js index e86bd848e..13885c600 100644 --- a/app/javascript/flavours/glitch/actions/search.js +++ b/app/javascript/flavours/glitch/actions/search.js @@ -1,4 +1,5 @@ import api from 'flavours/glitch/util/api'; +import { fetchRelationships } from './accounts'; export const SEARCH_CHANGE = 'SEARCH_CHANGE'; export const SEARCH_CLEAR = 'SEARCH_CLEAR'; @@ -38,6 +39,7 @@ export function submitSearch() { }, }).then(response => { dispatch(fetchSearchSuccess(response.data)); + dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); }).catch(error => { dispatch(fetchSearchFail(error)); }); diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index ae51e8349..6e34d0be6 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -2,11 +2,10 @@ import { connectStream } from 'flavours/glitch/util/stream'; import { updateTimeline, deleteFromTimelines, - refreshHomeTimeline, - connectTimeline, + expandHomeTimeline, disconnectTimeline, } from './timelines'; -import { updateNotifications, refreshNotifications } from './notifications'; +import { updateNotifications, expandNotifications } from './notifications'; import { getLocale } from 'mastodon/locales'; const { messages } = getLocale(); @@ -16,10 +15,6 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) return connectStream (path, pollingRefresh, (dispatch, getState) => { const locale = getState().getIn(['meta', 'locale']); return { - onConnect() { - dispatch(connectTimeline(timelineId)); - }, - onDisconnect() { dispatch(disconnectTimeline(timelineId)); }, @@ -41,10 +36,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) }); } -function refreshHomeTimelineAndNotification (dispatch) { - dispatch(refreshHomeTimeline()); - dispatch(refreshNotifications()); -} +const refreshHomeTimelineAndNotification = (dispatch, done) => { + dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done)))); +}; export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); export const connectCommunityStream = () => connectTimelineStream('community', 'public:local'); diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index d99c6d98b..9597fe89d 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -4,32 +4,16 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; -export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST'; -export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS'; -export const TIMELINE_REFRESH_FAIL = 'TIMELINE_REFRESH_FAIL'; - export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; -export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; -export function refreshTimelineSuccess(timeline, statuses, skipLoading, next, partial) { - return { - type: TIMELINE_REFRESH_SUCCESS, - timeline, - statuses, - skipLoading, - next, - partial, - }; -}; - export function updateTimeline(timeline, status) { return (dispatch, getState) => { const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; @@ -77,97 +61,43 @@ export function deleteFromTimelines(id) { }; }; -export function refreshTimelineRequest(timeline, skipLoading) { - return { - type: TIMELINE_REFRESH_REQUEST, - timeline, - skipLoading, - }; -}; +const noOp = () => {}; -export function refreshTimeline(timelineId, path, params = {}) { - return function (dispatch, getState) { - const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); - - if (timeline.get('isLoading') || (timeline.get('online') && !timeline.get('isPartial'))) { - return; - } - - const ids = timeline.get('items', ImmutableList()); - const newestId = ids.size > 0 ? ids.first() : null; - - let skipLoading = timeline.get('loaded'); - - if (newestId !== null) { - params.since_id = newestId; - } - - dispatch(refreshTimelineRequest(timelineId, skipLoading)); - - api(getState).get(path, { params }).then(response => { - if (response.status === 206) { - dispatch(refreshTimelineSuccess(timelineId, [], skipLoading, null, true)); - } else { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null, false)); - } - }).catch(error => { - dispatch(refreshTimelineFail(timelineId, error, skipLoading)); - }); - }; -}; - -export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); -export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); -export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); -export const refreshDirectTimeline = () => refreshTimeline('direct', '/api/v1/timelines/direct'); -export const refreshAccountTimeline = (accountId, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }); -export const refreshAccountFeaturedTimeline = accountId => refreshTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); -export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); -export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); -export const refreshListTimeline = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); - -export function refreshTimelineFail(timeline, error, skipLoading) { - return { - type: TIMELINE_REFRESH_FAIL, - timeline, - error, - skipLoading, - skipAlert: error.response && error.response.status === 404, - }; -}; - -export function expandTimeline(timelineId, path, params = {}) { +export function expandTimeline(timelineId, path, params = {}, done = noOp) { return (dispatch, getState) => { const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); - const ids = timeline.get('items', ImmutableList()); - if (timeline.get('isLoading') || ids.size === 0) { + if (timeline.get('isLoading')) { + done(); return; } - params.max_id = ids.last(); - params.limit = 10; + if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) { + params.since_id = timeline.getIn(['items', 0]); + } dispatch(expandTimelineRequest(timelineId)); api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206)); + done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error)); + done(); }); }; }; -export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home'); -export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public'); -export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); -export const expandDirectTimeline = () => expandTimeline('direct', '/api/v1/timelines/direct'); -export const expandAccountTimeline = (accountId, withReplies) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }) -export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); -export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); -export const expandListTimeline = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); +export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); +export const expandPublicTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId }, done); +export const expandCommunityTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId }, done); +export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); +export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); +export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); +export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); +export const expandHashtagTimeline = (hashtag, { maxId } = {}, done = noOp) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }, done); +export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); export function expandTimelineRequest(timeline) { return { @@ -176,12 +106,13 @@ export function expandTimelineRequest(timeline) { }; }; -export function expandTimelineSuccess(timeline, statuses, next) { +export function expandTimelineSuccess(timeline, statuses, next, partial) { return { type: TIMELINE_EXPAND_SUCCESS, timeline, statuses, next, + partial, }; }; @@ -201,13 +132,6 @@ export function scrollTopTimeline(timeline, top) { }; }; -export function connectTimeline(timeline) { - return { - type: TIMELINE_CONNECT, - timeline, - }; -}; - export function disconnectTimeline(timeline) { return { type: TIMELINE_DISCONNECT, diff --git a/app/javascript/flavours/glitch/components/load_gap.js b/app/javascript/flavours/glitch/components/load_gap.js new file mode 100644 index 000000000..012303ae1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_gap.js @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, +}); + +@injectIntl +export default class LoadGap extends React.PureComponent { + + static propTypes = { + disabled: PropTypes.bool, + maxId: PropTypes.string, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.onClick(this.props.maxId); + } + + render () { + const { disabled, intl } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/load_more.js b/app/javascript/flavours/glitch/components/load_more.js index c4c8c94a2..389c3e1e1 100644 --- a/app/javascript/flavours/glitch/components/load_more.js +++ b/app/javascript/flavours/glitch/components/load_more.js @@ -6,6 +6,7 @@ export default class LoadMore extends React.PureComponent { static propTypes = { onClick: PropTypes.func, + disabled: PropTypes.bool, visible: PropTypes.bool, } @@ -14,10 +15,10 @@ export default class LoadMore extends React.PureComponent { } render() { - const { visible } = this.props; + const { disabled, visible } = this.props; return ( - ); diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index 7f5150f7b..d90f9bdb4 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -40,6 +40,7 @@ class Item extends React.PureComponent { size: PropTypes.number.isRequired, letterbox: PropTypes.bool, onClick: PropTypes.func.isRequired, + displayWidth: PropTypes.number, }; static defaultProps = { @@ -78,7 +79,7 @@ class Item extends React.PureComponent { } render () { - const { attachment, index, size, standalone, letterbox } = this.props; + const { attachment, index, size, standalone, letterbox, displayWidth } = this.props; let width = 50; let height = 100; @@ -141,7 +142,7 @@ class Item extends React.PureComponent { const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; - const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; + const sizes = hasSize ? `${displayWidth * (width / 100)}px` : null; const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; @@ -235,7 +236,7 @@ export default class MediaGallery extends React.PureComponent { } handleRef = (node) => { - if (node && this.isStandaloneEligible()) { + if (node /*&& this.isStandaloneEligible()*/) { // offsetWidth triggers a layout, so only calculate when we need to this.setState({ width: node.offsetWidth, @@ -272,9 +273,9 @@ export default class MediaGallery extends React.PureComponent { ); } else { if (this.isStandaloneEligible()) { - children = ; + children = ; } else { - children = media.take(4).map((attachment, i) => ); + children = media.take(4).map((attachment, i) => ); } } diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js index 789e117c7..89f81f58e 100644 --- a/app/javascript/flavours/glitch/components/modal_root.js +++ b/app/javascript/flavours/glitch/components/modal_root.js @@ -6,6 +6,7 @@ export default class ModalRoot extends React.PureComponent { static propTypes = { children: PropTypes.node, onClose: PropTypes.func.isRequired, + noEsc: PropTypes.bool, }; state = { @@ -16,7 +17,7 @@ export default class ModalRoot extends React.PureComponent { handleKeyUp = (e) => { if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) - && !!this.props.children && !this.props.props.noEsc) { + && !!this.props.children && !this.props.noEsc) { this.props.onClose(); } } diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js index df3ace4c1..b96b4dd98 100644 --- a/app/javascript/flavours/glitch/components/scrollable_list.js +++ b/app/javascript/flavours/glitch/components/scrollable_list.js @@ -17,7 +17,7 @@ export default class ScrollableList extends PureComponent { static propTypes = { scrollKey: PropTypes.string.isRequired, - onScrollToBottom: PropTypes.func, + onLoadMore: PropTypes.func, onScrollToTop: PropTypes.func, onScroll: PropTypes.func, trackScroll: PropTypes.bool, @@ -44,9 +44,11 @@ export default class ScrollableList extends PureComponent { const { scrollTop, scrollHeight, clientHeight } = this.node; const offset = scrollHeight - scrollTop - clientHeight; - if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { - this.props.onScrollToBottom(); - } else if (scrollTop < 100 && this.props.onScrollToTop) { + if (400 > offset && this.props.onLoadMore && !this.props.isLoading) { + this.props.onLoadMore(); + } + + if (scrollTop < 100 && this.props.onScrollToTop) { this.props.onScrollToTop(); } else if (this.props.onScroll) { this.props.onScroll(); @@ -144,15 +146,15 @@ export default class ScrollableList extends PureComponent { handleLoadMore = (e) => { e.preventDefault(); - this.props.onScrollToBottom(); + this.props.onLoadMore(); } render () { - const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; + const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = React.Children.count(children); - const loadMore = (hasMore && childrenCount > 0) ? : null; + const loadMore = (hasMore && childrenCount > 0 && onLoadMore) ? : null; let scrollableArea = null; if (isLoading || childrenCount > 0 || !emptyMessage) { diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index bff396f04..c93705266 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -32,6 +32,8 @@ export default class Status extends ImmutablePureComponent { onFavourite: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, + onDirect: PropTypes.func, + onMention: PropTypes.func, onPin: PropTypes.func, onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, @@ -257,8 +259,8 @@ export default class Status extends ImmutablePureComponent { } }; - handleOpenVideo = startTime => { - this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + handleOpenVideo = (media, startTime) => { + this.props.onOpenVideo(media, startTime); } handleHotkeyReply = e => { diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index da6e4e6ba..6ae4bc08d 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -10,6 +10,7 @@ import RelativeTimestamp from './relative_timestamp'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' }, @@ -44,6 +45,7 @@ export default class StatusActionBar extends ImmutablePureComponent { onFavourite: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, + onDirect: PropTypes.func, onMention: PropTypes.func, onMute: PropTypes.func, onBlock: PropTypes.func, @@ -98,6 +100,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.props.onMention(this.props.status.get('account'), this.context.router.history); } + handleDirectClick = () => { + this.props.onDirect(this.props.status.get('account'), this.context.router.history); + } + handleMuteClick = () => { this.props.onMute(this.props.status.get('account')); } @@ -157,6 +163,7 @@ export default class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); menu.push(null); menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index 32b0770cb..6542df65b 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -98,7 +98,7 @@ export default class StatusContent extends React.PureComponent { const [ startX, startY ] = this.startXY; const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; - if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { + if (e.target.localName === 'button' || e.target.localName == 'video' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { return; } @@ -188,11 +188,9 @@ export default class StatusContent extends React.PureComponent { } return ( -
+
diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js index 2b35d6f3d..33bc7a959 100644 --- a/app/javascript/flavours/glitch/components/status_list.js +++ b/app/javascript/flavours/glitch/components/status_list.js @@ -1,8 +1,10 @@ +import { debounce } from 'lodash'; import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import StatusContainer from 'flavours/glitch/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'; @@ -12,7 +14,7 @@ export default class StatusList extends ImmutablePureComponent { scrollKey: PropTypes.string.isRequired, statusIds: ImmutablePropTypes.list.isRequired, featuredStatusIds: ImmutablePropTypes.list, - onScrollToBottom: PropTypes.func, + onLoadMore: PropTypes.func, onScrollToTop: PropTypes.func, onScroll: PropTypes.func, trackScroll: PropTypes.bool, @@ -50,6 +52,10 @@ export default class StatusList extends ImmutablePureComponent { this._selectChild(elementIndex); } + handleLoadOlder = debounce(() => { + this.props.onLoadMore(this.props.statusIds.last()); + }, 300, { leading: true }) + _selectChild (index) { const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); @@ -63,7 +69,7 @@ export default class StatusList extends ImmutablePureComponent { } render () { - const { statusIds, featuredStatusIds, ...other } = this.props; + const { statusIds, featuredStatusIds, onLoadMore, ...other } = this.props; const { isLoading, isPartial } = other; if (isPartial) { @@ -82,7 +88,14 @@ export default class StatusList extends ImmutablePureComponent { } let scrollableContent = (isLoading || statusIds.size > 0) ? ( - statusIds.map(statusId => ( + statusIds.map((statusId, index) => statusId === null ? ( + 0 ? statusIds.get(index - 1) : null} + onClick={onLoadMore} + /> + ) : ( + {scrollableContent} ); diff --git a/app/javascript/flavours/glitch/containers/card_container.js b/app/javascript/flavours/glitch/containers/card_container.js deleted file mode 100644 index dec7df522..000000000 --- a/app/javascript/flavours/glitch/containers/card_container.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Card from 'flavours/glitch/features/status/components/card'; -import { fromJS } from 'immutable'; - -export default class CardContainer extends React.PureComponent { - - static propTypes = { - locale: PropTypes.string, - card: PropTypes.array.isRequired, - }; - - render () { - const { card, ...props } = this.props; - return ; - } - -} diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js new file mode 100644 index 000000000..0e1904132 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/media_container.js @@ -0,0 +1,90 @@ +import React, { PureComponent, Fragment } from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from 'mastodon/locales'; +import MediaGallery from 'flavours/glitch/components/media_gallery'; +import Video from 'flavours/glitch/features/video'; +import Card from 'flavours/glitch/features/status/components/card'; +import ModalRoot from 'flavours/glitch/components/modal_root'; +import MediaModal from 'flavours/glitch/features/ui/components/media_modal'; +import { List as ImmutableList, fromJS } from 'immutable'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +const MEDIA_COMPONENTS = { MediaGallery, Video, Card }; + +export default class MediaContainer extends PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + components: PropTypes.object.isRequired, + }; + + state = { + media: null, + index: null, + time: null, + }; + + handleOpenMedia = (media, index) => { + document.body.classList.add('media-standalone__body'); + this.setState({ media, index }); + } + + handleOpenVideo = (video, time) => { + const media = ImmutableList([video]); + + document.body.classList.add('media-standalone__body'); + this.setState({ media, time }); + } + + handleCloseMedia = () => { + document.body.classList.remove('media-standalone__body'); + this.setState({ media: null, index: null, time: null }); + } + + render () { + const { locale, components } = this.props; + + return ( + + + {[].map.call(components, (component, i) => { + const componentName = component.getAttribute('data-component'); + const Component = MEDIA_COMPONENTS[componentName]; + const { media, card, ...props } = JSON.parse(component.getAttribute('data-props')); + + Object.assign(props, { + ...(media ? { media: fromJS(media) } : {}), + ...(card ? { card: fromJS(card) } : {}), + + ...(componentName === 'Video' ? { + onOpenVideo: this.handleOpenVideo, + } : { + onOpenMedia: this.handleOpenMedia, + }), + }); + + return ReactDOM.createPortal( + , + component, + ); + })} + + {this.state.media && ( + + )} + + + + ); + } + +} diff --git a/app/javascript/flavours/glitch/containers/media_galleries_container.js b/app/javascript/flavours/glitch/containers/media_galleries_container.js deleted file mode 100644 index a69457882..000000000 --- a/app/javascript/flavours/glitch/containers/media_galleries_container.js +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import { IntlProvider, addLocaleData } from 'react-intl'; -import { getLocale } from 'mastodon/locales'; -import MediaGallery from 'flavours/glitch/components/media_gallery'; -import ModalRoot from 'flavours/glitch/components/modal_root'; -import MediaModal from 'flavours/glitch/features/ui/components/media_modal'; -import { fromJS } from 'immutable'; - -const { localeData, messages } = getLocale(); -addLocaleData(localeData); - -export default class MediaGalleriesContainer extends React.PureComponent { - - static propTypes = { - locale: PropTypes.string.isRequired, - galleries: PropTypes.object.isRequired, - }; - - state = { - media: null, - index: null, - }; - - handleOpenMedia = (media, index) => { - document.body.classList.add('media-gallery-standalone__body'); - this.setState({ media, index }); - } - - handleCloseMedia = () => { - document.body.classList.remove('media-gallery-standalone__body'); - this.setState({ media: null, index: null }); - } - - render () { - const { locale, galleries } = this.props; - - return ( - - - {[].map.call(galleries, gallery => { - const { media, ...props } = JSON.parse(gallery.getAttribute('data-props')); - - return ReactDOM.createPortal( - , - gallery - ); - })} - - {this.state.media === null || this.state.index === null ? null : ( - - )} - - - - ); - } - -} diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 3fc6a6a79..acec00e12 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -5,6 +5,7 @@ import { makeGetStatus } from 'flavours/glitch/selectors'; import { replyCompose, mentionCompose, + directCompose, } from 'flavours/glitch/actions/compose'; import { reblog, @@ -131,6 +132,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + onMention (account, router) { dispatch(mentionCompose(account, router)); }, diff --git a/app/javascript/flavours/glitch/containers/timeline_container.js b/app/javascript/flavours/glitch/containers/timeline_container.js index c5ffe1b63..56669a49a 100644 --- a/app/javascript/flavours/glitch/containers/timeline_container.js +++ b/app/javascript/flavours/glitch/containers/timeline_container.js @@ -6,6 +6,7 @@ import { hydrateStore } from 'flavours/glitch/actions/store'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from 'mastodon/locales'; import PublicTimeline from 'flavours/glitch/features/standalone/public_timeline'; +import CommunityTimeline from 'flavours/glitch/features/standalone/community_timeline'; import HashtagTimeline from 'flavours/glitch/features/standalone/hashtag_timeline'; import initialState from 'flavours/glitch/util/initial_state'; @@ -23,17 +24,24 @@ export default class TimelineContainer extends React.PureComponent { static propTypes = { locale: PropTypes.string.isRequired, hashtag: PropTypes.string, + showPublicTimeline: PropTypes.bool.isRequired, + }; + + static defaultProps = { + showPublicTimeline: initialState.settings.known_fediverse, }; render () { - const { locale, hashtag } = this.props; + const { locale, hashtag, showPublicTimeline } = this.props; let timeline; if (hashtag) { timeline = ; - } else { + } else if (showPublicTimeline) { timeline = ; + } else { + timeline = ; } return ( diff --git a/app/javascript/flavours/glitch/containers/video_container.js b/app/javascript/flavours/glitch/containers/video_container.js deleted file mode 100644 index b206e9a10..000000000 --- a/app/javascript/flavours/glitch/containers/video_container.js +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { IntlProvider, addLocaleData } from 'react-intl'; -import { getLocale } from 'mastodon/locales'; -import Video from 'flavours/glitch/features/video'; - -const { localeData, messages } = getLocale(); -addLocaleData(localeData); - -export default class VideoContainer extends React.PureComponent { - - static propTypes = { - locale: PropTypes.string.isRequired, - }; - - render () { - const { locale, ...props } = this.props; - - return ( - - - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js index fb90722f3..8b95c08f2 100644 --- a/app/javascript/flavours/glitch/features/account/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js @@ -8,6 +8,7 @@ import { me } from 'flavours/glitch/util/initial_state'; const messages = defineMessages({ mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, + direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, @@ -32,6 +33,7 @@ export default class ActionBar extends React.PureComponent { onFollow: PropTypes.func, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, + onDirect: PropTypes.func.isRequired, onReblogToggle: PropTypes.func.isRequired, onReport: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, @@ -53,6 +55,7 @@ export default class ActionBar extends React.PureComponent { let extraInfo = ''; menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); + menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect }); if ('share' in navigator) { menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js index 7a0a2dfa9..464c73c9a 100644 --- a/app/javascript/flavours/glitch/features/account/components/header.js +++ b/app/javascript/flavours/glitch/features/account/components/header.js @@ -38,6 +38,8 @@ export default class Header extends ImmutablePureComponent { let displayName = account.get('display_name_html'); let fields = account.get('fields'); + let badge = account.get('bot') ? (
) : null; + let info = ''; let mutingInfo = ''; let actionBtn = ''; @@ -99,38 +101,31 @@ export default class Header extends ImmutablePureComponent { @{account.get('acct')} {account.get('locked') ? : null} + + {badge} +
{fields.size > 0 && ( - - - {fields.map((pair, i) => ( - - - ))} - -
- -
+
+ {fields.map((pair, i) => ( +
+
+
+
+ ))} +
)} {fields.size == 0 && metadata.length && ( - - - {(() => { - let data = []; - for (let i = 0; i < metadata.length; i++) { - data.push( - - - - - ); - } - return data; - })()} - -
+
+ {metadata.map((pair, i) => ( +
+
+
+
+ ))} +
) || null} {info} diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js index 63ff98deb..ebd23a971 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/index.js +++ b/app/javascript/flavours/glitch/features/account_gallery/index.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { fetchAccount } from 'flavours/glitch/actions/accounts'; -import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines'; +import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import Column from 'flavours/glitch/features/ui/components/column'; import ColumnBackButton from 'flavours/glitch/components/column_back_button'; @@ -17,9 +17,31 @@ import LoadMore from 'flavours/glitch/components/load_more'; const mapStateToProps = (state, props) => ({ medias: getAccountGallery(state, props.params.accountId), isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), - hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']), + hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), }); +class LoadMoreMedia extends ImmutablePureComponent { + + static propTypes = { + maxId: PropTypes.string, + onLoadMore: PropTypes.func.isRequired, + }; + + handleLoadMore = () => { + this.props.onLoadMore(this.props.maxId); + } + + render () { + return ( + + ); + } + +} + @connect(mapStateToProps) export default class AccountGallery extends ImmutablePureComponent { @@ -33,19 +55,19 @@ export default class AccountGallery extends ImmutablePureComponent { componentDidMount () { this.props.dispatch(fetchAccount(this.props.params.accountId)); - this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); + this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); } componentWillReceiveProps (nextProps) { if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { this.props.dispatch(fetchAccount(nextProps.params.accountId)); - this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); + this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); } } handleScrollToBottom = () => { if (this.props.hasMore) { - this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); + this.handleLoadMore(this.props.medias.last().getIn(['status', 'id'])); } } @@ -58,7 +80,11 @@ export default class AccountGallery extends ImmutablePureComponent { } } - handleLoadMore = (e) => { + handleLoadMore = maxId => { + this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId })); + }; + + handleLoadOlder = (e) => { e.preventDefault(); this.handleScrollToBottom(); } @@ -66,7 +92,7 @@ export default class AccountGallery extends ImmutablePureComponent { render () { const { medias, isLoading, hasMore } = this.props; - let loadMore = null; + let loadOlder = null; if (!medias && isLoading) { return ( @@ -77,7 +103,7 @@ export default class AccountGallery extends ImmutablePureComponent { } if (!isLoading && medias.size > 0 && hasMore) { - loadMore = ; + loadOlder = ; } return ( @@ -89,13 +115,18 @@ export default class AccountGallery extends ImmutablePureComponent {
- {medias.map(media => - ( media === null ? ( + 0 ? medias.getIn(index - 1, 'id') : null} + /> + ) : ( + ) - )} - {loadMore} + /> + ))} + {loadOlder}
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js index 39a1850d7..a1434b8dd 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js +++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js @@ -16,6 +16,7 @@ export default class Header extends ImmutablePureComponent { onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, + onDirect: PropTypes.func.isRequired, onReblogToggle: PropTypes.func.isRequired, onReport: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, @@ -40,6 +41,10 @@ export default class Header extends ImmutablePureComponent { this.props.onMention(this.props.account, this.context.router.history); } + handleDirect = () => { + this.props.onDirect(this.props.account, this.context.router.history); + } + handleReport = () => { this.props.onReport(this.props.account); } @@ -89,6 +94,7 @@ export default class Header extends ImmutablePureComponent { account={account} onBlock={this.handleBlock} onMention={this.handleMention} + onDirect={this.handleDirect} onReblogToggle={this.handleReblogToggle} onReport={this.handleReport} onMute={this.handleMute} diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js index 848119c63..fb0edfa88 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js +++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js @@ -9,7 +9,10 @@ import { unblockAccount, unmuteAccount, } from 'flavours/glitch/actions/accounts'; -import { mentionCompose } from 'flavours/glitch/actions/compose'; +import { + mentionCompose, + directCompose +} from 'flavours/glitch/actions/compose'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initReport } from 'flavours/glitch/actions/reports'; import { openModal } from 'flavours/glitch/actions/modal'; @@ -67,6 +70,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(mentionCompose(account, router)); }, + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + onReblogToggle (account) { if (account.getIn(['relationship', 'showing_reblogs'])) { dispatch(followAccount(account.get('id'), false)); diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js index fbb16dff9..2216f9153 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.js +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { fetchAccount } from 'flavours/glitch/actions/accounts'; -import { refreshAccountTimeline, refreshAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines'; +import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines'; import StatusList from '../../components/status_list'; import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; @@ -19,7 +19,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), - hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']), + hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), }; }; @@ -40,22 +40,24 @@ export default class AccountTimeline extends ImmutablePureComponent { const { params: { accountId }, withReplies } = this.props; this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(refreshAccountFeaturedTimeline(accountId)); - this.props.dispatch(refreshAccountTimeline(accountId, withReplies)); + if (!withReplies) { + this.props.dispatch(expandAccountFeaturedTimeline(accountId)); + } + this.props.dispatch(expandAccountTimeline(accountId, { withReplies })); } componentWillReceiveProps (nextProps) { if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { this.props.dispatch(fetchAccount(nextProps.params.accountId)); - this.props.dispatch(refreshAccountFeaturedTimeline(nextProps.params.accountId)); - this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies)); + if (!nextProps.withReplies) { + this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId)); + } + this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies })); } } - handleScrollToBottom = () => { - if (!this.props.isLoading && this.props.hasMore) { - this.props.dispatch(expandAccountTimeline(this.props.params.accountId, this.props.withReplies)); - } + handleLoadMore = maxId => { + this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies })); } render () { @@ -80,7 +82,7 @@ export default class AccountTimeline extends ImmutablePureComponent { featuredStatusIds={featuredStatusIds} isLoading={isLoading} hasMore={hasMore} - onScrollToBottom={this.handleScrollToBottom} + onLoadMore={this.handleLoadMore} /> ); diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js index dae5caf1d..9468ad81d 100644 --- a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js +++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js @@ -62,7 +62,7 @@ export default class Bookmarks extends ImmutablePureComponent { this.column = c; } - handleScrollToBottom = debounce(() => { + handleLoadMore = debounce(() => { this.props.dispatch(expandBookmarkedStatuses()); }, 300, { leading: true }) @@ -89,7 +89,7 @@ export default class Bookmarks extends ImmutablePureComponent { scrollKey={`bookmarked_statuses-${columnId}`} hasMore={hasMore} isLoading={isLoading} - onScrollToBottom={this.handleScrollToBottom} + onLoadMore={this.handleLoadMore} /> ); diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js index 55355414f..b5843ca16 100644 --- a/app/javascript/flavours/glitch/features/community_timeline/index.js +++ b/app/javascript/flavours/glitch/features/community_timeline/index.js @@ -4,10 +4,7 @@ import PropTypes from 'prop-types'; import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; import Column from 'flavours/glitch/components/column'; import ColumnHeader from 'flavours/glitch/components/column_header'; -import { - refreshCommunityTimeline, - expandCommunityTimeline, -} from 'flavours/glitch/actions/timelines'; +import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; @@ -55,7 +52,7 @@ export default class CommunityTimeline extends React.PureComponent { componentDidMount () { const { dispatch } = this.props; - dispatch(refreshCommunityTimeline()); + dispatch(expandCommunityTimeline()); this.disconnect = dispatch(connectCommunityStream()); } @@ -70,8 +67,8 @@ export default class CommunityTimeline extends React.PureComponent { this.column = c; } - handleLoadMore = () => { - this.props.dispatch(expandCommunityTimeline()); + handleLoadMore = maxId => { + this.props.dispatch(expandCommunityTimeline({ maxId })); } render () { @@ -97,7 +94,7 @@ export default class CommunityTimeline extends React.PureComponent { trackScroll={!pinned} scrollKey={`community_timeline-${columnId}`} timelineId='community' - loadMore={this.handleLoadMore} + onLoadMore={this.handleLoadMore} emptyMessage={} /> diff --git a/app/javascript/flavours/glitch/features/composer/direct_warning/index.js b/app/javascript/flavours/glitch/features/composer/direct_warning/index.js new file mode 100644 index 000000000..d1febdd1b --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/direct_warning/index.js @@ -0,0 +1,53 @@ +import React from 'react'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { defineMessages, FormattedMessage } from 'react-intl'; + +// This is the spring used with our motion. +const motionSpring = spring(1, { damping: 35, stiffness: 400 }); + +// Messages. +const messages = defineMessages({ + disclaimer: { + defaultMessage: 'This toot will only be sent to all the mentioned users.', + id: 'compose_form.direct_message_warning', + }, + learn_more: { + defaultMessage: 'Learn more', + id: 'compose_form.direct_message_warning_learn_more' + } +}); + +// The component. +export default function ComposerDirectWarning () { + return ( + + {({ opacity, scaleX, scaleY }) => ( +
+ + + +
+ )} +
+ ); +} + +ComposerDirectWarning.propTypes = {}; diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js index 3aa283628..21b03be39 100644 --- a/app/javascript/flavours/glitch/features/composer/index.js +++ b/app/javascript/flavours/glitch/features/composer/index.js @@ -39,6 +39,7 @@ import ComposerTextarea from './textarea'; import ComposerUploadForm from './upload_form'; import ComposerWarning from './warning'; import ComposerHashtagWarning from './hashtag_warning'; +import ComposerDirectWarning from './direct_warning'; // Utils. import { countableText } from 'flavours/glitch/util/counter'; @@ -55,6 +56,7 @@ function mapStateToProps (state) { advancedOptions: state.getIn(['compose', 'advanced_options']), amUnlocked: !state.getIn(['accounts', me, 'locked']), focusDate: state.getIn(['compose', 'focusDate']), + caretPosition: state.getIn(['compose', 'caretPosition']), isSubmitting: state.getIn(['compose', 'is_submitting']), isUploading: state.getIn(['compose', 'is_uploading']), layout: state.getIn(['local_settings', 'layout']), @@ -116,7 +118,6 @@ const handlers = { handleEmoji (data) { const { textarea: { selectionStart } } = this; const { onInsertEmoji } = this.props; - this.caretPos = selectionStart + data.native.length + 1; if (onInsertEmoji) { onInsertEmoji(selectionStart, data); } @@ -138,7 +139,6 @@ const handlers = { // Selects a suggestion from the autofill. handleSelect (tokenStart, token, value) { const { onSelectSuggestion } = this.props; - this.caretPos = null; if (onSelectSuggestion) { onSelectSuggestion(tokenStart, token, value); } @@ -190,20 +190,9 @@ class Composer extends React.Component { assignHandlers(this, handlers); // Instance variables. - this.caretPos = null; this.textarea = null; } - // If this is the update where we've finished uploading, - // save the last caret position so we can restore it below! - componentWillReceiveProps (nextProps) { - const { textarea } = this; - const { isUploading } = this.props; - if (textarea && isUploading && !nextProps.isUploading) { - this.caretPos = textarea.selectionStart; - } - } - // Tells our state the composer has been mounted. componentDidMount () { const { onMount } = this.props; @@ -227,17 +216,13 @@ class Composer extends React.Component { // - Replying to more than one user, selects any usernames past // the first; this provides a convenient shortcut to drop // everyone else from the conversation. - // - If we've just finished uploading an image, and have a saved - // caret position, restores the cursor to that position after the - // text changes. componentDidUpdate (prevProps) { const { - caretPos, textarea, } = this; const { focusDate, - isUploading, + caretPosition, isSubmitting, preselectDate, text, @@ -245,14 +230,14 @@ class Composer extends React.Component { let selectionEnd, selectionStart; // Caret/selection handling. - if (focusDate !== prevProps.focusDate || (prevProps.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) { + if (focusDate !== prevProps.focusDate) { switch (true) { case preselectDate !== prevProps.preselectDate: selectionStart = text.search(/\s/) + 1; selectionEnd = text.length; break; - case !isNaN(caretPos) && caretPos !== null: - selectionStart = selectionEnd = caretPos; + case !isNaN(caretPosition) && caretPosition !== null: + selectionStart = selectionEnd = caretPosition; break; default: selectionStart = selectionEnd = text.length; @@ -326,6 +311,7 @@ class Composer extends React.Component { onSubmit={handleSubmit} text={spoilerText} /> + {privacy === 'direct' ? : null} {privacy === 'private' && amUnlocked ? : null} {privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? : null} {replyContent ? ( @@ -408,6 +394,7 @@ Composer.propTypes = { advancedOptions: ImmutablePropTypes.map, amUnlocked: PropTypes.bool, focusDate: PropTypes.instanceOf(Date), + caretPosition: PropTypes.number, isSubmitting: PropTypes.bool, isUploading: PropTypes.bool, layout: PropTypes.string, diff --git a/app/javascript/flavours/glitch/features/composer/reply/index.js b/app/javascript/flavours/glitch/features/composer/reply/index.js index 0b8ceddee..0500a75d0 100644 --- a/app/javascript/flavours/glitch/features/composer/reply/index.js +++ b/app/javascript/flavours/glitch/features/composer/reply/index.js @@ -58,6 +58,7 @@ export default class ComposerReply extends React.PureComponent { icon='times' onClick={handleClick} title={intl.formatMessage(messages.cancel)} + inverted /> {account ? ( { - this.props.dispatch(expandDirectTimeline()); + handleLoadMore = maxId => { + this.props.dispatch(expandDirectTimeline({ maxId })); } render () { @@ -97,7 +94,7 @@ export default class DirectTimeline extends React.PureComponent { trackScroll={!pinned} scrollKey={`direct_timeline-${columnId}`} timelineId='direct' - loadMore={this.handleLoadMore} + onLoadMore={this.handleLoadMore} emptyMessage={} /> diff --git a/app/javascript/flavours/glitch/features/domain_blocks/index.js b/app/javascript/flavours/glitch/features/domain_blocks/index.js index b17c47e91..3b29e2a26 100644 --- a/app/javascript/flavours/glitch/features/domain_blocks/index.js +++ b/app/javascript/flavours/glitch/features/domain_blocks/index.js @@ -52,7 +52,7 @@ export default class Blocks extends ImmutablePureComponent { } return ( - + {domains.map(domain => diff --git a/app/javascript/flavours/glitch/features/drawer/results/index.js b/app/javascript/flavours/glitch/features/drawer/results/index.js index f2a79eb59..23dc0e3cf 100644 --- a/app/javascript/flavours/glitch/features/drawer/results/index.js +++ b/app/javascript/flavours/glitch/features/drawer/results/index.js @@ -68,6 +68,8 @@ export default function DrawerResults ({ {accounts && accounts.size ? (
+
+ {accounts.map( accountId => ( +
+ {statuses.map( statusId => ( +
+ {hashtags.map( hashtag => ( { + handleLoadMore = debounce(() => { this.props.dispatch(expandFavouritedStatuses()); }, 300, { leading: true }) @@ -89,7 +89,7 @@ export default class Favourites extends ImmutablePureComponent { scrollKey={`favourited_statuses-${columnId}`} hasMore={hasMore} isLoading={isLoading} - onScrollToBottom={this.handleScrollToBottom} + onLoadMore={this.handleLoadMore} /> ); diff --git a/app/javascript/flavours/glitch/features/getting_started_misc/index.js b/app/javascript/flavours/glitch/features/getting_started_misc/index.js index 77c44c273..b67e6f97f 100644 --- a/app/javascript/flavours/glitch/features/getting_started_misc/index.js +++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.js @@ -50,7 +50,7 @@ export default class gettingStartedMisc extends ImmutablePureComponent { - + diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js index 9f3c9bec7..8f77ed42b 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js @@ -4,10 +4,7 @@ import PropTypes from 'prop-types'; import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; import Column from 'flavours/glitch/components/column'; import ColumnHeader from 'flavours/glitch/components/column_header'; -import { - refreshHashtagTimeline, - expandHashtagTimeline, -} from 'flavours/glitch/actions/timelines'; +import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import { FormattedMessage } from 'react-intl'; import { connectHashtagStream } from 'flavours/glitch/actions/streaming'; @@ -61,13 +58,13 @@ export default class HashtagTimeline extends React.PureComponent { const { dispatch } = this.props; const { id } = this.props.params; - dispatch(refreshHashtagTimeline(id)); + dispatch(expandHashtagTimeline(id)); this._subscribe(dispatch, id); } componentWillReceiveProps (nextProps) { if (nextProps.params.id !== this.props.params.id) { - this.props.dispatch(refreshHashtagTimeline(nextProps.params.id)); + this.props.dispatch(expandHashtagTimeline(nextProps.params.id)); this._unsubscribe(); this._subscribe(this.props.dispatch, nextProps.params.id); } @@ -81,8 +78,8 @@ export default class HashtagTimeline extends React.PureComponent { this.column = c; } - handleLoadMore = () => { - this.props.dispatch(expandHashtagTimeline(this.props.params.id)); + handleLoadMore = maxId => { + this.props.dispatch(expandHashtagTimeline(this.props.params.id, { maxId })); } render () { @@ -108,7 +105,7 @@ export default class HashtagTimeline extends React.PureComponent { trackScroll={!pinned} scrollKey={`hashtag_timeline-${columnId}`} timelineId={`hashtag:${id}`} - loadMore={this.handleLoadMore} + onLoadMore={this.handleLoadMore} emptyMessage={} /> diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js index c20c0244a..3650ffc6d 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/index.js +++ b/app/javascript/flavours/glitch/features/home_timeline/index.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { expandHomeTimeline, refreshHomeTimeline } from 'flavours/glitch/actions/timelines'; +import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; import PropTypes from 'prop-types'; import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; import Column from 'flavours/glitch/components/column'; @@ -16,7 +16,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, - isPartial: state.getIn(['timelines', 'home', 'isPartial'], false), + isPartial: state.getIn(['timelines', 'home', 'items', 0], null) === null, }); @connect(mapStateToProps) @@ -55,8 +55,8 @@ export default class HomeTimeline extends React.PureComponent { this.column = c; } - handleLoadMore = () => { - this.props.dispatch(expandHomeTimeline()); + handleLoadMore = maxId => { + this.props.dispatch(expandHomeTimeline({ maxId })); } componentDidMount () { @@ -78,7 +78,7 @@ export default class HomeTimeline extends React.PureComponent { return; } else if (!wasPartial && isPartial) { this.polling = setInterval(() => { - dispatch(refreshHomeTimeline()); + dispatch(expandHomeTimeline()); }, 3000); } else if (wasPartial && !isPartial) { this._stopPolling(); @@ -114,7 +114,7 @@ export default class HomeTimeline extends React.PureComponent { }} />} /> diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js index f9476d92d..07edf45aa 100644 --- a/app/javascript/flavours/glitch/features/list_timeline/index.js +++ b/app/javascript/flavours/glitch/features/list_timeline/index.js @@ -8,7 +8,7 @@ import ColumnHeader from 'flavours/glitch/components/column_header'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { connectListStream } from 'flavours/glitch/actions/streaming'; -import { refreshListTimeline, expandListTimeline } from 'flavours/glitch/actions/timelines'; +import { expandListTimeline } from 'flavours/glitch/actions/timelines'; import { fetchList, deleteList } from 'flavours/glitch/actions/lists'; import { openModal } from 'flavours/glitch/actions/modal'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; @@ -67,7 +67,7 @@ export default class ListTimeline extends React.PureComponent { const { id } = this.props.params; dispatch(fetchList(id)); - dispatch(refreshListTimeline(id)); + dispatch(expandListTimeline(id)); this.disconnect = dispatch(connectListStream(id)); } @@ -83,9 +83,9 @@ export default class ListTimeline extends React.PureComponent { this.column = c; } - handleLoadMore = () => { + handleLoadMore = maxId => { const { id } = this.props.params; - this.props.dispatch(expandListTimeline(id)); + this.props.dispatch(expandListTimeline(id, { maxId })); } handleEditClick = () => { @@ -164,7 +164,7 @@ export default class ListTimeline extends React.PureComponent { trackScroll={!pinned} scrollKey={`list_timeline-${columnId}`} timelineId={`list:${id}`} - loadMore={this.handleLoadMore} + onLoadMore={this.handleLoadMore} emptyMessage={} /> diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index 12b0b5b83..266d6807d 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -17,6 +17,7 @@ import { createSelector } from 'reselect'; import { List as ImmutableList } from 'immutable'; import { debounce } from 'lodash'; import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import LoadGap from 'flavours/glitch/components/load_gap'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, @@ -25,14 +26,14 @@ const messages = defineMessages({ const getNotifications = createSelector([ state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), state => state.getIn(['notifications', 'items']), -], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type')))); +], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')))); const mapStateToProps = state => ({ notifications: getNotifications(state), localSettings: state.get('local_settings'), isLoading: state.getIn(['notifications', 'isLoading'], true), isUnread: state.getIn(['notifications', 'unread']) > 0, - hasMore: !!state.getIn(['notifications', 'next']), + hasMore: state.getIn(['notifications', 'hasMore']), notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), }); @@ -67,9 +68,13 @@ export default class Notifications extends React.PureComponent { trackScroll: true, }; - handleScrollToBottom = debounce(() => { - this.props.dispatch(scrollTopNotifications(false)); - this.props.dispatch(expandNotifications()); + handleLoadGap = (maxId) => { + this.props.dispatch(expandNotifications({ maxId })); + }; + + handleLoadOlder = debounce(() => { + const last = this.props.notifications.last(); + this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); }, 300, { leading: true }); handleScrollToTop = debounce(() => { @@ -104,12 +109,12 @@ export default class Notifications extends React.PureComponent { } handleMoveUp = id => { - const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1; + const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1; this._selectChild(elementIndex); } handleMoveDown = id => { - const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1; + const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1; this._selectChild(elementIndex); } @@ -131,7 +136,14 @@ export default class Notifications extends React.PureComponent { if (isLoading && this.scrollableContent) { scrollableContent = this.scrollableContent; } else if (notifications.size > 0 || hasMore) { - scrollableContent = notifications.map((item) => ( + scrollableContent = notifications.map((item, index) => item === null ? ( + 0 ? notifications.getIn([index - 1, 'id']) : null} + onClick={this.handleLoadGap} + /> + ) : ( { - this.props.dispatch(expandPublicTimeline()); + handleLoadMore = maxId => { + this.props.dispatch(expandPublicTimeline({ maxId })); } render () { @@ -95,7 +92,7 @@ export default class PublicTimeline extends React.PureComponent { } diff --git a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js new file mode 100644 index 000000000..c488f9541 --- /dev/null +++ b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { defineMessages, injectIntl } from 'react-intl'; +import { connectCommunityStream } from 'flavours/glitch/actions/streaming'; + +const messages = defineMessages({ + title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' }, +}); + +@connect() +@injectIntl +export default class CommunityTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(expandCommunityTimeline()); + this.disconnect = dispatch(connectCommunityStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + handleLoadMore = maxId => { + this.props.dispatch(expandCommunityTimeline({ maxId })); + } + + render () { + const { intl } = this.props; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js index 0ad2cef80..dc02f1c91 100644 --- a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js +++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js @@ -2,12 +2,10 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; -import { - refreshHashtagTimeline, - expandHashtagTimeline, -} from 'flavours/glitch/actions/timelines'; +import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines'; import Column from 'flavours/glitch/components/column'; import ColumnHeader from 'flavours/glitch/components/column_header'; +import { connectHashtagStream } from 'flavours/glitch/actions/streaming'; @connect() export default class HashtagTimeline extends React.PureComponent { @@ -28,22 +26,19 @@ export default class HashtagTimeline extends React.PureComponent { componentDidMount () { const { dispatch, hashtag } = this.props; - dispatch(refreshHashtagTimeline(hashtag)); - - this.polling = setInterval(() => { - dispatch(refreshHashtagTimeline(hashtag)); - }, 10000); + dispatch(expandHashtagTimeline(hashtag)); + this.disconnect = dispatch(connectHashtagStream(hashtag)); } componentWillUnmount () { - if (typeof this.polling !== 'undefined') { - clearInterval(this.polling); - this.polling = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } - handleLoadMore = () => { - this.props.dispatch(expandHashtagTimeline(this.props.hashtag)); + handleLoadMore = maxId => { + this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId })); } render () { @@ -61,7 +56,7 @@ export default class HashtagTimeline extends React.PureComponent { trackScroll={false} scrollKey='standalone_hashtag_timeline' timelineId={`hashtag:${hashtag}`} - loadMore={this.handleLoadMore} + onLoadMore={this.handleLoadMore} /> ); diff --git a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js index 717f6fcaf..0b4238485 100644 --- a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js +++ b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js @@ -2,13 +2,11 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; -import { - refreshPublicTimeline, - expandPublicTimeline, -} from 'flavours/glitch/actions/timelines'; +import { expandPublicTimeline } from 'flavours/glitch/actions/timelines'; import Column from 'flavours/glitch/components/column'; import ColumnHeader from 'flavours/glitch/components/column_header'; import { defineMessages, injectIntl } from 'react-intl'; +import { connectPublicStream } from 'flavours/glitch/actions/streaming'; const messages = defineMessages({ title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' }, @@ -34,22 +32,19 @@ export default class PublicTimeline extends React.PureComponent { componentDidMount () { const { dispatch } = this.props; - dispatch(refreshPublicTimeline()); - - this.polling = setInterval(() => { - dispatch(refreshPublicTimeline()); - }, 3000); + dispatch(expandPublicTimeline()); + this.disconnect = dispatch(connectPublicStream()); } componentWillUnmount () { - if (typeof this.polling !== 'undefined') { - clearInterval(this.polling); - this.polling = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } - handleLoadMore = () => { - this.props.dispatch(expandPublicTimeline()); + handleLoadMore = maxId => { + this.props.dispatch(expandPublicTimeline({ maxId })); } render () { @@ -65,7 +60,7 @@ export default class PublicTimeline extends React.PureComponent { diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index 1ea0fa421..ef8805377 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -8,6 +8,7 @@ import { me } from 'flavours/glitch/util/initial_state'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, @@ -43,6 +44,7 @@ export default class ActionBar extends React.PureComponent { onMuteConversation: PropTypes.func, onBlock: PropTypes.func, onDelete: PropTypes.func.isRequired, + onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onReport: PropTypes.func, onPin: PropTypes.func, @@ -70,6 +72,10 @@ export default class ActionBar extends React.PureComponent { this.props.onDelete(this.props.status); } + handleDirectClick = () => { + this.props.onDirect(this.props.status.get('account'), this.context.router.history); + } + handleMentionClick = () => { this.props.onMention(this.props.status.get('account'), this.context.router.history); } @@ -115,6 +121,7 @@ export default class ActionBar extends React.PureComponent { if (publicStatus) { menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + menu.push(null); } if (me === status.getIn(['account', 'id'])) { @@ -128,6 +135,7 @@ export default class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); menu.push(null); menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); @@ -149,7 +157,7 @@ export default class ActionBar extends React.PureComponent {
-
+
{shareButton}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 16f7ae830..5cfc9dfae 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -37,8 +37,8 @@ export default class DetailedStatus extends ImmutablePureComponent { e.stopPropagation(); } - handleOpenVideo = startTime => { - this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + handleOpenVideo = (media, startTime) => { + this.props.onOpenVideo(media, startTime); } render () { diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 7e1658dbb..6c9da8e3e 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -21,6 +21,7 @@ import { import { replyCompose, mentionCompose, + directCompose, } from 'flavours/glitch/actions/compose'; import { blockAccount } from 'flavours/glitch/actions/accounts'; import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; @@ -170,6 +171,10 @@ export default class Status extends ImmutablePureComponent { } } + handleDirectClick = (account, router) => { + this.props.dispatch(directCompose(account, router)); + } + handleMentionClick = (account, router) => { this.props.dispatch(mentionCompose(account, router)); } @@ -399,6 +404,7 @@ export default class Status extends ImmutablePureComponent { onReblog={this.handleReblogClick} onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} + onDirect={this.handleDirectClick} onMention={this.handleMentionClick} onMute={this.handleMuteClick} onMuteConversation={this.handleConversationMuteClick} diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js index 6ab6770ed..bffe3b1f7 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -2,6 +2,7 @@ import React from 'react'; import ReactSwipeableViews from 'react-swipeable-views'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; +import Video from 'flavours/glitch/features/video'; import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player'; import classNames from 'classnames'; import { defineMessages, injectIntl } from 'react-intl'; @@ -112,6 +113,22 @@ export default class MediaModal extends ImmutablePureComponent { onClick={this.toggleNavigation} /> ); + } else if (image.get('type') === 'video') { + const { time } = this.props; + + return ( +