diff --git a/.env.production.sample b/.env.production.sample index d46768d09a..d920f18e9f 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -33,7 +33,6 @@ LOCAL_DOMAIN=example.com # Application secrets # Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) -PAPERCLIP_SECRET= SECRET_KEY_BASE= OTP_SECRET= @@ -143,6 +142,16 @@ STREAMING_CLUSTER_NUM=1 # Maximum allowed character count # MAX_TOOT_CHARS=500 +# LDAP authentication (optional) +# LDAP_ENABLED=true +# LDAP_HOST=localhost +# LDAP_PORT=389 +# LDAP_METHOD=simple_tls +# LDAP_BASE= +# LDAP_BIND_DN= +# LDAP_PASSWORD= +# LDAP_UID=cn + # PAM authentication (optional) # PAM authentication uses for the email generation the "email" pam variable # and optional as fallback PAM_DEFAULT_SUFFIX @@ -153,7 +162,7 @@ STREAMING_CLUSTER_NUM=1 # PAM_DEFAULT_SUFFIX=pam # Name of the pam service (pam "auth" section is evaluated) # PAM_DEFAULT_SERVICE=rpam -# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) +# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default) # PAM_CONTROLLED_SERVICE=rpam # Global OAuth settings (optional) : @@ -186,7 +195,7 @@ STREAMING_CLUSTER_NUM=1 # Optional SAML authentication (cf. omniauth-saml) # SAML_ENABLED=true # SAML_ACS_URL= -# SAML_ISSUER=http://localhost:3000/auth/auth/saml/metadata +# SAML_ISSUER=http://localhost:3000/auth/auth/saml/callback # SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO # SAML_IDP_CERT= # SAML_IDP_CERT_FINGERPRINT= diff --git a/Gemfile b/Gemfile index b6962861fe..bf53d4c774 100644 --- a/Gemfile +++ b/Gemfile @@ -7,7 +7,6 @@ gem 'pkg-config', '~> 1.2' gem 'puma', '~> 3.10' gem 'rails', '~> 5.1.4' -gem 'uglifier', '~> 3.2' gem 'hamlit-rails', '~> 0.2' gem 'pg', '~> 0.20' @@ -35,8 +34,9 @@ gem 'devise', '~> 4.4' gem 'devise-two-factor', '~> 3.0' gem 'devise_pam_authenticatable2', '~> 8.0', install_if: -> { ENV['PAM_ENABLED'] == 'true' } +gem 'net-ldap', '~> 0.10', install_if: -> { ENV['LDAP_ENABLED'] == 'true' } gem 'omniauth-cas', '~> 1.1', install_if: -> { ENV['CAS_ENABLED'] == 'true' } -gem 'omniauth-saml', '~> 1.8', install_if: -> { ENV['SAML_ENABLED'] == 'true' } +gem 'omniauth-saml', '~> 1.10', install_if: -> { ENV['SAML_ENABLED'] == 'true' } gem 'omniauth', '~> 1.2' gem 'doorkeeper', '~> 4.2' @@ -98,6 +98,10 @@ group :development, :test do gem 'rspec-rails', '~> 3.7' end +group :production, :test do + gem 'private_address_check', '~> 0.4.1' +end + group :test do gem 'capybara', '~> 2.15' gem 'climate_control', '~> 0.2' diff --git a/Gemfile.lock b/Gemfile.lock index 1905cf3e1f..f0ad8ab55f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -178,7 +178,6 @@ GEM et-orbi (1.0.8) tzinfo excon (0.59.0) - execjs (2.7.0) fabrication (2.18.0) faker (1.8.4) i18n (~> 0.5) @@ -319,6 +318,7 @@ GEM multi_json (1.12.2) multipart-post (2.0.0) necromancer (0.4.0) + net-ldap (0.16.1) net-scp (1.2.1) net-ssh (>= 2.6.5) net-ssh (4.2.0) @@ -340,9 +340,9 @@ GEM addressable (~> 2.3) nokogiri (~> 1.5) omniauth (~> 1.2) - omniauth-saml (1.9.0) + omniauth-saml (1.10.0) omniauth (~> 1.3, >= 1.3.2) - ruby-saml (~> 1.4, >= 1.4.3) + ruby-saml (~> 1.7) orm_adapter (0.5.0) ostatus2 (2.0.3) addressable (~> 2.5) @@ -379,6 +379,7 @@ GEM premailer-rails (1.10.1) actionmailer (>= 3, < 6) premailer (~> 1.7, >= 1.7.9) + private_address_check (0.4.1) pry (0.11.3) coderay (~> 1.1.0) method_source (~> 0.9.0) @@ -498,7 +499,7 @@ GEM unicode-display_width (~> 1.0, >= 1.0.1) ruby-oembed (0.12.0) ruby-progressbar (1.9.0) - ruby-saml (1.6.1) + ruby-saml (1.7.2) nokogiri (>= 1.5.10) rufus-scheduler (3.4.2) et-orbi (~> 1.0) @@ -586,8 +587,6 @@ GEM thread_safe (~> 0.1) tzinfo-data (1.2017.3) tzinfo (>= 1.0.0) - uglifier (3.2.0) - execjs (>= 0.3.0, < 3) unf (0.1.4) unf_ext unf_ext (0.0.7.4) @@ -672,12 +671,13 @@ DEPENDENCIES memory_profiler microformats (~> 4.0) mime-types (~> 3.1) + net-ldap (~> 0.10) nokogiri (~> 1.8) nsa (~> 0.2) oj (~> 3.3) omniauth (~> 1.2) omniauth-cas (~> 1.1) - omniauth-saml (~> 1.8) + omniauth-saml (~> 1.10) ostatus2 (~> 2.0) ox (~> 2.8) paperclip (~> 5.1) @@ -688,6 +688,7 @@ DEPENDENCIES pkg-config (~> 1.2) posix-spawn premailer-rails + private_address_check (~> 0.4.1) pry-rails (~> 0.3) puma (~> 3.10) pundit (~> 1.1) @@ -724,7 +725,6 @@ DEPENDENCIES tty-prompt twitter-text (~> 1.14) tzinfo-data (~> 1.2017) - uglifier (~> 3.2) webmock (~> 3.0) webpacker (~> 3.0) webpush diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c9725ed007..1efaf619b0 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class AccountsController < ApplicationController + PAGE_SIZE = 20 + include AccountControllerConcern before_action :set_cache_headers @@ -17,13 +19,16 @@ class AccountsController < ApplicationController end @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? - @statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @statuses = filtered_status_page(params) @statuses = cache_collection(@statuses, Status) - @next_url = next_url unless @statuses.empty? + unless @statuses.empty? + @older_url = older_url if @statuses.last.id > filtered_statuses.last.id + @newer_url = newer_url if @statuses.first.id < filtered_statuses.first.id + end end format.atom do - @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]) render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? })) end @@ -70,13 +75,22 @@ class AccountsController < ApplicationController @account = Account.find_local!(params[:username]) end - def next_url + def older_url + ::Rails.logger.info("older: max_id #{@statuses.last.id}, url #{pagination_url(max_id: @statuses.last.id)}") + pagination_url(max_id: @statuses.last.id) + end + + def newer_url + pagination_url(min_id: @statuses.first.id) + end + + def pagination_url(max_id: nil, min_id: nil) if media_requested? - short_account_media_url(@account, max_id: @statuses.last.id) + short_account_media_url(@account, max_id: max_id, min_id: min_id) elsif replies_requested? - short_account_with_replies_url(@account, max_id: @statuses.last.id) + short_account_with_replies_url(@account, max_id: max_id, min_id: min_id) else - short_account_url(@account, max_id: @statuses.last.id) + short_account_url(@account, max_id: max_id, min_id: min_id) end end @@ -87,4 +101,12 @@ class AccountsController < ApplicationController def replies_requested? request.path.ends_with?('/with_replies') end + + def filtered_status_page(params) + if params[:min_id].present? + filtered_statuses.paginate_by_min_id(PAGE_SIZE, params[:min_id]).reverse + else + filtered_statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]).to_a + end + end end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 52e68ab35f..7b5168b314 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -51,6 +51,10 @@ class Api::BaseController < ApplicationController [params[:limit].to_i.abs, default_limit * 2].min end + def truthy_param?(key) + ActiveModel::Type::Boolean.new.cast(params[key]) + end + def current_resource_owner @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token end diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 11e647c3cb..7649da4339 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -22,8 +22,4 @@ class Api::V1::Accounts::SearchController < Api::BaseController following: truthy_param?(:following) ) end - - def truthy_param?(key) - params[key] == 'true' - end end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 095f6937b0..7261ccd247 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -28,9 +28,9 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def account_statuses default_statuses.tap do |statuses| - statuses.merge!(only_media_scope) if params[:only_media] - statuses.merge!(pinned_scope) if params[:pinned] - statuses.merge!(no_replies_scope) if params[:exclude_replies] + 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 diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 4e73e9e8b5..d643259441 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -13,9 +13,9 @@ class Api::V1::AccountsController < Api::BaseController end def follow - FollowService.new.call(current_user.account, @account.acct, reblogs: params[:reblogs]) + FollowService.new.call(current_user.account, @account.acct, reblogs: truthy_param?(:reblogs)) - options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: params[:reblogs] } }, requested_map: { @account.id => false } } + options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) end @@ -26,7 +26,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account, notifications: params[:notifications]) + MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications)) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 22828217d2..f5095e0730 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -13,14 +13,14 @@ class Api::V1::ReportsController < Api::BaseController end def create - @report = current_account.reports.create!( - target_account: reported_account, + @report = ReportService.new.call( + current_account, + reported_account, status_ids: reported_status_ids, - comment: report_params[:comment] + comment: report_params[:comment], + forward: report_params[:forward] ) - User.staff.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later } - render json: @report, serializer: REST::ReportSerializer end @@ -39,6 +39,6 @@ class Api::V1::ReportsController < Api::BaseController end def report_params - params.permit(:account_id, :comment, status_ids: []) + params.permit(:account_id, :comment, :forward, status_ids: []) end end diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb index d1b4e04022..99b635ad90 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -33,12 +33,8 @@ class Api::V1::SearchController < Api::BaseController SearchService.new.call( params[:q], RESULTS_LIMIT, - resolving_search?, + truthy_param?(:resolve), current_account ) end - - def resolving_search? - params[:resolve] == 'true' - end end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 49887778e0..d7d70b94d5 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -21,15 +21,23 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def public_statuses - public_timeline_statuses.paginate_by_max_id( + statuses = public_timeline_statuses.paginate_by_max_id( limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id] ) + + if truthy_param?(:only_media) + # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids. + status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id) + statuses.where(id: status_ids) + else + statuses + end end def public_timeline_statuses - Status.as_public_timeline(current_account, params[:local]) + Status.as_public_timeline(current_account, truthy_param?(:local)) end def insert_pagination_headers @@ -37,7 +45,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def pagination_params(core_params) - params.permit(:local, :limit).merge(core_params) + params.permit(:local, :limit, :only_media).merge(core_params) end def next_path diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 08db04a39d..eb32611ad4 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -29,16 +29,24 @@ class Api::V1::Timelines::TagController < Api::BaseController if @tag.nil? [] else - tag_timeline_statuses.paginate_by_max_id( + statuses = tag_timeline_statuses.paginate_by_max_id( limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id] ) + + if truthy_param?(:only_media) + # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids. + status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id) + statuses.where(id: status_ids) + else + statuses + end end end def tag_timeline_statuses - Status.as_tag_timeline(@tag, current_account, params[:local]) + Status.as_tag_timeline(@tag, current_account, truthy_param?(:local)) end def insert_pagination_headers @@ -46,7 +54,7 @@ class Api::V1::Timelines::TagController < Api::BaseController end def pagination_params(core_params) - params.permit(:local, :limit).merge(core_params) + params.permit(:local, :limit, :only_media).merge(core_params) end def next_path diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a296d96db6..fc745eaece 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -15,7 +15,7 @@ class ApplicationController < ActionController::Base helper_method :current_flavour helper_method :current_skin helper_method :single_user_mode? - helper_method :use_pam? + helper_method :use_seamless_external_login? rescue_from ActionController::RoutingError, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found @@ -146,8 +146,8 @@ class ApplicationController < ActionController::Base @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists? end - def use_pam? - Devise.pam_authentication + def use_seamless_external_login? + Devise.pam_authentication || Devise.ldap_authentication end def current_account diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 475cd540a3..c9e507343f 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -38,7 +38,7 @@ class Auth::SessionsController < Devise::SessionsController if session[:otp_user_id] User.find(session[:otp_user_id]) elsif user_params[:email] - if use_pam? && Devise.check_at_sign && user_params[:email].index('@').nil? + if use_seamless_external_login? && Devise.check_at_sign && user_params[:email].index('@').nil? User.joins(:account).find_by(accounts: { username: user_params[:email] }) else User.find_for_authentication(email: user_params[:email]) diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 080cbde114..c74d3f86d8 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -9,6 +9,8 @@ class FollowerAccountsController < ApplicationController 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? end format.json do diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 74e83ad814..4c1e3f327b 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -9,6 +9,8 @@ class FollowingAccountsController < ApplicationController 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? end format.json do diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss index a25a50739e..d97e236963 100644 --- a/app/javascript/flavours/glitch/styles/about.scss +++ b/app/javascript/flavours/glitch/styles/about.scss @@ -15,117 +15,169 @@ $small-breakpoint: 960px; } } -.show-xs, -.show-sm { - display: none; -} +.landing-page { + .grid { + display: grid; + grid-gap: 10px; + grid-template-columns: 1fr 2fr; + grid-auto-columns: 25%; + grid-auto-rows: max-content; -.show-m { - display: block; -} + .column-0 { + display: none; + } -@media screen and (max-width: $small-breakpoint) { - .hide-sm { - display: none !important; + .column-1 { + grid-column: 1; + grid-row: 1; + } + + .column-2 { + grid-column: 2; + grid-row: 1; + } + + .column-3 { + grid-column: 3; + grid-row: 1 / 3; + } + + .column-4 { + grid-column: 1 / 3; + grid-row: 2; + } } - .show-sm { - display: block !important; - } -} + @media screen and (max-width: $small-breakpoint) { + .grid { + grid-template-columns: 40% 60%; -@media screen and (max-width: $column-breakpoint) { - .hide-xs { - display: none !important; - } + .column-0 { + display: none; + } - .show-xs { - display: block !important; - } -} + .column-1 { + grid-column: 1; + grid-row: 1; -.row { - display: flex; - flex-wrap: wrap; - margin: 0 -5px; - - @for $i from 1 through 15 { - .column-#{$i} { - box-sizing: border-box; - min-height: 1px; - flex: 0 0 percentage($i / 15); - max-width: percentage($i / 15); - padding: 0 5px; - - @media screen and (max-width: $small-breakpoint) { - &-sm { - box-sizing: border-box; - min-height: 1px; - flex: 0 0 percentage($i / 15); - max-width: percentage($i / 15); - padding: 0 5px; - - @media screen and (max-width: $column-breakpoint) { - max-width: 100%; - flex: 0 0 100%; - margin-bottom: 10px; - - &:last-child { - margin-bottom: 0; - } - } + &.non-preview .landing-page__forms { + height: 100%; } } - @media screen and (max-width: $column-breakpoint) { - max-width: 100%; - flex: 0 0 100%; - margin-bottom: 10px; + .column-2 { + grid-column: 2; + grid-row: 1 / 3; - &:last-child { - margin-bottom: 0; + &.non-preview { + grid-column: 2; + grid-row: 1; + } + } + + .column-3 { + grid-column: 1; + grid-row: 2 / 4; + } + + .column-4 { + grid-column: 2; + grid-row: 3; + + &.non-preview { + grid-column: 1 / 3; + grid-row: 2; } } } } -} -.column-flex { - display: flex; - flex-direction: column; -} + @media screen and (max-width: $column-breakpoint) { + .grid { + grid-template-columns: auto; -.separator-or { - position: relative; - margin: 40px 0; - text-align: center; + .column-0 { + display: block; + grid-column: 1; + grid-row: 1; + } - &::before { - content: ""; - display: block; - width: 100%; - height: 0; - border-bottom: 1px solid rgba($ui-base-lighter-color, .6); - position: absolute; - top: 50%; - left: 0; + .column-1 { + grid-column: 1; + grid-row: 3; + + .brand { + display: none; + } + } + + .column-2 { + grid-column: 1; + grid-row: 2; + + .landing-page__logo, + .landing-page__call-to-action { + display: none; + } + + &.non-preview { + grid-column: 1; + grid-row: 2; + } + } + + .column-3 { + grid-column: 1; + grid-row: 5; + } + + .column-4 { + grid-column: 1; + grid-row: 4; + + &.non-preview { + grid-column: 1; + grid-row: 4; + } + } + } } - span { - display: inline-block; - background: $ui-base-color; - font-size: 12px; - font-weight: 500; - color: $ui-primary-color; - text-transform: uppercase; + .column-flex { + display: flex; + flex-direction: column; + } + + .separator-or { position: relative; - z-index: 1; - padding: 0 8px; - cursor: default; - } -} + margin: 40px 0; + text-align: center; + + &::before { + content: ""; + display: block; + width: 100%; + height: 0; + border-bottom: 1px solid rgba($ui-base-lighter-color, .6); + position: absolute; + top: 50%; + left: 0; + } + + span { + display: inline-block; + background: $ui-base-color; + font-size: 12px; + font-weight: 500; + color: $ui-primary-color; + text-transform: uppercase; + position: relative; + z-index: 1; + padding: 0 8px; + cursor: default; + } + } -.landing-page { p, li { font-family: 'mastodon-font-sans-serif', sans-serif; @@ -502,7 +554,7 @@ $small-breakpoint: 960px; display: block; width: 80px; height: 80px; - border-radius: $ui-avatar-border-size; + border-radius: 48px; } } @@ -539,6 +591,7 @@ $small-breakpoint: 960px; img { position: static; + padding: 10px 0; } @media screen and (max-width: $small-breakpoint) { @@ -558,18 +611,33 @@ $small-breakpoint: 960px; } &__call-to-action { - margin-bottom: 10px; background: darken($ui-base-color, 4%); border-radius: 4px; padding: 25px 40px; overflow: hidden; .row { + display: flex; + flex-direction: row-reverse; + flex-wrap: wrap; + justify-content: space-between; align-items: center; } - .information-board__section { - padding: 0; + .row__information-board { + display: flex; + justify-content: flex-end; + align-items: flex-end; + + .information-board__section { + flex: 1 0 auto; + padding: 0 10px; + } + } + + .row__mascot { + flex: 1; + margin: 10px -50px 0 0; } } @@ -619,6 +687,8 @@ $small-breakpoint: 960px; &__short-description { .row { + display: flex; + flex-wrap: wrap; align-items: center; margin-bottom: 40px; } @@ -668,7 +738,6 @@ $small-breakpoint: 960px; height: 100%; @media screen and (max-width: $small-breakpoint) { - margin-bottom: 10px; height: auto; } @@ -717,15 +786,14 @@ $small-breakpoint: 960px; width: 100%; flex: 1 1 auto; overflow: hidden; + height: 100%; .column-header { color: inherit; font-family: inherit; font-size: 16px; - padding: 15px; line-height: inherit; font-weight: inherit; - margin: 0; } .column { @@ -942,93 +1010,54 @@ $small-breakpoint: 960px; } &.tag-page { - .features { - padding: 30px 0; + .grid { + @media screen and (min-width: $small-breakpoint) { + grid-template-columns: 33% 67%; + } - .container-alt { - max-width: 820px; - - #mastodon-timeline { - margin-right: 0; - border-top-right-radius: 0; - } - - .about-mastodon { - .about-hashtag { - background: darken($ui-base-color, 4%); - padding: 0 20px 20px 30px; - border-radius: 0 5px 5px 0; - - .brand { - padding-top: 20px; - margin-bottom: 20px; - - img { - height: 48px; - width: auto; - } - } - - p { - strong { - color: $ui-secondary-color; - font-weight: 700; - } - } - - .cta { - margin: 0; - - .button { - margin-right: 4px; - } - } - } - - .features-list { - margin-left: 30px; - margin-right: 10px; - } - } + .column-2 { + grid-column: 2; + grid-row: 1; } } - @media screen and (max-width: 675px) { - .features { - padding: 10px 0; + .brand { + text-align: unset; + padding: 0; - .container-alt { - display: flex; - flex-direction: column; + img { + height: 48px; + width: auto; + } + } - #mastodon-timeline { - order: 2; - flex: 0 0 auto; - height: 60vh; - margin-bottom: 20px; - border-top-right-radius: 4px; - } + .cta { + margin: 0; - .about-mastodon { - order: 1; - flex: 0 0 auto; - max-width: 100%; + .button { + margin-right: 4px; + } + } - .about-hashtag { - background: unset; - padding: 0; - border-radius: 0; - - .cta { - margin: 20px 0; - } - } - - .features-list { - display: none; - } - } + @media screen and (max-width: $column-breakpoint) { + .grid { + .column-1 { + grid-column: 1; + grid-row: 2; } + + .column-2 { + grid-column: 1; + grid-row: 1; + } + } + + .brand { + margin: 0; + } + + .landing-page__features { + display: none; } } } diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js index b19a07285b..afa0c34121 100644 --- a/app/javascript/mastodon/actions/reports.js +++ b/app/javascript/mastodon/actions/reports.js @@ -10,6 +10,7 @@ export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; +export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE'; export function initReport(account, status) { return dispatch => { @@ -45,6 +46,7 @@ export function submitReport() { account_id: getState().getIn(['reports', 'new', 'account_id']), status_ids: getState().getIn(['reports', 'new', 'status_ids']), comment: getState().getIn(['reports', 'new', 'comment']), + forward: getState().getIn(['reports', 'new', 'forward']), }).then(response => { dispatch(closeModal()); dispatch(submitReportSuccess(response.data)); @@ -78,3 +80,10 @@ export function changeReportComment(comment) { comment, }; }; + +export function changeReportForward(forward) { + return { + type: REPORT_FORWARD_CHANGE, + forward, + }; +}; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index df6a363795..858a12b152 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -120,7 +120,7 @@ export function refreshTimeline(timelineId, path, params = {}) { 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 refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); +export const refreshAccountTimeline = (accountId, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }); 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}`); @@ -161,7 +161,7 @@ export function expandTimeline(timelineId, path, params = {}) { 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 expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); +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}`); diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 9e1bb77c2c..3568a84407 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -283,8 +283,9 @@ export default class MediaGallery extends React.PureComponent { if (width) { style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); } + } else if (width) { + style.height = width / (16/9); } else { - // crop the image style.height = height; } @@ -309,7 +310,7 @@ export default class MediaGallery extends React.PureComponent { if (this.isStandaloneEligible()) { children = ; } else { - children = media.take(4).map((attachment, i) => ); + children = media.take(4).map((attachment, i) => ); } } diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index c030510a09..c52cd5f090 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -184,6 +184,7 @@ export default class Status extends ImmutablePureComponent { src={video.get('url')} width={239} height={110} + inline sensitive={status.get('sensitive')} onOpenVideo={this.handleOpenVideo} /> diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index b4f812c9a0..b538fa5fcb 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -53,11 +53,11 @@ export default class ActionBar extends React.PureComponent { let extraInfo = ''; menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); + if ('share' in navigator) { menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); } - menu.push(null); - menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` }); + menu.push(null); if (account.get('id') === me) { diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index dda3d4e37b..59c805c38d 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -12,24 +12,26 @@ export default class MediaItem extends ImmutablePureComponent { render () { const { media } = this.props; const status = media.get('status'); + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + const style = {}; - let content, style; + let content; if (media.get('type') === 'gifv') { content = GIF; } if (!status.get('sensitive')) { - style = { backgroundImage: `url(${media.get('preview_url')})` }; + style.backgroundImage = `url(${media.get('preview_url')})`; + style.backgroundPosition = `${x}% ${y}%`; } return (
- + {content}
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index ece219a3d5..4b408256a1 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -11,7 +11,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { getAccountGallery } from '../../selectors'; import MediaItem from './components/media_item'; import HeaderContainer from '../account_timeline/containers/header_container'; -import { FormattedMessage } from 'react-intl'; import { ScrollContainer } from 'react-router-scroll-4'; import LoadMore from '../../components/load_more'; @@ -89,10 +88,6 @@ export default class AccountGallery extends ImmutablePureComponent {
-
- -
-
{medias.map(media => ( + +
+ + + +
); } diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index f8c85c296d..aed009ef08 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -12,11 +12,15 @@ import ColumnBackButton from '../../components/column_back_button'; import { List as ImmutableList } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; -const mapStateToProps = (state, props) => ({ - statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()), - isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']), - hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']), -}); +const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { + const path = withReplies ? `${accountId}:with_replies` : accountId; + + return { + statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), + isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), + hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']), + }; +}; @connect(mapStateToProps) export default class AccountTimeline extends ImmutablePureComponent { @@ -27,23 +31,24 @@ export default class AccountTimeline extends ImmutablePureComponent { statusIds: ImmutablePropTypes.list, isLoading: PropTypes.bool, hasMore: PropTypes.bool, + withReplies: PropTypes.bool, }; componentWillMount () { this.props.dispatch(fetchAccount(this.props.params.accountId)); - this.props.dispatch(refreshAccountTimeline(this.props.params.accountId)); + this.props.dispatch(refreshAccountTimeline(this.props.params.accountId, this.props.withReplies)); } componentWillReceiveProps (nextProps) { - if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + 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(refreshAccountTimeline(nextProps.params.accountId)); + this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies)); } } handleScrollToBottom = () => { if (!this.props.isLoading && this.props.hasMore) { - this.props.dispatch(expandAccountTimeline(this.props.params.accountId)); + this.props.dispatch(expandAccountTimeline(this.props.params.accountId, this.props.withReplies)); } } diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js index 398fc44cee..71c0a203f0 100644 --- a/app/javascript/mastodon/features/compose/components/search.js +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -4,6 +4,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Overlay from 'react-overlays/lib/Overlay'; import Motion from '../../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; +import { searchEnabled } from '../../../initial_state'; const messages = defineMessages({ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, @@ -17,7 +18,7 @@ class SearchPopout extends React.PureComponent { render () { const { style } = this.props; - + const extraInformation = searchEnabled ? : ; return (
@@ -32,7 +33,7 @@ class SearchPopout extends React.PureComponent {
  • URL
  • - + {extraInformation}
    )} diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index abdb9a3f68..d4f21fc32b 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -57,6 +57,7 @@ export default class DetailedStatus extends ImmutablePureComponent { src={video.get('url')} width={300} height={150} + inline onOpenVideo={this.handleOpenVideo} sensitive={status.get('sensitive')} /> diff --git a/app/javascript/mastodon/features/ui/components/bundle.js b/app/javascript/mastodon/features/ui/components/bundle.js index fc88e0c70f..06a6c9cddf 100644 --- a/app/javascript/mastodon/features/ui/components/bundle.js +++ b/app/javascript/mastodon/features/ui/components/bundle.js @@ -26,7 +26,7 @@ class Bundle extends React.Component { onFetchFail: noop, } - static cache = {} + static cache = new Map state = { mod: undefined, @@ -51,13 +51,12 @@ class Bundle extends React.Component { load = (props) => { const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; + const cachedMod = Bundle.cache.get(fetchComponent); onFetch(); - if (Bundle.cache[fetchComponent.name]) { - const mod = Bundle.cache[fetchComponent.name]; - - this.setState({ mod: mod.default }); + if (cachedMod) { + this.setState({ mod: cachedMod.default }); onFetchSuccess(); return Promise.resolve(); } @@ -71,7 +70,7 @@ class Bundle extends React.Component { return fetchComponent() .then((mod) => { - Bundle.cache[fetchComponent.name] = mod; + Bundle.cache.set(fetchComponent, mod); this.setState({ mod: mod.default }); onFetchSuccess(); }) diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index a01e5a390e..e82c46402b 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -6,6 +6,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import ReactSwipeableViews from 'react-swipeable-views'; import { links, getIndex, getLink } from './tabs_bar'; +import { Link } from 'react-router-dom'; import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; @@ -152,11 +153,19 @@ export default class ColumnsArea extends ImmutablePureComponent { this.pendingIndex = null; if (singleColumn) { - return columnIndex !== -1 ? ( - + const floatingActionButton = this.context.router.history.location.pathname === '/statuses/new' ? null : ; + + return columnIndex !== -1 ? [ + {links.map(this.renderView)} - - ) :
    {children}
    ; +
    , + + floatingActionButton, + ] : [ +
    {children}
    , + + floatingActionButton, + ]; } return ( diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js index b5dfa422e4..3ae97646fc 100644 --- a/app/javascript/mastodon/features/ui/components/report_modal.js +++ b/app/javascript/mastodon/features/ui/components/report_modal.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { changeReportComment, submitReport } from '../../../actions/reports'; +import { changeReportComment, changeReportForward, submitReport } from '../../../actions/reports'; import { refreshAccountTimeline } from '../../../actions/timelines'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; @@ -10,8 +10,11 @@ import StatusCheckBox from '../../report/containers/status_check_box_container'; import { OrderedSet } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Button from '../../../components/button'; +import Toggle from 'react-toggle'; +import IconButton from '../../../components/icon_button'; const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, submit: { id: 'report.submit', defaultMessage: 'Submit' }, }); @@ -26,6 +29,7 @@ const makeMapStateToProps = () => { isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), account: getAccount(state, accountId), comment: state.getIn(['reports', 'new', 'comment']), + forward: state.getIn(['reports', 'new', 'forward']), statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), }; }; @@ -42,14 +46,19 @@ export default class ReportModal extends ImmutablePureComponent { account: ImmutablePropTypes.map, statusIds: ImmutablePropTypes.orderedSet.isRequired, comment: PropTypes.string.isRequired, + forward: PropTypes.bool, dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; - handleCommentChange = (e) => { + handleCommentChange = e => { this.props.dispatch(changeReportComment(e.target.value)); } + handleForwardChange = e => { + this.props.dispatch(changeReportForward(e.target.checked)); + } + handleSubmit = () => { this.props.dispatch(submitReport()); } @@ -65,26 +74,25 @@ export default class ReportModal extends ImmutablePureComponent { } render () { - const { account, comment, intl, statusIds, isSubmitting } = this.props; + const { account, comment, intl, statusIds, isSubmitting, forward, onClose } = this.props; if (!account) { return null; } + const domain = account.get('acct').split('@')[1]; + return (
    + {account.get('acct')} }} />
    -
    -
    - {statusIds.map(statusId => )} -
    -
    -
    +

    +