Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Essem 2024-03-14 20:36:26 -05:00
commit fd1f0891d9
No known key found for this signature in database
GPG key ID: 7D497397CC3A2A8C
525 changed files with 9484 additions and 4272 deletions

View file

@ -123,7 +123,7 @@ module.exports = defineConfig({
'react/react-in-jsx-scope': 'off', // not needed with new JSX transform 'react/react-in-jsx-scope': 'off', // not needed with new JSX transform
'react/self-closing-comp': 'error', 'react/self-closing-comp': 'error',
// recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/index.js // recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46
'jsx-a11y/accessible-emoji': 'warn', 'jsx-a11y/accessible-emoji': 'warn',
'jsx-a11y/click-events-have-key-events': 'off', 'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/label-has-associated-control': 'off', 'jsx-a11y/label-has-associated-control': 'off',
@ -176,7 +176,7 @@ module.exports = defineConfig({
}, },
], ],
// See https://github.com/import-js/eslint-plugin-import/blob/main/config/recommended.js // See https://github.com/import-js/eslint-plugin-import/blob/v2.29.1/config/recommended.js
'import/extensions': [ 'import/extensions': [
'error', 'error',
'always', 'always',

View file

@ -20,7 +20,7 @@ FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
# Example: v4.2.0-nightly.2023.11.09+something # Example: v4.2.0-nightly.2023.11.09+something
# Overwrite existance of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] # Overwrite existence of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"]
ARG MASTODON_VERSION_PRERELEASE="" ARG MASTODON_VERSION_PRERELEASE=""
# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="something"] # Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="something"]
ARG MASTODON_VERSION_METADATA="" ARG MASTODON_VERSION_METADATA=""
@ -29,7 +29,7 @@ ARG MASTODON_VERSION_METADATA=""
# See: https://docs.joinmastodon.org/admin/config/#rails_serve_static_files # See: https://docs.joinmastodon.org/admin/config/#rails_serve_static_files
ARG RAILS_SERVE_STATIC_FILES="true" ARG RAILS_SERVE_STATIC_FILES="true"
# Allow to use YJIT compiler # Allow to use YJIT compiler
# See: https://github.com/ruby/ruby/blob/master/doc/yjit/yjit.md # See: https://github.com/ruby/ruby/blob/v3_2_3/doc/yjit/yjit.md
ARG RUBY_YJIT_ENABLE="1" ARG RUBY_YJIT_ENABLE="1"
# Timezone used by the Docker container and runtime, change with [--build-arg TZ=Europe/Berlin] # Timezone used by the Docker container and runtime, change with [--build-arg TZ=Europe/Berlin]
ARG TZ="Etc/UTC" ARG TZ="Etc/UTC"

View file

@ -112,7 +112,7 @@ group :test do
# RSpec helpers for email specs # RSpec helpers for email specs
gem 'email_spec' gem 'email_spec'
# Extra RSpec extenion methods and helpers for sidekiq # Extra RSpec extension methods and helpers for sidekiq
gem 'rspec-sidekiq', '~> 4.0' gem 'rspec-sidekiq', '~> 4.0'
# Browser integration testing # Browser integration testing

View file

@ -465,11 +465,11 @@ GEM
statsd-ruby (~> 1.4, >= 1.4.0) statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.16.3) oj (3.16.3)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
omniauth (2.1.1) omniauth (2.1.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-cas (3.0.0.beta.1) omniauth-cas (3.0.0)
addressable (~> 2.8) addressable (~> 2.8)
nokogiri (~> 1.12) nokogiri (~> 1.12)
omniauth (~> 2.1) omniauth (~> 2.1)
@ -505,7 +505,7 @@ GEM
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.5.5) pg (1.5.6)
pghero (3.4.1) pghero (3.4.1)
activerecord (>= 6) activerecord (>= 6)
posix-spawn (0.3.15) posix-spawn (0.3.15)
@ -543,8 +543,9 @@ GEM
httpclient httpclient
json-jwt (>= 1.11.0) json-jwt (>= 1.11.0)
rack (>= 2.1.0) rack (>= 2.1.0)
rack-protection (3.0.5) rack-protection (3.2.0)
rack base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6) rack-proxy (0.7.6)
rack rack
rack-session (1.0.2) rack-session (1.0.2)
@ -743,7 +744,7 @@ GEM
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (>= 1.1.1, < 3)
terrapin (1.0.1) terrapin (1.0.1)
climate_control climate_control
test-prof (1.3.1) test-prof (1.3.2)
thor (1.3.1) thor (1.3.1)
tilt (2.3.0) tilt (2.3.0)
timeout (0.4.1) timeout (0.4.1)

View file

@ -53,7 +53,7 @@ module Admin
end end
def resource_params def resource_params
params.require(:rule).permit(:text, :priority) params.require(:rule).permit(:text, :hint, :priority)
end end
end end
end end

View file

@ -8,6 +8,7 @@ class Api::BaseController < ApplicationController
include Api::AccessTokenTrackingConcern include Api::AccessTokenTrackingConcern
include Api::CachingConcern include Api::CachingConcern
include Api::ContentSecurityPolicy include Api::ContentSecurityPolicy
include Api::ErrorHandling
skip_before_action :require_functional!, unless: :limited_federation_mode? skip_before_action :require_functional!, unless: :limited_federation_mode?
@ -18,51 +19,6 @@ class Api::BaseController < ApplicationController
protect_from_forgery with: :null_session protect_from_forgery with: :null_session
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: e.to_s }, status: 422
end
rescue_from ActiveRecord::RecordNotUnique do
render json: { error: 'Duplicate record' }, status: 422
end
rescue_from Date::Error do
render json: { error: 'Invalid date supplied' }, status: 422
end
rescue_from ActiveRecord::RecordNotFound do
render json: { error: 'Record not found' }, status: 404
end
rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do
render json: { error: 'Remote data could not be fetched' }, status: 503
end
rescue_from OpenSSL::SSL::SSLError do
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
end
rescue_from Mastodon::NotPermittedError do
render json: { error: 'This action is not allowed' }, status: 403
end
rescue_from Seahorse::Client::NetworkingError do |e|
Rails.logger.warn "Storage server error: #{e}"
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end
rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end
rescue_from Mastodon::RateLimitExceededError do
render json: { error: I18n.t('errors.429') }, status: 429
end
rescue_from ActionController::ParameterMissing, Mastodon::InvalidParameterError do |e|
render json: { error: e.to_s }, status: 400
end
def doorkeeper_unauthorized_render_options(error: nil) def doorkeeper_unauthorized_render_options(error: nil)
{ json: { error: error.try(:description) || 'Not authorized' } } { json: { error: error.try(:description) || 'Not authorized' } }
end end
@ -73,6 +29,14 @@ class Api::BaseController < ApplicationController
protected protected
def pagination_max_id
pagination_collection.last.id
end
def pagination_since_id
pagination_collection.first.id
end
def set_pagination_headers(next_path = nil, prev_path = nil) def set_pagination_headers(next_path = nil, prev_path = nil)
links = [] links = []
links << [next_path, [%w(rel next)]] if next_path links << [next_path, [%w(rel next)]] if next_path

View file

@ -51,11 +51,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
end end
def pagination_max_id def pagination_collection
@statuses.last.id @statuses
end
def pagination_since_id
@statuses.first.id
end end
end end

View file

@ -137,12 +137,8 @@ class Api::V1::Admin::AccountsController < Api::BaseController
api_v1_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty? api_v1_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty?
end end
def pagination_max_id def pagination_collection
@accounts.last.id @accounts
end
def pagination_since_id
@accounts.first.id
end end
def records_continue? def records_continue?

View file

@ -77,12 +77,8 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController
api_v1_admin_canonical_email_blocks_url(pagination_params(min_id: pagination_since_id)) unless @canonical_email_blocks.empty? api_v1_admin_canonical_email_blocks_url(pagination_params(min_id: pagination_since_id)) unless @canonical_email_blocks.empty?
end end
def pagination_max_id def pagination_collection
@canonical_email_blocks.last.id @canonical_email_blocks
end
def pagination_since_id
@canonical_email_blocks.first.id
end end
def records_continue? def records_continue?

View file

@ -73,12 +73,8 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController
api_v1_admin_domain_allows_url(pagination_params(min_id: pagination_since_id)) unless @domain_allows.empty? api_v1_admin_domain_allows_url(pagination_params(min_id: pagination_since_id)) unless @domain_allows.empty?
end end
def pagination_max_id def pagination_collection
@domain_allows.last.id @domain_allows
end
def pagination_since_id
@domain_allows.first.id
end end
def records_continue? def records_continue?

View file

@ -84,12 +84,8 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
api_v1_admin_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @domain_blocks.empty? api_v1_admin_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @domain_blocks.empty?
end end
def pagination_max_id def pagination_collection
@domain_blocks.last.id @domain_blocks
end
def pagination_since_id
@domain_blocks.first.id
end end
def records_continue? def records_continue?

View file

@ -70,12 +70,8 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController
api_v1_admin_email_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @email_domain_blocks.empty? api_v1_admin_email_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @email_domain_blocks.empty?
end end
def pagination_max_id def pagination_collection
@email_domain_blocks.last.id @email_domain_blocks
end
def pagination_since_id
@email_domain_blocks.first.id
end end
def records_continue? def records_continue?

View file

@ -75,12 +75,8 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController
api_v1_admin_ip_blocks_url(pagination_params(min_id: pagination_since_id)) unless @ip_blocks.empty? api_v1_admin_ip_blocks_url(pagination_params(min_id: pagination_since_id)) unless @ip_blocks.empty?
end end
def pagination_max_id def pagination_collection
@ip_blocks.last.id @ip_blocks
end
def pagination_since_id
@ip_blocks.first.id
end end
def records_continue? def records_continue?

View file

@ -101,12 +101,8 @@ class Api::V1::Admin::ReportsController < Api::BaseController
api_v1_admin_reports_url(pagination_params(min_id: pagination_since_id)) unless @reports.empty? api_v1_admin_reports_url(pagination_params(min_id: pagination_since_id)) unless @reports.empty?
end end
def pagination_max_id def pagination_collection
@reports.last.id @reports
end
def pagination_since_id
@reports.first.id
end end
def records_continue? def records_continue?

View file

@ -56,12 +56,8 @@ class Api::V1::Admin::TagsController < Api::BaseController
api_v1_admin_tags_url(pagination_params(min_id: pagination_since_id)) unless @tags.empty? api_v1_admin_tags_url(pagination_params(min_id: pagination_since_id)) unless @tags.empty?
end end
def pagination_max_id def pagination_collection
@tags.last.id @tags
end
def pagination_since_id
@tags.first.id
end end
def records_continue? def records_continue?

View file

@ -54,12 +54,8 @@ class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseC
api_v1_admin_trends_links_preview_card_providers_url(pagination_params(min_id: pagination_since_id)) unless @providers.empty? api_v1_admin_trends_links_preview_card_providers_url(pagination_params(min_id: pagination_since_id)) unless @providers.empty?
end end
def pagination_max_id def pagination_collection
@providers.last.id @providers
end
def pagination_since_id
@providers.first.id
end end
def records_continue? def records_continue?

View file

@ -40,12 +40,8 @@ class Api::V1::BlocksController < Api::BaseController
api_v1_blocks_url pagination_params(since_id: pagination_since_id) unless paginated_blocks.empty? api_v1_blocks_url pagination_params(since_id: pagination_since_id) unless paginated_blocks.empty?
end end
def pagination_max_id def pagination_collection
paginated_blocks.last.id paginated_blocks
end
def pagination_since_id
paginated_blocks.first.id
end end
def records_continue? def records_continue?

View file

@ -43,12 +43,8 @@ class Api::V1::BookmarksController < Api::BaseController
api_v1_bookmarks_url pagination_params(min_id: pagination_since_id) unless results.empty? api_v1_bookmarks_url pagination_params(min_id: pagination_since_id) unless results.empty?
end end
def pagination_max_id def pagination_collection
results.last.id results
end
def pagination_since_id
results.first.id
end end
def records_continue? def records_continue?

View file

@ -41,12 +41,8 @@ class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController
api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty? api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty?
end end
def pagination_max_id def pagination_collection
@encrypted_messages.last.id @encrypted_messages
end
def pagination_since_id
@encrypted_messages.first.id
end end
def records_continue? def records_continue?

View file

@ -50,12 +50,8 @@ class Api::V1::DomainBlocksController < Api::BaseController
api_v1_domain_blocks_url pagination_params(since_id: pagination_since_id) unless @blocks.empty? api_v1_domain_blocks_url pagination_params(since_id: pagination_since_id) unless @blocks.empty?
end end
def pagination_max_id def pagination_collection
@blocks.last.id @blocks
end
def pagination_since_id
@blocks.first.id
end end
def records_continue? def records_continue?

View file

@ -44,12 +44,8 @@ class Api::V1::EndorsementsController < Api::BaseController
api_v1_endorsements_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? api_v1_endorsements_url pagination_params(since_id: pagination_since_id) unless @accounts.empty?
end end
def pagination_max_id def pagination_collection
@accounts.last.id @accounts
end
def pagination_since_id
@accounts.first.id
end end
def records_continue? def records_continue?

View file

@ -43,12 +43,8 @@ class Api::V1::FavouritesController < Api::BaseController
api_v1_favourites_url pagination_params(min_id: pagination_since_id) unless results.empty? api_v1_favourites_url pagination_params(min_id: pagination_since_id) unless results.empty?
end end
def pagination_max_id def pagination_collection
results.last.id results
end
def pagination_since_id
results.first.id
end end
def records_continue? def records_continue?

View file

@ -34,12 +34,8 @@ class Api::V1::FollowedTagsController < Api::BaseController
api_v1_followed_tags_url pagination_params(since_id: pagination_since_id) unless @results.empty? api_v1_followed_tags_url pagination_params(since_id: pagination_since_id) unless @results.empty?
end end
def pagination_max_id def pagination_collection
@results.last.id @results
end
def pagination_since_id
@results.first.id
end end
def records_continue? def records_continue?

View file

@ -71,12 +71,8 @@ class Api::V1::Lists::AccountsController < Api::BaseController
api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty?
end end
def pagination_max_id def pagination_collection
@accounts.last.id @accounts
end
def pagination_since_id
@accounts.first.id
end end
def records_continue? def records_continue?

View file

@ -40,12 +40,8 @@ class Api::V1::MutesController < Api::BaseController
api_v1_mutes_url pagination_params(since_id: pagination_since_id) unless paginated_mutes.empty? api_v1_mutes_url pagination_params(since_id: pagination_since_id) unless paginated_mutes.empty?
end end
def pagination_max_id def pagination_collection
paginated_mutes.last.id paginated_mutes
end
def pagination_since_id
paginated_mutes.first.id
end end
def records_continue? def records_continue?

View file

@ -0,0 +1,37 @@
# frozen_string_literal: true
class Api::V1::Notifications::PoliciesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update
before_action :require_user!
before_action :set_policy
def show
render json: @policy, serializer: REST::NotificationPolicySerializer
end
def update
@policy.update!(resource_params)
render json: @policy, serializer: REST::NotificationPolicySerializer
end
private
def set_policy
@policy = NotificationPolicy.find_or_initialize_by(account: current_account)
with_read_replica do
@policy.summarize!
end
end
def resource_params
params.permit(
:filter_not_following,
:filter_not_followers,
:filter_new_accounts,
:filter_private_mentions
)
end
end

View file

@ -0,0 +1,79 @@
# frozen_string_literal: true
class Api::V1::Notifications::RequestsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index
before_action :require_user!
before_action :set_request, except: :index
after_action :insert_pagination_headers, only: :index
def index
with_read_replica do
@requests = load_requests
@relationships = relationships
end
render json: @requests, each_serializer: REST::NotificationRequestSerializer, relationships: @relationships
end
def show
render json: @request, serializer: REST::NotificationRequestSerializer
end
def accept
AcceptNotificationRequestService.new.call(@request)
render_empty
end
def dismiss
@request.update!(dismissed: true)
render_empty
end
private
def load_requests
requests = NotificationRequest.where(account: current_account).where(dismissed: truthy_param?(:dismissed) || false).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
NotificationRequest.preload_cache_collection(requests) do |statuses|
cache_collection(statuses, Status)
end
end
def relationships
StatusRelationshipsPresenter.new(@requests.map(&:last_status), current_user&.account_id)
end
def set_request
@request = NotificationRequest.where(account: current_account).find(params[:id])
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) unless @requests.empty?
end
def prev_path
api_v1_notifications_requests_url pagination_params(min_id: pagination_since_id) unless @requests.empty?
end
def pagination_max_id
@requests.last.id
end
def pagination_since_id
@requests.first.id
end
def pagination_params(core_params)
params.slice(:dismissed).permit(:dismissed).merge(core_params)
end
end

View file

@ -58,7 +58,8 @@ class Api::V1::NotificationsController < Api::BaseController
current_account.notifications.without_suspended.browserable( current_account.notifications.without_suspended.browserable(
types: Array(browserable_params[:types]), types: Array(browserable_params[:types]),
exclude_types: Array(browserable_params[:exclude_types]), exclude_types: Array(browserable_params[:exclude_types]),
from_account_id: browserable_params[:account_id] from_account_id: browserable_params[:account_id],
include_filtered: truthy_param?(:include_filtered)
) )
end end
@ -78,19 +79,15 @@ class Api::V1::NotificationsController < Api::BaseController
api_v1_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? api_v1_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty?
end end
def pagination_max_id def pagination_collection
@notifications.last.id @notifications
end
def pagination_since_id
@notifications.first.id
end end
def browserable_params def browserable_params
params.permit(:account_id, types: [], exclude_types: []) params.permit(:account_id, :include_filtered, types: [], exclude_types: [])
end end
def pagination_params(core_params) def pagination_params(core_params)
params.slice(:limit, :account_id, :types, :exclude_types).permit(:limit, :account_id, types: [], exclude_types: []).merge(core_params) params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered).permit(:limit, :account_id, :include_filtered, types: [], exclude_types: []).merge(core_params)
end end
end end

View file

@ -63,11 +63,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
end end
def pagination_max_id def pagination_collection
@statuses.last.id @statuses
end
def pagination_since_id
@statuses.first.id
end end
end end

View file

@ -9,12 +9,8 @@ class Api::V1::Timelines::BaseController < Api::BaseController
set_pagination_headers(next_path, prev_path) set_pagination_headers(next_path, prev_path)
end end
def pagination_max_id def pagination_collection
@statuses.last.id @statuses
end
def pagination_since_id
@statuses.first.id
end end
def next_path_params def next_path_params

View file

@ -131,7 +131,7 @@ class ApplicationController < ActionController::Base
end end
def single_user_mode? def single_user_mode?
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.without_internal.exists?
end end
def use_seamless_external_login? def use_seamless_external_login?

View file

@ -0,0 +1,52 @@
# frozen_string_literal: true
module Api::ErrorHandling
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: e.to_s }, status: 422
end
rescue_from ActiveRecord::RecordNotUnique do
render json: { error: 'Duplicate record' }, status: 422
end
rescue_from Date::Error do
render json: { error: 'Invalid date supplied' }, status: 422
end
rescue_from ActiveRecord::RecordNotFound do
render json: { error: 'Record not found' }, status: 404
end
rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do
render json: { error: 'Remote data could not be fetched' }, status: 503
end
rescue_from OpenSSL::SSL::SSLError do
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
end
rescue_from Mastodon::NotPermittedError do
render json: { error: 'This action is not allowed' }, status: 403
end
rescue_from Seahorse::Client::NetworkingError do |e|
Rails.logger.warn "Storage server error: #{e}"
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end
rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end
rescue_from Mastodon::RateLimitExceededError do
render json: { error: I18n.t('errors.429') }, status: 429
end
rescue_from ActionController::ParameterMissing, Mastodon::InvalidParameterError do |e|
render json: { error: e.to_s }, status: 400
end
end
end

View file

@ -214,7 +214,7 @@ module ApplicationHelper
state_params[:moved_to_account] = current_account.moved_to_account state_params[:moved_to_account] = current_account.moved_to_account
end end
state_params[:owner] = Account.local.without_suspended.where('id > 0').first if single_user_mode? state_params[:owner] = Account.local.without_suspended.without_internal.first if single_user_mode?
json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json
# rubocop:disable Rails/OutputSafety # rubocop:disable Rails/OutputSafety

View file

@ -198,6 +198,7 @@ module LanguagesHelper
ldn: ['Láadan', 'Láadan'].freeze, ldn: ['Láadan', 'Láadan'].freeze,
lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze, lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze,
moh: ['Mohawk', 'Kanienʼkéha'].freeze, moh: ['Mohawk', 'Kanienʼkéha'].freeze,
nds: ['Low German', 'Plattdüütsch'].freeze,
pdc: ['Pennsylvania Dutch', 'Pennsilfaani-Deitsch'].freeze, pdc: ['Pennsylvania Dutch', 'Pennsilfaani-Deitsch'].freeze,
sco: ['Scots', 'Scots'].freeze, sco: ['Scots', 'Scots'].freeze,
sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze, sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze,

View file

@ -825,11 +825,12 @@ export function addPollOption(title) {
}; };
} }
export function changePollOption(index, title) { export function changePollOption(index, title, maxOptions) {
return { return {
type: COMPOSE_POLL_OPTION_CHANGE, type: COMPOSE_POLL_OPTION_CHANGE,
index, index,
title, title,
maxOptions,
}; };
} }

View file

@ -57,6 +57,38 @@ export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
export const NOTIFICATION_POLICY_FETCH_REQUEST = 'NOTIFICATION_POLICY_FETCH_REQUEST';
export const NOTIFICATION_POLICY_FETCH_SUCCESS = 'NOTIFICATION_POLICY_FETCH_SUCCESS';
export const NOTIFICATION_POLICY_FETCH_FAIL = 'NOTIFICATION_POLICY_FETCH_FAIL';
export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST';
export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS';
export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL';
export const NOTIFICATION_REQUESTS_EXPAND_REQUEST = 'NOTIFICATION_REQUESTS_EXPAND_REQUEST';
export const NOTIFICATION_REQUESTS_EXPAND_SUCCESS = 'NOTIFICATION_REQUESTS_EXPAND_SUCCESS';
export const NOTIFICATION_REQUESTS_EXPAND_FAIL = 'NOTIFICATION_REQUESTS_EXPAND_FAIL';
export const NOTIFICATION_REQUEST_FETCH_REQUEST = 'NOTIFICATION_REQUEST_FETCH_REQUEST';
export const NOTIFICATION_REQUEST_FETCH_SUCCESS = 'NOTIFICATION_REQUEST_FETCH_SUCCESS';
export const NOTIFICATION_REQUEST_FETCH_FAIL = 'NOTIFICATION_REQUEST_FETCH_FAIL';
export const NOTIFICATION_REQUEST_ACCEPT_REQUEST = 'NOTIFICATION_REQUEST_ACCEPT_REQUEST';
export const NOTIFICATION_REQUEST_ACCEPT_SUCCESS = 'NOTIFICATION_REQUEST_ACCEPT_SUCCESS';
export const NOTIFICATION_REQUEST_ACCEPT_FAIL = 'NOTIFICATION_REQUEST_ACCEPT_FAIL';
export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMISS_REQUEST';
export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS';
export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL';
defineMessages({ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
}); });
@ -402,3 +434,270 @@ export function setBrowserPermission (value) {
value, value,
}; };
} }
export const fetchNotificationPolicy = () => (dispatch, getState) => {
dispatch(fetchNotificationPolicyRequest());
api(getState).get('/api/v1/notifications/policy').then(({ data }) => {
dispatch(fetchNotificationPolicySuccess(data));
}).catch(err => {
dispatch(fetchNotificationPolicyFail(err));
});
};
export const fetchNotificationPolicyRequest = () => ({
type: NOTIFICATION_POLICY_FETCH_REQUEST,
});
export const fetchNotificationPolicySuccess = policy => ({
type: NOTIFICATION_POLICY_FETCH_SUCCESS,
policy,
});
export const fetchNotificationPolicyFail = error => ({
type: NOTIFICATION_POLICY_FETCH_FAIL,
error,
});
export const updateNotificationsPolicy = params => (dispatch, getState) => {
dispatch(fetchNotificationPolicyRequest());
api(getState).put('/api/v1/notifications/policy', params).then(({ data }) => {
dispatch(fetchNotificationPolicySuccess(data));
}).catch(err => {
dispatch(fetchNotificationPolicyFail(err));
});
};
export const fetchNotificationRequests = () => (dispatch, getState) => {
const params = {};
if (getState().getIn(['notificationRequests', 'isLoading'])) {
return;
}
if (getState().getIn(['notificationRequests', 'items'])?.size > 0) {
params.since_id = getState().getIn(['notificationRequests', 'items', 0, 'id']);
}
dispatch(fetchNotificationRequestsRequest());
api(getState).get('/api/v1/notifications/requests', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchNotificationRequestsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchNotificationRequestsFail(err));
});
};
export const fetchNotificationRequestsRequest = () => ({
type: NOTIFICATION_REQUESTS_FETCH_REQUEST,
});
export const fetchNotificationRequestsSuccess = (requests, next) => ({
type: NOTIFICATION_REQUESTS_FETCH_SUCCESS,
requests,
next,
});
export const fetchNotificationRequestsFail = error => ({
type: NOTIFICATION_REQUESTS_FETCH_FAIL,
error,
});
export const expandNotificationRequests = () => (dispatch, getState) => {
const url = getState().getIn(['notificationRequests', 'next']);
if (!url || getState().getIn(['notificationRequests', 'isLoading'])) {
return;
}
dispatch(expandNotificationRequestsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(expandNotificationRequestsSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(expandNotificationRequestsFail(err));
});
};
export const expandNotificationRequestsRequest = () => ({
type: NOTIFICATION_REQUESTS_EXPAND_REQUEST,
});
export const expandNotificationRequestsSuccess = (requests, next) => ({
type: NOTIFICATION_REQUESTS_EXPAND_SUCCESS,
requests,
next,
});
export const expandNotificationRequestsFail = error => ({
type: NOTIFICATION_REQUESTS_EXPAND_FAIL,
error,
});
export const fetchNotificationRequest = id => (dispatch, getState) => {
const current = getState().getIn(['notificationRequests', 'current']);
if (current.getIn(['item', 'id']) === id || current.get('isLoading')) {
return;
}
dispatch(fetchNotificationRequestRequest(id));
api(getState).get(`/api/v1/notifications/requests/${id}`).then(({ data }) => {
dispatch(fetchNotificationRequestSuccess(data));
}).catch(err => {
dispatch(fetchNotificationRequestFail(id, err));
});
};
export const fetchNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_FETCH_REQUEST,
id,
});
export const fetchNotificationRequestSuccess = request => ({
type: NOTIFICATION_REQUEST_FETCH_SUCCESS,
request,
});
export const fetchNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_FETCH_FAIL,
id,
error,
});
export const acceptNotificationRequest = id => (dispatch, getState) => {
dispatch(acceptNotificationRequestRequest(id));
api(getState).post(`/api/v1/notifications/requests/${id}/accept`).then(() => {
dispatch(acceptNotificationRequestSuccess(id));
}).catch(err => {
dispatch(acceptNotificationRequestFail(id, err));
});
};
export const acceptNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_ACCEPT_REQUEST,
id,
});
export const acceptNotificationRequestSuccess = id => ({
type: NOTIFICATION_REQUEST_ACCEPT_SUCCESS,
id,
});
export const acceptNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_ACCEPT_FAIL,
id,
error,
});
export const dismissNotificationRequest = id => (dispatch, getState) => {
dispatch(dismissNotificationRequestRequest(id));
api(getState).post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{
dispatch(dismissNotificationRequestSuccess(id));
}).catch(err => {
dispatch(dismissNotificationRequestFail(id, err));
});
};
export const dismissNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_DISMISS_REQUEST,
id,
});
export const dismissNotificationRequestSuccess = id => ({
type: NOTIFICATION_REQUEST_DISMISS_SUCCESS,
id,
});
export const dismissNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_DISMISS_FAIL,
id,
error,
});
export const fetchNotificationsForRequest = accountId => (dispatch, getState) => {
const current = getState().getIn(['notificationRequests', 'current']);
const params = { account_id: accountId };
if (current.getIn(['item', 'account']) === accountId) {
if (current.getIn(['notifications', 'isLoading'])) {
return;
}
if (current.getIn(['notifications', 'items'])?.size > 0) {
params.since_id = current.getIn(['notifications', 'items', 0, 'id']);
}
}
dispatch(fetchNotificationsForRequestRequest());
api(getState).get('/api/v1/notifications', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(fetchNotificationsForRequestSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(fetchNotificationsForRequestFail(err));
});
};
export const fetchNotificationsForRequestRequest = () => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
});
export const fetchNotificationsForRequestSuccess = (notifications, next) => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
notifications,
next,
});
export const fetchNotificationsForRequestFail = (error) => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
error,
});
export const expandNotificationsForRequest = () => (dispatch, getState) => {
const url = getState().getIn(['notificationRequests', 'current', 'notifications', 'next']);
if (!url || getState().getIn(['notificationRequests', 'current', 'notifications', 'isLoading'])) {
return;
}
dispatch(expandNotificationsForRequestRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(expandNotificationsForRequestSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(expandNotificationsForRequestFail(err));
});
};
export const expandNotificationsForRequestRequest = () => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST,
});
export const expandNotificationsForRequestSuccess = (notifications, next) => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS,
notifications,
next,
});
export const expandNotificationsForRequestFail = (error) => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL,
error,
});

View file

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent, useCallback } from 'react'; import { PureComponent, useCallback } from 'react';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
@ -11,7 +11,7 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import TuneIcon from '@/material-icons/400-24px/tune.svg?react'; import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { ButtonInTabsBar, useColumnsContext } from 'flavours/glitch/features/ui/util/columns_context'; import { ButtonInTabsBar, useColumnsContext } from 'flavours/glitch/features/ui/util/columns_context';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
@ -23,10 +23,12 @@ const messages = defineMessages({
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
}); });
const BackButton = ({ pinned, show }) => { const BackButton = ({ pinned, show, onlyIcon }) => {
const history = useAppHistory(); const history = useAppHistory();
const intl = useIntl();
const { multiColumn } = useColumnsContext(); const { multiColumn } = useColumnsContext();
const handleBackClick = useCallback(() => { const handleBackClick = useCallback(() => {
@ -39,18 +41,20 @@ const BackButton = ({ pinned, show }) => {
const showButton = history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || show); const showButton = history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || show);
if(!showButton) return null; if (!showButton) return null;
return (<button onClick={handleBackClick} className='column-header__back-button'>
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>);
return (
<button onClick={handleBackClick} className={classNames('column-header__back-button', { 'compact': onlyIcon })} aria-label={intl.formatMessage(messages.back)}>
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
{!onlyIcon && <FormattedMessage id='column_back_button.label' defaultMessage='Back' />}
</button>
);
}; };
BackButton.propTypes = { BackButton.propTypes = {
pinned: PropTypes.bool, pinned: PropTypes.bool,
show: PropTypes.bool, show: PropTypes.bool,
onlyIcon: PropTypes.bool,
}; };
class ColumnHeader extends PureComponent { class ColumnHeader extends PureComponent {
@ -145,27 +149,31 @@ class ColumnHeader extends PureComponent {
} }
if (multiColumn && pinned) { if (multiColumn && pinned) {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
moveButtons = ( moveButtons = (
<div key='move-buttons' className='column-header__setting-arrows'> <div className='column-header__setting-arrows'>
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button> <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button> <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>
</div> </div>
); );
} else if (multiColumn && this.props.onPin) { } else if (multiColumn && this.props.onPin) {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
} }
backButton = <BackButton pinned={pinned} show={showBackButton} />; backButton = <BackButton pinned={pinned} show={showBackButton} onlyIcon={!!title} />;
const collapsedContent = [ const collapsedContent = [
extraContent, extraContent,
]; ];
if (multiColumn) { if (multiColumn) {
collapsedContent.push(pinButton); collapsedContent.push(
collapsedContent.push(moveButtons); <div key='buttons' className='column-header__advanced-buttons'>
{pinButton}
{moveButtons}
</div>
);
} }
if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) { if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
@ -177,7 +185,7 @@ class ColumnHeader extends PureComponent {
onClick={this.handleToggleClick} onClick={this.handleToggleClick}
> >
<i className='icon-with-badge'> <i className='icon-with-badge'>
<Icon id='sliders' icon={TuneIcon} /> <Icon id='sliders' icon={SettingsIcon} />
{collapseIssues && <i className='icon-with-badge__issue-badge' />} {collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i> </i>
</button> </button>
@ -190,16 +198,19 @@ class ColumnHeader extends PureComponent {
<div className={wrapperClassName}> <div className={wrapperClassName}>
<h1 className={buttonClassName}> <h1 className={buttonClassName}>
{hasTitle && ( {hasTitle && (
<button onClick={this.handleTitleClick}> <>
<Icon id={icon} icon={iconComponent} className='column-header__icon' /> {showBackButton && backButton}
{title}
</button> <button onClick={this.handleTitleClick} className='column-header__title'>
{!showBackButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
{title}
</button>
</>
)} )}
{!hasTitle && backButton} {!hasTitle && showBackButton && backButton}
<div className='column-header__buttons'> <div className='column-header__buttons'>
{hasTitle && backButton}
{extraButton} {extraButton}
{collapseButton} {collapseButton}
</div> </div>

View file

@ -9,7 +9,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { CircularProgress } from 'flavours/glitch/components/circular_progress'; import { CircularProgress } from 'flavours/glitch/components/circular_progress';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
@ -298,7 +297,7 @@ class Dropdown extends PureComponent {
}) : ( }) : (
<IconButton <IconButton
icon={!open ? icon : 'close'} icon={!open ? icon : 'close'}
iconComponent={!open ? iconComponent : CloseIcon} iconComponent={iconComponent}
title={title} title={title}
active={open} active={open}
disabled={disabled} disabled={disabled}

View file

@ -5,9 +5,7 @@ import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { Icon } from 'flavours/glitch/components/icon';
import InlineAccount from 'flavours/glitch/components/inline_account'; import InlineAccount from 'flavours/glitch/components/inline_account';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
@ -67,7 +65,7 @@ class EditedTimestamp extends PureComponent {
return ( return (
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}> <DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
<button className='dropdown-menu__text-button'> <button className='dropdown-menu__text-button'>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(timestamp, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> <Icon id='caret-down' icon={ArrowDropDownIcon} /> <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <span className='animated-number'>{intl.formatDate(timestamp, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span> }} />
</button> </button>
</DropdownMenu> </DropdownMenu>
); );

View file

@ -20,6 +20,7 @@ import Card from '../features/status/components/card';
// to use the progress bar to show download progress // to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle'; import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { SensitiveMediaContext } from '../features/ui/util/sensitive_media_context';
import { displayMedia, visibleReactions } from '../initial_state'; import { displayMedia, visibleReactions } from '../initial_state';
import AttachmentList from './attachment_list'; import AttachmentList from './attachment_list';
@ -73,9 +74,7 @@ export const defaultMediaVisibility = (status, settings) => {
class Status extends ImmutablePureComponent { class Status extends ImmutablePureComponent {
static contextTypes = { static contextType = SensitiveMediaContext;
identity: PropTypes.object,
};
static propTypes = { static propTypes = {
containerId: PropTypes.string, containerId: PropTypes.string,
@ -132,8 +131,7 @@ class Status extends ImmutablePureComponent {
isCollapsed: false, isCollapsed: false,
autoCollapsed: false, autoCollapsed: false,
isExpanded: undefined, isExpanded: undefined,
showMedia: undefined, showMedia: defaultMediaVisibility(this.props.status, this.props.settings) && !(this.context?.hideMediaByDefault),
statusId: undefined,
revealBehindCW: undefined, revealBehindCW: undefined,
showCard: false, showCard: false,
forceFilter: undefined, forceFilter: undefined,
@ -218,12 +216,6 @@ class Status extends ImmutablePureComponent {
updated = true; updated = true;
} }
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings);
update.statusId = nextProps.status.get('id');
updated = true;
}
if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) { if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) {
update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']); update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']);
if (update.revealBehindCW) { if (update.revealBehindCW) {
@ -319,6 +311,18 @@ class Status extends ImmutablePureComponent {
if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) { if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) {
this.props.updateScrollBottom(snapshot.height - snapshot.top); this.props.updateScrollBottom(snapshot.height - snapshot.top);
} }
// This will potentially cause a wasteful redraw, but in most cases `Status` components are used
// with a `key` directly depending on their `id`, preventing re-use of the component across
// different IDs.
// But just in case this does change, reset the state on status change.
if (this.props.status?.get('id') !== prevProps.status?.get('id')) {
this.setState({
showMedia: defaultMediaVisibility(this.props.status, this.props.settings) && !(this.context?.hideMediaByDefault),
forceFilter: undefined,
});
}
} }
componentWillUnmount() { componentWillUnmount() {

View file

@ -170,7 +170,8 @@ class About extends PureComponent {
<ol className='rules-list'> <ol className='rules-list'>
{server.get('rules').map(rule => ( {server.get('rules').map(rule => (
<li key={rule.get('id')}> <li key={rule.get('id')}>
<span className='rules-list__text'>{rule.get('text')}</span> <div className='rules-list__text'>{rule.get('text')}</div>
{rule.get('hint').length > 0 && (<div className='rules-list__hint'>{rule.get('hint')}</div>)}
</li> </li>
))} ))}
</ol> </ol>
@ -188,18 +189,20 @@ class About extends PureComponent {
<> <>
<p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p> <p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
<div className='about__domain-blocks'> {domainBlocks.get('items').size > 0 && (
{domainBlocks.get('items').map(block => ( <div className='about__domain-blocks'>
<div className='about__domain-blocks__domain' key={block.get('domain')}> {domainBlocks.get('items').map(block => (
<div className='about__domain-blocks__domain__header'> <div className='about__domain-blocks__domain' key={block.get('domain')}>
<h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6> <div className='about__domain-blocks__domain__header'>
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span> <h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
</div> <span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span>
</div>
<p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p> <p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
</div> </div>
))} ))}
</div> </div>
)}
</> </>
) : ( ) : (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p> <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>

View file

@ -21,6 +21,7 @@ import { Button } from 'flavours/glitch/components/button';
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button'; import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { autoPlayGif, me, domain } from 'flavours/glitch/initial_state'; import { autoPlayGif, me, domain } from 'flavours/glitch/initial_state';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
@ -206,7 +207,7 @@ class Header extends ImmutablePureComponent {
if (me !== account.get('id')) { if (me !== account.get('id')) {
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = ''; actionBtn = <Button disabled><LoadingIndicator /></Button>;
} else if (account.getIn(['relationship', 'requested'])) { } else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) { } else if (!account.getIn(['relationship', 'blocking'])) {
@ -347,15 +348,10 @@ class Header extends ImmutablePureComponent {
</a> </a>
<div className='account__header__tabs__buttons'> <div className='account__header__tabs__buttons'>
{!hidden && ( {!hidden && bellBtn}
<> {!hidden && shareBtn}
{actionBtn}
{bellBtn}
{shareBtn}
</>
)}
<DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' iconComponent={MoreHorizIcon} size={24} direction='right' /> <DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' iconComponent={MoreHorizIcon} size={24} direction='right' />
{!hidden && actionBtn}
</div> </div>
</div> </div>

View file

@ -26,7 +26,7 @@ class ColumnSettings extends PureComponent {
const { settings, onChange, intl } = this.props; const { settings, onChange, intl } = this.props;
return ( return (
<div> <div className='column-settings'>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} /> <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
</div> </div>

View file

@ -59,10 +59,11 @@ const Option = ({ multipleChoice, index, title, autoFocus }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const suggestions = useSelector(state => state.getIn(['compose', 'suggestions'])); const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
const lang = useSelector(state => state.getIn(['compose', 'language'])); const lang = useSelector(state => state.getIn(['compose', 'language']));
const maxOptions = useSelector(state => state.getIn(['server', 'server', 'configuration', 'polls', 'max_options']));
const handleChange = useCallback(({ target: { value } }) => { const handleChange = useCallback(({ target: { value } }) => {
dispatch(changePollOption(index, value)); dispatch(changePollOption(index, value, maxOptions));
}, [dispatch, index]); }, [dispatch, index, maxOptions]);
const handleSuggestionsFetchRequested = useCallback(token => { const handleSuggestionsFetchRequested = useCallback(token => {
dispatch(fetchComposeSuggestions(token)); dispatch(fetchComposeSuggestions(token));

View file

@ -7,7 +7,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { LoadMore } from 'flavours/glitch/components/load_more'; import { LoadMore } from 'flavours/glitch/components/load_more';
@ -76,11 +75,6 @@ class SearchResults extends ImmutablePureComponent {
return ( return (
<div className='search-results'> <div className='search-results'>
<div className='search-results__header'>
<Icon id='search' icon={SearchIcon} />
<FormattedMessage id='explore.search_results' defaultMessage='Search results' />
</div>
{accounts} {accounts}
{hashtags} {hashtags}
{statuses} {statuses}

View file

@ -26,18 +26,20 @@ class ColumnSettings extends PureComponent {
const { settings, onChange, intl } = this.props; const { settings, onChange, intl } = this.props;
return ( return (
<div> <div className='column-settings'>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> <section>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['conversations']} onChange={onChange} label={<FormattedMessage id='direct.group_by_conversations' defaultMessage='Group by conversation' />} />
</div>
</section>
<div className='column-settings__row'> <section>
<SettingToggle settings={settings} settingPath={['conversations']} onChange={onChange} label={<FormattedMessage id='direct.group_by_conversations' defaultMessage='Group by conversation' />} /> <h3><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></h3>
</div>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> <div className='column-settings__row'>
<SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
<div className='column-settings__row'> </div>
<SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> </section>
</div>
</div> </div>
); );
} }

View file

@ -45,28 +45,37 @@ const ColumnSettings = () => {
); );
return ( return (
<div> <div className='column-settings'>
<div className='column-settings__row'> <section>
<SettingToggle <div className='column-settings__row'>
settings={settings} <SettingToggle
settingPath={['onlyMedia']} settings={settings}
onChange={onChange} settingPath={['onlyMedia']}
label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} onChange={onChange}
/> label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />}
<SettingToggle />
settings={settings}
settingPath={['allowLocalOnly']} <SettingToggle
onChange={onChange} settings={settings}
label={<FormattedMessage id='firehose.column_settings.allow_local_only' defaultMessage='Show local-only posts in "All"' />} settingPath={['allowLocalOnly']}
/> onChange={onChange}
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> label={<FormattedMessage id='firehose.column_settings.allow_local_only' defaultMessage='Show local-only posts in "All"' />}
<SettingText />
settings={settings} </div>
settingPath={['regex', 'body']} </section>
onChange={onChange}
label={intl.formatMessage(messages.filter_regex)} <section>
/> <h3><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></h3>
</div>
<div className='column-settings__row'>
<SettingText
settings={settings}
settingPath={['regex', 'body']}
onChange={onChange}
label={intl.formatMessage(messages.filter_regex)}
/>
</div>
</section>
</div> </div>
); );
}; };

View file

@ -109,28 +109,28 @@ class ColumnSettings extends PureComponent {
const { settings, onChange } = this.props; const { settings, onChange } = this.props;
return ( return (
<div> <div className='column-settings'>
<div className='column-settings__row'> <section>
<div className='setting-toggle'> <div className='column-settings__row'>
<Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} /> <SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
<span className='setting-toggle__label'> <div className='setting-toggle'>
<FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' /> <Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} />
</span>
<span className='setting-toggle__label'>
<FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' />
</span>
</div>
</div> </div>
</div>
{this.state.open && ( {this.state.open && (
<div className='column-settings__hashtags'> <div className='column-settings__hashtags'>
{this.modeSelect('any')} {this.modeSelect('any')}
{this.modeSelect('all')} {this.modeSelect('all')}
{this.modeSelect('none')} {this.modeSelect('none')}
</div> </div>
)} )}
</section>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
</div>
</div> </div>
); );
} }

View file

@ -35,75 +35,68 @@ export const ColumnSettings: React.FC = () => {
); );
return ( return (
<div> <div className='column-settings'>
<span className='column-settings__section'> <section>
<FormattedMessage <div className='column-settings__row'>
id='home.column_settings.basic' <SettingToggle
defaultMessage='Basic' prefix='home_timeline'
/> settings={settings}
</span> settingPath={['shows', 'reblog']}
onChange={onChange}
label={
<FormattedMessage
id='home.column_settings.show_reblogs'
defaultMessage='Show boosts'
/>
}
/>
<div className='column-settings__row'> <SettingToggle
<SettingToggle prefix='home_timeline'
prefix='home_timeline' settings={settings}
settings={settings} settingPath={['shows', 'reply']}
settingPath={['shows', 'reblog']} onChange={onChange}
onChange={onChange} label={
label={ <FormattedMessage
<FormattedMessage id='home.column_settings.show_replies'
id='home.column_settings.show_reblogs' defaultMessage='Show replies'
defaultMessage='Show boosts' />
/> }
} />
/>
</div>
<div className='column-settings__row'> <SettingToggle
<SettingToggle prefix='home_timeline'
prefix='home_timeline' settings={settings}
settings={settings} settingPath={['shows', 'direct']}
settingPath={['shows', 'reply']} onChange={onChange}
onChange={onChange} label={
label={ <FormattedMessage
<FormattedMessage id='home.column_settings.show_direct'
id='home.column_settings.show_replies' defaultMessage='Show private mentions'
defaultMessage='Show replies' />
/> }
} />
/> </div>
</div> </section>
<div className='column-settings__row'> <section aria-labelledby='home-column-advanced'>
<SettingToggle <h3 id='home-column-advanced'>
prefix='home_timeline' <FormattedMessage
settings={settings} id='home.column_settings.advanced'
settingPath={['shows', 'direct']} defaultMessage='Advanced'
onChange={onChange} />
label={ </h3>
<FormattedMessage
id='home.column_settings.show_direct'
defaultMessage='Show private mentions'
/>
}
/>
</div>
<span className='column-settings__section'> <div className='column-settings__row'>
<FormattedMessage <SettingText
id='home.column_settings.advanced' prefix='home_timeline'
defaultMessage='Advanced' settings={settings}
/> settingPath={['regex', 'body']}
</span> onChange={onChange}
label={intl.formatMessage(messages.filter_regex)}
<div className='column-settings__row'> />
<SettingText </div>
prefix='home_timeline' </section>
settings={settings}
settingPath={['regex', 'body']}
onChange={onChange}
label={intl.formatMessage(messages.filter_regex)}
/>
</div>
</div> </div>
); );
}; };

View file

@ -193,35 +193,38 @@ class ListTimeline extends PureComponent {
pinned={pinned} pinned={pinned}
multiColumn={multiColumn} multiColumn={multiColumn}
> >
<div className='column-settings__row column-header__links'> <div className='column-settings'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}> <section className='column-header__links'>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' /> <button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
</button> <Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}> <button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' /> <Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
</button> </button>
</div> </section>
<div className='setting-toggle'> <section>
<Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} /> <div className='setting-toggle'>
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'> <Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} />
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' /> <label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
</label> <FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
</div> </label>
{ replies_policy !== undefined && (
<div role='group' aria-labelledby={`list-${id}-replies-policy`}>
<span id={`list-${id}-replies-policy`} className='column-settings__section'>
<FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' />
</span>
<div className='column-settings__row'>
{ ['none', 'list', 'followed'].map(policy => (
<RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
))}
</div> </div>
</div> </section>
)}
{replies_policy !== undefined && (
<section aria-labelledby={`list-${id}-replies-policy`}>
<h3 id={`list-${id}-replies-policy`}><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></h3>
<div className='column-settings__row'>
{ ['none', 'list', 'followed'].map(policy => (
<RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
))}
</div>
</section>
)}
</div>
</ColumnHeader> </ColumnHeader>
<StatusListContainer <StatusListContainer

View file

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import Toggle from 'react-toggle';
export const CheckboxWithLabel = ({ checked, disabled, children, onChange }) => {
const handleChange = useCallback(({ target }) => {
onChange(target.checked);
}, [onChange]);
return (
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
{children}
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle checked={checked} onChange={handleChange} disabled={disabled} />
</div>
</div>
</label>
);
};
CheckboxWithLabel.propTypes = {
checked: PropTypes.bool,
disabled: PropTypes.bool,
children: PropTypes.children,
onChange: PropTypes.func,
};

View file

@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'flavours/glitch/permissions';
import { CheckboxWithLabel } from './checkbox_with_label';
import ClearColumnButton from './clear_column_button'; import ClearColumnButton from './clear_column_button';
import GrantPermissionButton from './grant_permission_button'; import GrantPermissionButton from './grant_permission_button';
import PillBarButton from './pill_bar_button'; import PillBarButton from './pill_bar_button';
@ -27,14 +28,32 @@ export default class ColumnSettings extends PureComponent {
alertsEnabled: PropTypes.bool, alertsEnabled: PropTypes.bool,
browserSupport: PropTypes.bool, browserSupport: PropTypes.bool,
browserPermission: PropTypes.string, browserPermission: PropTypes.string,
notificationPolicy: ImmutablePropTypes.map,
onChangePolicy: PropTypes.func.isRequired,
}; };
onPushChange = (path, checked) => { onPushChange = (path, checked) => {
this.props.onChange(['push', ...path], checked); this.props.onChange(['push', ...path], checked);
}; };
handleFilterNotFollowing = checked => {
this.props.onChangePolicy('filter_not_following', checked);
};
handleFilterNotFollowers = checked => {
this.props.onChangePolicy('filter_not_followers', checked);
};
handleFilterNewAccounts = checked => {
this.props.onChangePolicy('filter_new_accounts', checked);
};
handleFilterPrivateMentions = checked => {
this.props.onChangePolicy('filter_private_mentions', checked);
};
render () { render () {
const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props; const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission, notificationPolicy } = this.props;
const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />; const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />; const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />;
@ -47,48 +66,68 @@ export default class ColumnSettings extends PureComponent {
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />; const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
return ( return (
<div> <div className='column-settings'>
{alertsEnabled && browserSupport && browserPermission === 'denied' && ( {alertsEnabled && browserSupport && browserPermission === 'denied' && (
<div className='column-settings__row column-settings__row--with-margin'> <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span>
<span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span>
</div>
)} )}
{alertsEnabled && browserSupport && browserPermission === 'default' && ( {alertsEnabled && browserSupport && browserPermission === 'default' && (
<div className='column-settings__row column-settings__row--with-margin'> <span className='warning-hint'>
<span className='warning-hint'> <FormattedMessage id='notifications.permission_required' defaultMessage='Desktop notifications are unavailable because the required permission has not been granted.' /> <GrantPermissionButton onClick={onRequestNotificationPermission} />
<FormattedMessage id='notifications.permission_required' defaultMessage='Desktop notifications are unavailable because the required permission has not been granted.' /> <GrantPermissionButton onClick={onRequestNotificationPermission} /> </span>
</span>
</div>
)} )}
<div className='column-settings__row'> <section>
<ClearColumnButton onClick={onClear} /> <ClearColumnButton onClick={onClear} />
</div> </section>
<div role='group' aria-labelledby='notifications-unread-markers'> <section>
<span id='notifications-unread-markers' className='column-settings__section'> <h3><FormattedMessage id='notifications.policy.title' defaultMessage='Filter out notifications from…' /></h3>
<FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
</span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={unreadMarkersShowStr} /> <CheckboxWithLabel checked={notificationPolicy.get('filter_not_following')} onChange={this.handleFilterNotFollowing}>
</div> <strong><FormattedMessage id='notifications.policy.filter_not_following_title' defaultMessage="People you don't follow" /></strong>
</div> <span className='hint'><FormattedMessage id='notifications.policy.filter_not_following_hint' defaultMessage='Until you manually approve them' /></span>
</CheckboxWithLabel>
<div role='group' aria-labelledby='notifications-filter-bar'> <CheckboxWithLabel checked={notificationPolicy.get('filter_not_followers')} onChange={this.handleFilterNotFollowers}>
<span id='notifications-filter-bar' className='column-settings__section'> <strong><FormattedMessage id='notifications.policy.filter_not_followers_title' defaultMessage='People not following you' /></strong>
<FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> <span className='hint'><FormattedMessage id='notifications.policy.filter_not_followers_hint' defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}' values={{ days: 3 }} /></span>
</span> </CheckboxWithLabel>
<CheckboxWithLabel checked={notificationPolicy.get('filter_new_accounts')} onChange={this.handleFilterNewAccounts}>
<strong><FormattedMessage id='notifications.policy.filter_new_accounts_title' defaultMessage='New accounts' /></strong>
<span className='hint'><FormattedMessage id='notifications.policy.filter_new_accounts.hint' defaultMessage='Created within the past {days, plural, one {one day} other {# days}}' values={{ days: 30 }} /></span>
</CheckboxWithLabel>
<CheckboxWithLabel checked={notificationPolicy.get('filter_private_mentions')} onChange={this.handleFilterPrivateMentions}>
<strong><FormattedMessage id='notifications.policy.filter_private_mentions_title' defaultMessage='Unsolicited private mentions' /></strong>
<span className='hint'><FormattedMessage id='notifications.policy.filter_private_mentions_hint' defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender" /></span>
</CheckboxWithLabel>
</div>
</section>
<section role='group' aria-labelledby='notifications-filter-bar'>
<h3 id='notifications-filter-bar'><FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /></h3>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterBarShowStr} /> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterBarShowStr} />
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} /> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
</div> </div>
</div> </section>
<div role='group' aria-labelledby='notifications-follow'> <section role='group' aria-labelledby='notifications-unread-markers'>
<span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <h3 id='notifications-unread-markers'>
<FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
</h3>
<div className='column-settings__row'>
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={unreadMarkersShowStr} />
</div>
</section>
<section role='group' aria-labelledby='notifications-follow'>
<h3 id='notifications-follow'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
@ -96,10 +135,10 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
<div role='group' aria-labelledby='notifications-follow-request'> <section role='group' aria-labelledby='notifications-follow-request'>
<span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> <h3 id='notifications-follow-request'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
@ -107,10 +146,10 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
<div role='group' aria-labelledby='notifications-favourite'> <section role='group' aria-labelledby='notifications-favourite'>
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favorites:' /></span> <h3 id='notifications-favourite'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favorites:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
@ -118,10 +157,10 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
<div role='group' aria-labelledby='notifications-reaction'> <section role='group' aria-labelledby='notifications-reaction'>
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reaction' defaultMessage='Reactions:' /></span> <h3 id='notifications-reaction'><FormattedMessage id='notifications.column_settings.reaction' defaultMessage='Reactions:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reaction']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reaction']} onChange={onChange} label={alertStr} />
@ -129,10 +168,10 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reaction']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reaction']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reaction']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reaction']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
<div role='group' aria-labelledby='notifications-mention'> <section role='group' aria-labelledby='notifications-mention'>
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <h3 id='notifications-mention'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
@ -140,10 +179,10 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
<div role='group' aria-labelledby='notifications-reblog'> <section role='group' aria-labelledby='notifications-reblog'>
<span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <h3 id='notifications-reblog'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
@ -151,10 +190,10 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
<div role='group' aria-labelledby='notifications-poll'> <section role='group' aria-labelledby='notifications-poll'>
<span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> <h3 id='notifications-poll'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
@ -162,10 +201,10 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
<div role='group' aria-labelledby='notifications-status'> <section role='group' aria-labelledby='notifications-status'>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New posts:' /></span> <h3 id='notifications-status'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New posts:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
@ -173,10 +212,10 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
<div role='group' aria-labelledby='notifications-update'> <section role='group' aria-labelledby='notifications-update'>
<span id='notifications-update' className='column-settings__section'><FormattedMessage id='notifications.column_settings.update' defaultMessage='Edits:' /></span> <h3 id='notifications-update'><FormattedMessage id='notifications.column_settings.update' defaultMessage='Edits:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'update']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'update']} onChange={onChange} label={alertStr} />
@ -184,11 +223,11 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'update']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'update']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'update']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'update']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
{((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) && ( {((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) && (
<div role='group' aria-labelledby='notifications-admin-sign-up'> <section role='group' aria-labelledby='notifications-admin-sign-up'>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></span> <h3 id='notifications-status'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.sign_up']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.sign_up']} onChange={onChange} label={alertStr} />
@ -196,12 +235,12 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'admin.sign_up']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'admin.sign_up']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'admin.sign_up']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'admin.sign_up']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
)} )}
{((this.context.identity.permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS) && ( {((this.context.identity.permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS) && (
<div role='group' aria-labelledby='notifications-admin-report'> <section role='group' aria-labelledby='notifications-admin-report'>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span> <h3 id='notifications-status'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></h3>
<div className='column-settings__pillbar'> <div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.report']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.report']} onChange={onChange} label={alertStr} />
@ -209,7 +248,7 @@ export default class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'admin.report']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'admin.report']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'admin.report']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'admin.report']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </section>
)} )}
</div> </div>
); );

View file

@ -0,0 +1,49 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import ArchiveIcon from '@/material-icons/400-24px/archive.svg?react';
import { fetchNotificationPolicy } from 'flavours/glitch/actions/notifications';
import { Icon } from 'flavours/glitch/components/icon';
import { toCappedNumber } from 'flavours/glitch/utils/numbers';
export const FilteredNotificationsBanner = () => {
const dispatch = useDispatch();
const policy = useSelector(state => state.get('notificationPolicy'));
useEffect(() => {
dispatch(fetchNotificationPolicy());
const interval = setInterval(() => {
dispatch(fetchNotificationPolicy());
}, 120000);
return () => {
clearInterval(interval);
};
}, [dispatch]);
if (policy === null || policy.getIn(['summary', 'pending_notifications_count']) * 1 === 0) {
return null;
}
return (
<Link className='filtered-notifications-banner' to='/notifications/requests'>
<Icon icon={ArchiveIcon} />
<div className='filtered-notifications-banner__text'>
<strong><FormattedMessage id='filtered_notifications_banner.title' defaultMessage='Filtered notifications' /></strong>
<span><FormattedMessage id='filtered_notifications_banner.pending_requests' defaultMessage='Notifications from {count, plural, =0 {no one} one {one person} other {# people}} you may know' values={{ count: policy.getIn(['summary', 'pending_requests_count']) }} /></span>
</div>
<div className='filtered-notifications-banner__badge'>
{toCappedNumber(policy.getIn(['summary', 'pending_notifications_count']))}
</div>
</Link>
);
};

View file

@ -0,0 +1,65 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react';
import { acceptNotificationRequest, dismissNotificationRequest } from 'flavours/glitch/actions/notifications';
import { Avatar } from 'flavours/glitch/components/avatar';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { makeGetAccount } from 'flavours/glitch/selectors';
import { toCappedNumber } from 'flavours/glitch/utils/numbers';
const getAccount = makeGetAccount();
const messages = defineMessages({
accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' },
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
});
export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
const dispatch = useDispatch();
const account = useSelector(state => getAccount(state, accountId));
const intl = useIntl();
const handleDismiss = useCallback(() => {
dispatch(dismissNotificationRequest(id));
}, [dispatch, id]);
const handleAccept = useCallback(() => {
dispatch(acceptNotificationRequest(id));
}, [dispatch, id]);
return (
<div className='notification-request'>
<Link to={`/notifications/requests/${id}`} className='notification-request__link'>
<Avatar account={account} size={36} />
<div className='notification-request__name'>
<div className='notification-request__name__display-name'>
<bdi><strong dangerouslySetInnerHTML={{ __html: account?.get('display_name_html') }} /></bdi>
<span className='filtered-notifications-banner__badge'>{toCappedNumber(notificationsCount)}</span>
</div>
<span>@{account?.get('acct')}</span>
</div>
</Link>
<div className='notification-request__actions'>
<IconButton iconComponent={VolumeOffIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<IconButton iconComponent={DoneIcon} onClick={handleAccept} title={intl.formatMessage(messages.accept)} />
</div>
</div>
);
};
NotificationRequest.propTypes = {
id: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
notificationsCount: PropTypes.string.isRequired,
};

View file

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { showAlert } from '../../../actions/alerts'; import { showAlert } from '../../../actions/alerts';
import { openModal } from '../../../actions/modal'; import { openModal } from '../../../actions/modal';
import { setFilter, clearNotifications, requestBrowserPermission } from '../../../actions/notifications'; import { setFilter, clearNotifications, requestBrowserPermission, updateNotificationsPolicy } from '../../../actions/notifications';
import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
import { changeSetting } from '../../../actions/settings'; import { changeSetting } from '../../../actions/settings';
import ColumnSettings from '../components/column_settings'; import ColumnSettings from '../components/column_settings';
@ -21,6 +21,7 @@ const mapStateToProps = state => ({
alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
browserSupport: state.getIn(['notifications', 'browserSupport']), browserSupport: state.getIn(['notifications', 'browserSupport']),
browserPermission: state.getIn(['notifications', 'browserPermission']), browserPermission: state.getIn(['notifications', 'browserPermission']),
notificationPolicy: state.get('notificationPolicy'),
}); });
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
@ -73,6 +74,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(requestBrowserPermission()); dispatch(requestBrowserPermission());
}, },
onChangePolicy (param, checked) {
dispatch(updateNotificationsPolicy({
[param]: checked,
}));
},
}); });
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));

View file

@ -37,6 +37,7 @@ import { LoadGap } from '../../components/load_gap';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import NotificationPurgeButtonsContainer from '../../containers/notification_purge_buttons_container'; import NotificationPurgeButtonsContainer from '../../containers/notification_purge_buttons_container';
import { FilteredNotificationsBanner } from './components/filtered_notifications_banner';
import NotificationsPermissionBanner from './components/notifications_permission_banner'; import NotificationsPermissionBanner from './components/notifications_permission_banner';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import FilterBarContainer from './containers/filter_bar_container'; import FilterBarContainer from './containers/filter_bar_container';
@ -357,6 +358,9 @@ class Notifications extends PureComponent {
</ColumnHeader> </ColumnHeader>
{filterBarContainer} {filterBarContainer}
<FilteredNotificationsBanner />
{scrollContainer} {scrollContainer}
<Helmet> <Helmet>

View file

@ -0,0 +1,147 @@
import PropTypes from 'prop-types';
import { useRef, useCallback, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useSelector, useDispatch } from 'react-redux';
import ArchiveIcon from '@/material-icons/400-24px/archive.svg?react';
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react';
import { fetchNotificationRequest, fetchNotificationsForRequest, expandNotificationsForRequest, acceptNotificationRequest, dismissNotificationRequest } from 'flavours/glitch/actions/notifications';
import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header';
import { IconButton } from 'flavours/glitch/components/icon_button';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { SensitiveMediaContextProvider } from 'flavours/glitch/features/ui/util/sensitive_media_context';
import NotificationContainer from './containers/notification_container';
const messages = defineMessages({
title: { id: 'notification_requests.notifications_from', defaultMessage: 'Notifications from {name}' },
accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' },
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
});
const selectChild = (ref, index, alignTop) => {
const container = ref.current.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (alignTop && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!alignTop && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
};
export const NotificationRequest = ({ multiColumn, params: { id } }) => {
const columnRef = useRef();
const intl = useIntl();
const dispatch = useDispatch();
const notificationRequest = useSelector(state => state.getIn(['notificationRequests', 'current', 'item', 'id']) === id ? state.getIn(['notificationRequests', 'current', 'item']) : null);
const accountId = notificationRequest?.get('account');
const account = useSelector(state => state.getIn(['accounts', accountId]));
const notifications = useSelector(state => state.getIn(['notificationRequests', 'current', 'notifications', 'items']));
const isLoading = useSelector(state => state.getIn(['notificationRequests', 'current', 'notifications', 'isLoading']));
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'current', 'notifications', 'next']));
const removed = useSelector(state => state.getIn(['notificationRequests', 'current', 'removed']));
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, [columnRef]);
const handleLoadMore = useCallback(() => {
dispatch(expandNotificationsForRequest());
}, [dispatch]);
const handleDismiss = useCallback(() => {
dispatch(dismissNotificationRequest(id));
}, [dispatch, id]);
const handleAccept = useCallback(() => {
dispatch(acceptNotificationRequest(id));
}, [dispatch, id]);
const handleMoveUp = useCallback(id => {
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
selectChild(columnRef, elementIndex, true);
}, [columnRef, notifications]);
const handleMoveDown = useCallback(id => {
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
selectChild(columnRef, elementIndex, false);
}, [columnRef, notifications]);
useEffect(() => {
dispatch(fetchNotificationRequest(id));
}, [dispatch, id]);
useEffect(() => {
if (accountId) {
dispatch(fetchNotificationsForRequest(accountId));
}
}, [dispatch, accountId]);
const columnTitle = intl.formatMessage(messages.title, { name: account?.get('display_name') || account?.get('username') });
return (
<Column bindToDocument={!multiColumn} ref={columnRef} label={columnTitle}>
<ColumnHeader
icon='archive'
iconComponent={ArchiveIcon}
title={columnTitle}
onClick={handleHeaderClick}
multiColumn={multiColumn}
showBackButton
extraButton={!removed && (
<>
<IconButton className='column-header__button' iconComponent={VolumeOffIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<IconButton className='column-header__button' iconComponent={DoneIcon} onClick={handleAccept} title={intl.formatMessage(messages.accept)} />
</>
)}
/>
<SensitiveMediaContextProvider hideMediaByDefault>
<ScrollableList
scrollKey={`notification_requests/${id}`}
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={isLoading}
showLoading={isLoading && notifications.size === 0}
hasMore={hasMore}
onLoadMore={handleLoadMore}
>
{notifications.map(item => (
item && <NotificationContainer
key={item.get('id')}
notification={item}
accountId={item.get('account')}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))}
</ScrollableList>
</SensitiveMediaContextProvider>
<Helmet>
<title>{columnTitle}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
NotificationRequest.propTypes = {
multiColumn: PropTypes.bool,
params: PropTypes.shape({
id: PropTypes.string.isRequired,
}),
};
export default NotificationRequest;

View file

@ -0,0 +1,85 @@
import PropTypes from 'prop-types';
import { useRef, useCallback, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useSelector, useDispatch } from 'react-redux';
import ArchiveIcon from '@/material-icons/400-24px/archive.svg?react';
import { fetchNotificationRequests, expandNotificationRequests } from 'flavours/glitch/actions/notifications';
import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { NotificationRequest } from './components/notification_request';
const messages = defineMessages({
title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' },
});
export const NotificationRequests = ({ multiColumn }) => {
const columnRef = useRef();
const intl = useIntl();
const dispatch = useDispatch();
const isLoading = useSelector(state => state.getIn(['notificationRequests', 'isLoading']));
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'next']));
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, [columnRef]);
const handleLoadMore = useCallback(() => {
dispatch(expandNotificationRequests());
}, [dispatch]);
useEffect(() => {
dispatch(fetchNotificationRequests());
}, [dispatch]);
return (
<Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='archive'
iconComponent={ArchiveIcon}
title={intl.formatMessage(messages.title)}
onClick={handleHeaderClick}
multiColumn={multiColumn}
showBackButton
/>
<ScrollableList
scrollKey='notification_requests'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={isLoading}
showLoading={isLoading && notificationRequests.size === 0}
hasMore={hasMore}
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.notification_requests' defaultMessage='All clear! There is nothing here. When you receive new notifications, they will appear here according to your settings.' />}
>
{notificationRequests.map(request => (
<NotificationRequest
key={request.get('id')}
id={request.get('id')}
accountId={request.get('account')}
notificationsCount={request.get('notifications_count')}
/>
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
NotificationRequests.propTypes = {
multiColumn: PropTypes.bool,
};
export default NotificationRequests;

View file

@ -25,18 +25,22 @@ class ColumnSettings extends PureComponent {
const { settings, onChange, intl } = this.props; const { settings, onChange, intl } = this.props;
return ( return (
<div> <div className='column-settings'>
<div className='column-settings__row'> <section>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} /> <div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyRemote']} onChange={onChange} label={<FormattedMessage id='community.column_settings.remote_only' defaultMessage='Remote only' />} /> <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
{!settings.getIn(['other', 'onlyRemote']) && <SettingToggle settings={settings} settingPath={['other', 'allowLocalOnly']} onChange={onChange} label={<FormattedMessage id='community.column_settings.allow_local_only' defaultMessage='Show local-only toots' />} />} <SettingToggle settings={settings} settingPath={['other', 'onlyRemote']} onChange={onChange} label={<FormattedMessage id='community.column_settings.remote_only' defaultMessage='Remote only' />} />
</div> {!settings.getIn(['other', 'onlyRemote']) && <SettingToggle settings={settings} settingPath={['other', 'allowLocalOnly']} onChange={onChange} label={<FormattedMessage id='community.column_settings.allow_local_only' defaultMessage='Show local-only toots' />} />}
</div>
</section>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> <section>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> <SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div> </div>
</section>
</div> </div>
); );
} }

View file

@ -1,6 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FormattedDate } from 'react-intl'; import { FormattedDate, FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link, withRouter } from 'react-router-dom'; import { Link, withRouter } from 'react-router-dom';
@ -8,14 +8,10 @@ import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import { AnimatedNumber } from 'flavours/glitch/components/animated_number'; import { AnimatedNumber } from 'flavours/glitch/components/animated_number';
import AttachmentList from 'flavours/glitch/components/attachment_list'; import AttachmentList from 'flavours/glitch/components/attachment_list';
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp'; import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar'; import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar';
import { Icon } from 'flavours/glitch/components/icon';
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon'; import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
import PollContainer from 'flavours/glitch/containers/poll_container'; import PollContainer from 'flavours/glitch/containers/poll_container';
@ -140,10 +136,7 @@ class DetailedStatus extends ImmutablePureComponent {
let applicationLink = ''; let applicationLink = '';
let reblogLink = ''; let reblogLink = '';
const reblogIcon = 'retweet';
const reblogIconComponent = RepeatIcon;
let favouriteLink = ''; let favouriteLink = '';
let edited = '';
// Depending on user settings, some media are considered as parts of the // Depending on user settings, some media are considered as parts of the
// contents (affected by CW) while other will be displayed outside of the // contents (affected by CW) while other will be displayed outside of the
@ -246,68 +239,53 @@ class DetailedStatus extends ImmutablePureComponent {
} }
if (status.get('application')) { if (status.get('application')) {
applicationLink = <> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></>; applicationLink = <>·<a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></>;
} }
const visibilityLink = <> · <VisibilityIcon visibility={status.get('visibility')} /></>; const visibilityLink = <>·<VisibilityIcon visibility={status.get('visibility')} /></>;
if (!['unlisted', 'public'].includes(status.get('visibility'))) { if (!['unlisted', 'public'].includes(status.get('visibility'))) {
reblogLink = null; reblogLink = null;
} else if (this.props.history) { } else if (this.props.history) {
reblogLink = ( reblogLink = (
<> <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
{' · '} <span className='detailed-status__reblogs'>
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'> <AnimatedNumber value={status.get('reblogs_count')} />
<Icon id={reblogIcon} icon={reblogIconComponent} /> </span>
<span className='detailed-status__reblogs'> <FormattedMessage id='status.reblogs' defaultMessage='{count, plural, one {boost} other {boosts}}' values={{ count: status.get('reblogs_count') }} />
<AnimatedNumber value={status.get('reblogs_count')} /> </Link>
</span>
</Link>
</>
); );
} else { } else {
reblogLink = ( reblogLink = (
<> <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
{' · '} <span className='detailed-status__reblogs'>
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}> <AnimatedNumber value={status.get('reblogs_count')} />
<Icon id={reblogIcon} icon={reblogIconComponent} /> </span>
<span className='detailed-status__reblogs'> <FormattedMessage id='status.reblogs' defaultMessage='{count, plural, one {boost} other {boosts}}' values={{ count: status.get('reblogs_count') }} />
<AnimatedNumber value={status.get('reblogs_count')} /> </a>
</span>
</a>
</>
); );
} }
if (this.props.history) { if (this.props.history) {
favouriteLink = ( favouriteLink = (
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'> <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'>
<Icon id='star' icon={StarIcon} />
<span className='detailed-status__favorites'> <span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} /> <AnimatedNumber value={status.get('favourites_count')} />
</span> </span>
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
</Link> </Link>
); );
} else { } else {
favouriteLink = ( favouriteLink = (
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id='star' icon={StarIcon} />
<span className='detailed-status__favorites'> <span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} /> <AnimatedNumber value={status.get('favourites_count')} />
</span> </span>
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
</a> </a>
); );
} }
if (status.get('edited_at')) {
edited = (
<>
{' · '}
<EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} />
</>
);
}
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
contentMedia.push(hashtagBar); contentMedia.push(hashtagBar);
@ -345,9 +323,23 @@ class DetailedStatus extends ImmutablePureComponent {
/> />
<div className='detailed-status__meta'> <div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> <div className='detailed-status__meta__line'>
<FormattedDate value={new Date(status.get('created_at'))} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} <FormattedDate value={new Date(status.get('created_at'))} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>
{visibilityLink}
{applicationLink}
</div>
{status.get('edited_at') && <div className='detailed-status__meta__line'><EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} /></div>}
<div className='detailed-status__meta__line'>
{reblogLink}
·
{favouriteLink}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,27 +1,30 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { NavLink } from 'react-router-dom'; import { useRouteMatch, NavLink } from 'react-router-dom';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
const ColumnLink = ({ icon, iconComponent, text, to, onClick, href, method, badge, transparent, ...other }) => { const ColumnLink = ({ icon, activeIcon, iconComponent, activeIconComponent, text, to, onClick, href, method, badge, transparent, ...other }) => {
const match = useRouteMatch(to);
const className = classNames('column-link', { 'column-link--transparent': transparent }); const className = classNames('column-link', { 'column-link--transparent': transparent });
const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null; const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
const iconElement = (typeof icon === 'string' || iconComponent) ? <Icon id={icon} icon={iconComponent} className='column-link__icon' /> : icon; const iconElement = (typeof icon === 'string' || iconComponent) ? <Icon id={icon} icon={iconComponent} className='column-link__icon' /> : icon;
const activeIconElement = activeIcon ?? (activeIconComponent ? <Icon id={icon} icon={activeIconComponent} className='column-link__icon' /> : iconElement);
const active = match?.isExact;
if (href) { if (href) {
return ( return (
<a href={href} className={className} data-method={method} title={text} {...other}> <a href={href} className={className} data-method={method} title={text} {...other}>
{iconElement} {active ? activeIconElement : iconElement}
<span>{text}</span> <span>{text}</span>
{badgeElement} {badgeElement}
</a> </a>
); );
} else if (to) { } else if (to) {
return ( return (
<NavLink to={to} className={className} title={text} {...other}> <NavLink to={to} className={className} title={text} exact {...other}>
{iconElement} {active ? activeIconElement : iconElement}
<span>{text}</span> <span>{text}</span>
{badgeElement} {badgeElement}
</NavLink> </NavLink>
@ -46,6 +49,8 @@ const ColumnLink = ({ icon, iconComponent, text, to, onClick, href, method, badg
ColumnLink.propTypes = { ColumnLink.propTypes = {
icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
iconComponent: PropTypes.func, iconComponent: PropTypes.func,
activeIcon: PropTypes.node,
activeIconComponent: PropTypes.func,
text: PropTypes.string.isRequired, text: PropTypes.string.isRequired,
to: PropTypes.string, to: PropTypes.string,
onClick: PropTypes.func, onClick: PropTypes.func,

View file

@ -1,55 +0,0 @@
import PropTypes from 'prop-types';
import { Component } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
const messages = defineMessages({
text: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
});
const mapStateToProps = state => ({
count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
});
class FollowRequestsColumnLink extends Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
count: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchFollowRequests());
}
render () {
const { count, intl } = this.props;
if (count === 0) {
return null;
}
return (
<ColumnLink
transparent
to='/follow_requests'
icon={<IconWithBadge className='column-link__icon' id='user-plus' icon={PersonAddIcon} count={count} />}
text={intl.formatMessage(messages.text)}
/>
);
}
}
export default injectIntl(connect(mapStateToProps)(FollowRequestsColumnLink));

View file

@ -1,10 +1,9 @@
import PropTypes from 'prop-types'; import { useEffect } from 'react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes'; import { useDispatch, useSelector } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchLists } from 'flavours/glitch/actions/lists'; import { fetchLists } from 'flavours/glitch/actions/lists';
@ -18,40 +17,25 @@ const getOrderedLists = createSelector([state => state.get('lists')], lists => {
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4); return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
}); });
const mapStateToProps = state => ({ export const ListPanel = () => {
lists: getOrderedLists(state), const dispatch = useDispatch();
}); const lists = useSelector(state => getOrderedLists(state));
class ListPanel extends ImmutablePureComponent { useEffect(() => {
static propTypes = {
dispatch: PropTypes.func.isRequired,
lists: ImmutablePropTypes.list,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchLists()); dispatch(fetchLists());
}, [dispatch]);
if (!lists || lists.isEmpty()) {
return null;
} }
render () { return (
const { lists } = this.props; <div className='list-panel'>
<hr />
if (!lists || lists.isEmpty()) { {lists.map(list => (
return null; <ColumnLink icon='list-ul' key={list.get('id')} iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={list.get('title')} to={`/lists/${list.get('id')}`} transparent />
} ))}
</div>
return ( );
<div className='list-panel'> };
<hr />
{lists.map(list => (
<ColumnLink icon='list-ul' iconComponent={ListAltIcon} key={list.get('id')} strict text={list.get('title')} to={`/lists/${list.get('id')}`} transparent />
))}
</div>
);
}
}
export default connect(mapStateToProps)(ListPanel);

View file

@ -1,19 +1,33 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Component } from 'react'; import { Component, useEffect } from 'react';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl, useIntl } from 'react-intl';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; import { useSelector, useDispatch } from 'react-redux';
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MailActiveIcon from '@/material-icons/400-24px/mail-fill.svg?react';
import MailIcon from '@/material-icons/400-24px/mail.svg?react'; import MailIcon from '@/material-icons/400-24px/mail.svg?react';
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react'; import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
import PersonAddActiveIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react'; import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarActiveIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarIcon from '@/material-icons/400-24px/star.svg?react';
import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
import { NavigationPortal } from 'flavours/glitch/components/navigation_portal'; import { NavigationPortal } from 'flavours/glitch/components/navigation_portal';
import { timelinePreview, trendsEnabled } from 'flavours/glitch/initial_state'; import { timelinePreview, trendsEnabled } from 'flavours/glitch/initial_state';
import { transientSingleColumn } from 'flavours/glitch/is_mobile'; import { transientSingleColumn } from 'flavours/glitch/is_mobile';
@ -21,9 +35,7 @@ import { preferencesLink } from 'flavours/glitch/utils/backend_links';
import ColumnLink from './column_link'; import ColumnLink from './column_link';
import DisabledAccountBanner from './disabled_account_banner'; import DisabledAccountBanner from './disabled_account_banner';
import FollowRequestsColumnLink from './follow_requests_column_link'; import { ListPanel } from './list_panel';
import ListPanel from './list_panel';
import NotificationsCounterIcon from './notifications_counter_icon';
import SignInBanner from './sign_in_banner'; import SignInBanner from './sign_in_banner';
const messages = defineMessages({ const messages = defineMessages({
@ -42,8 +54,48 @@ const messages = defineMessages({
advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' }, advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' }, openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
}); });
const NotificationsLink = () => {
const count = useSelector(state => state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0);
const intl = useIntl();
return (
<ColumnLink
transparent
to='/notifications'
icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={count} className='column-link__icon' />}
activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={count} className='column-link__icon' />}
text={intl.formatMessage(messages.notifications)}
/>
);
};
const FollowRequestsLink = () => {
const count = useSelector(state => state.getIn(['user_lists', 'follow_requests', 'items'])?.size ?? 0);
const intl = useIntl();
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchFollowRequests());
}, [dispatch]);
if (count === 0) {
return null;
}
return (
<ColumnLink
transparent
to='/follow_requests'
icon={<IconWithBadge id='user-plus' icon={PersonAddIcon} count={count} className='column-link__icon' />}
activeIcon={<IconWithBadge id='user-plus' icon={PersonAddActiveIcon} count={count} className='column-link__icon' />}
text={intl.formatMessage(messages.followRequests)}
/>
);
};
class NavigationPanel extends Component { class NavigationPanel extends Component {
static contextTypes = { static contextTypes = {
@ -84,9 +136,9 @@ class NavigationPanel extends Component {
{signedIn && ( {signedIn && (
<> <>
<ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} text={intl.formatMessage(messages.home)} /> <ColumnLink transparent to='/home' icon='home' iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} />
<ColumnLink transparent to='/notifications' icon={<NotificationsCounterIcon className='column-link__icon' />} text={intl.formatMessage(messages.notifications)} /> <NotificationsLink />
<FollowRequestsColumnLink /> <FollowRequestsLink />
</> </>
)} )}
@ -109,10 +161,10 @@ class NavigationPanel extends Component {
{signedIn && ( {signedIn && (
<> <>
<ColumnLink transparent to='/conversations' icon='at' iconComponent={MailIcon} text={intl.formatMessage(messages.direct)} /> <ColumnLink transparent to='/conversations' icon='at' iconComponent={MailIcon} activeIconComponent={MailActiveIcon} text={intl.formatMessage(messages.direct)} />
<ColumnLink transparent to='/bookmarks' icon='bookmark' iconComponent={BookmarksIcon} text={intl.formatMessage(messages.bookmarks)} /> <ColumnLink transparent to='/bookmarks' icon='bookmarks' iconComponent={BookmarksIcon} activeIconComponent={BookmarksActiveIcon} text={intl.formatMessage(messages.bookmarks)} />
<ColumnLink transparent to='/favourites' icon='star' iconComponent={StarIcon} text={intl.formatMessage(messages.favourites)} /> <ColumnLink transparent to='/favourites' icon='star' iconComponent={StarIcon} activeIconComponent={StarActiveIcon} text={intl.formatMessage(messages.favourites)} />
<ColumnLink transparent to='/lists' icon='list-ul' iconComponent={ListAltIcon} text={intl.formatMessage(messages.lists)} /> <ColumnLink transparent to='/lists' icon='list-ul' iconComponent={ListAltIcon} activeIconComponent={ListAltActiveIcon} text={intl.formatMessage(messages.lists)} />
<ListPanel /> <ListPanel />

View file

@ -1,13 +0,0 @@
import { connect } from 'react-redux';
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
const mapStateToProps = state => ({
count: state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0,
id: 'bell',
icon: NotificationsIcon,
});
export default connect(mapStateToProps)(IconWithBadge);

View file

@ -50,6 +50,8 @@ import {
DirectTimeline, DirectTimeline,
HashtagTimeline, HashtagTimeline,
Notifications, Notifications,
NotificationRequests,
NotificationRequest,
FollowRequests, FollowRequests,
FavouritedStatuses, FavouritedStatuses,
BookmarkedStatuses, BookmarkedStatuses,
@ -83,7 +85,6 @@ const mapStateToProps = state => ({
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4, canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
isWide: state.getIn(['local_settings', 'stretch']), isWide: state.getIn(['local_settings', 'stretch']),
dropdownMenuIsOpen: state.dropdownMenu.openId !== null,
unreadNotifications: state.getIn(['notifications', 'unread']), unreadNotifications: state.getIn(['notifications', 'unread']),
showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']), showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']),
hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']), hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']),
@ -213,7 +214,9 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} /> <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} /> <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} /> <WrappedRoute path='/notifications' component={Notifications} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} /> <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
@ -274,7 +277,6 @@ class UI extends PureComponent {
hasMediaAttachments: PropTypes.bool, hasMediaAttachments: PropTypes.bool,
canUploadMore: PropTypes.bool, canUploadMore: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
dropdownMenuIsOpen: PropTypes.bool,
unreadNotifications: PropTypes.number, unreadNotifications: PropTypes.number,
showFaviconBadge: PropTypes.bool, showFaviconBadge: PropTypes.bool,
hicolorPrivacyIcons: PropTypes.bool, hicolorPrivacyIcons: PropTypes.bool,
@ -600,7 +602,7 @@ class UI extends PureComponent {
render () { render () {
const { draggingOver } = this.state; const { draggingOver } = this.state;
const { children, isWide, location, dropdownMenuIsOpen, layout, moved } = this.props; const { children, isWide, location, layout, moved } = this.props;
const className = classNames('ui', { const className = classNames('ui', {
'wide': isWide, 'wide': isWide,
@ -632,7 +634,7 @@ class UI extends PureComponent {
return ( return (
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused> <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
<div className={className} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}> <div className={className} ref={this.setRef}>
{moved && (<div className='flash-message alert'> {moved && (<div className='flash-message alert'>
<FormattedMessage <FormattedMessage
id='moved_to_warning' id='moved_to_warning'

View file

@ -201,3 +201,11 @@ export function About () {
export function PrivacyPolicy () { export function PrivacyPolicy () {
return import(/*webpackChunkName: "features/glitch/async/privacy_policy" */'../../privacy_policy'); return import(/*webpackChunkName: "features/glitch/async/privacy_policy" */'../../privacy_policy');
} }
export function NotificationRequests () {
return import(/*webpackChunkName: "features/glitch/notifications/requests" */'../../notifications/requests');
}
export function NotificationRequest () {
return import(/*webpackChunkName: "features/glitch/notifications/request" */'../../notifications/request');
}

View file

@ -0,0 +1,28 @@
import { createContext, useContext, useMemo } from 'react';
export const SensitiveMediaContext = createContext<{
hideMediaByDefault: boolean;
}>({
hideMediaByDefault: false,
});
export function useSensitiveMediaContext() {
return useContext(SensitiveMediaContext);
}
type ContextValue = React.ContextType<typeof SensitiveMediaContext>;
export const SensitiveMediaContextProvider: React.FC<
React.PropsWithChildren<{ hideMediaByDefault: boolean }>
> = ({ hideMediaByDefault, children }) => {
const contextValue = useMemo<ContextValue>(
() => ({ hideMediaByDefault }),
[hideMediaByDefault],
);
return (
<SensitiveMediaContext.Provider value={contextValue}>
{children}
</SensitiveMediaContext.Provider>
);
};

View file

@ -66,6 +66,9 @@
"notification_purge.btn_invert": "Invert\nselection", "notification_purge.btn_invert": "Invert\nselection",
"notification_purge.btn_none": "Select\nnone", "notification_purge.btn_none": "Select\nnone",
"notification_purge.start": "Enter notification cleaning mode", "notification_purge.start": "Enter notification cleaning mode",
"notifications.column_settings.filter_bar.advanced": "Display all categories",
"notifications.column_settings.filter_bar.category": "Quick filter bar",
"notifications.column_settings.filter_bar.show_bar": "Show filter bar",
"notifications.column_settings.reaction": "Reactions:", "notifications.column_settings.reaction": "Reactions:",
"notifications.filter.reactions": "Reactions", "notifications.filter.reactions": "Reactions",
"notifications.marked_clear": "Clear selected notifications", "notifications.marked_clear": "Clear selected notifications",

View file

@ -55,7 +55,7 @@ import {
import { REDRAFT } from '../actions/statuses'; import { REDRAFT } from '../actions/statuses';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { me, defaultContentType, pollLimits } from '../initial_state'; import { me, defaultContentType } from '../initial_state';
import { recoverHashtags } from '../utils/hashtag'; import { recoverHashtags } from '../utils/hashtag';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
import { overwrite } from '../utils/js_helpers'; import { overwrite } from '../utils/js_helpers';
@ -358,12 +358,12 @@ const updateSuggestionTags = (state, token) => {
}); });
}; };
const updatePoll = (state, index, value) => state.updateIn(['poll', 'options'], options => { const updatePoll = (state, index, value, maxOptions) => state.updateIn(['poll', 'options'], options => {
const tmp = options.set(index, value).filterNot(x => x.trim().length === 0); const tmp = options.set(index, value).filterNot(x => x.trim().length === 0);
if (tmp.size === 0) { if (tmp.size === 0) {
return tmp.push('').push(''); return tmp.push('').push('');
} else if (tmp.size < pollLimits.max_options) { } else if (tmp.size < maxOptions) {
return tmp.push(''); return tmp.push('');
} }
@ -653,7 +653,7 @@ export default function compose(state = initialState, action) {
case COMPOSE_POLL_REMOVE: case COMPOSE_POLL_REMOVE:
return state.set('poll', null); return state.set('poll', null);
case COMPOSE_POLL_OPTION_CHANGE: case COMPOSE_POLL_OPTION_CHANGE:
return updatePoll(state, action.index, action.title); return updatePoll(state, action.index, action.title, action.maxOptions);
case COMPOSE_POLL_SETTINGS_CHANGE: case COMPOSE_POLL_SETTINGS_CHANGE:
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
case COMPOSE_LANGUAGE_CHANGE: case COMPOSE_LANGUAGE_CHANGE:

View file

@ -28,6 +28,8 @@ import media_attachments from './media_attachments';
import meta from './meta'; import meta from './meta';
import { modalReducer } from './modal'; import { modalReducer } from './modal';
import mutes from './mutes'; import mutes from './mutes';
import { notificationPolicyReducer } from './notification_policy';
import { notificationRequestsReducer } from './notification_requests';
import notifications from './notifications'; import notifications from './notifications';
import picture_in_picture from './picture_in_picture'; import picture_in_picture from './picture_in_picture';
import pinnedAccountsEditor from './pinned_accounts_editor'; import pinnedAccountsEditor from './pinned_accounts_editor';
@ -88,6 +90,8 @@ const reducers = {
history, history,
tags, tags,
followed_tags, followed_tags,
notificationPolicy: notificationPolicyReducer,
notificationRequests: notificationRequestsReducer,
}; };
// We want the root state to be an ImmutableRecord, which is an object with a defined list of keys, // We want the root state to be an ImmutableRecord, which is an object with a defined list of keys,

View file

@ -0,0 +1,12 @@
import { fromJS } from 'immutable';
import { NOTIFICATION_POLICY_FETCH_SUCCESS } from 'flavours/glitch/actions/notifications';
export const notificationPolicyReducer = (state = null, action) => {
switch(action.type) {
case NOTIFICATION_POLICY_FETCH_SUCCESS:
return fromJS(action.policy);
default:
return state;
}
};

View file

@ -0,0 +1,96 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import {
NOTIFICATION_REQUESTS_EXPAND_REQUEST,
NOTIFICATION_REQUESTS_EXPAND_SUCCESS,
NOTIFICATION_REQUESTS_EXPAND_FAIL,
NOTIFICATION_REQUESTS_FETCH_REQUEST,
NOTIFICATION_REQUESTS_FETCH_SUCCESS,
NOTIFICATION_REQUESTS_FETCH_FAIL,
NOTIFICATION_REQUEST_FETCH_REQUEST,
NOTIFICATION_REQUEST_FETCH_SUCCESS,
NOTIFICATION_REQUEST_FETCH_FAIL,
NOTIFICATION_REQUEST_ACCEPT_REQUEST,
NOTIFICATION_REQUEST_DISMISS_REQUEST,
NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST,
NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS,
NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL,
} from 'flavours/glitch/actions/notifications';
import { notificationToMap } from './notifications';
const initialState = ImmutableMap({
items: ImmutableList(),
isLoading: false,
next: null,
current: ImmutableMap({
isLoading: false,
item: null,
removed: false,
notifications: ImmutableMap({
items: ImmutableList(),
isLoading: false,
next: null,
}),
}),
});
const normalizeRequest = request => fromJS({
...request,
account: request.account.id,
});
const removeRequest = (state, id) => {
if (state.getIn(['current', 'item', 'id']) === id) {
state = state.setIn(['current', 'removed'], true);
}
return state.update('items', list => list.filterNot(item => item.get('id') === id));
};
export const notificationRequestsReducer = (state = initialState, action) => {
switch(action.type) {
case NOTIFICATION_REQUESTS_FETCH_SUCCESS:
return state.withMutations(map => {
map.update('items', list => ImmutableList(action.requests.map(normalizeRequest)).concat(list));
map.set('isLoading', false);
map.update('next', next => next ?? action.next);
});
case NOTIFICATION_REQUESTS_EXPAND_SUCCESS:
return state.withMutations(map => {
map.update('items', list => list.concat(ImmutableList(action.requests.map(normalizeRequest))));
map.set('isLoading', false);
map.set('next', action.next);
});
case NOTIFICATION_REQUESTS_EXPAND_REQUEST:
case NOTIFICATION_REQUESTS_FETCH_REQUEST:
return state.set('isLoading', true);
case NOTIFICATION_REQUESTS_EXPAND_FAIL:
case NOTIFICATION_REQUESTS_FETCH_FAIL:
return state.set('isLoading', false);
case NOTIFICATION_REQUEST_ACCEPT_REQUEST:
case NOTIFICATION_REQUEST_DISMISS_REQUEST:
return removeRequest(state, action.id);
case NOTIFICATION_REQUEST_FETCH_REQUEST:
return state.set('current', initialState.get('current').set('isLoading', true));
case NOTIFICATION_REQUEST_FETCH_SUCCESS:
return state.update('current', map => map.set('isLoading', false).set('item', normalizeRequest(action.request)));
case NOTIFICATION_REQUEST_FETCH_FAIL:
return state.update('current', map => map.set('isLoading', false));
case NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST:
case NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST:
return state.setIn(['current', 'notifications', 'isLoading'], true);
case NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS:
return state.updateIn(['current', 'notifications'], map => map.set('isLoading', false).update('items', list => ImmutableList(action.notifications.map(notificationToMap)).concat(list)).update('next', next => next ?? action.next));
case NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS:
return state.updateIn(['current', 'notifications'], map => map.set('isLoading', false).update('items', list => list.concat(ImmutableList(action.notifications.map(notificationToMap)))).set('next', action.next));
case NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL:
case NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL:
return state.setIn(['current', 'notifications', 'isLoading'], false);
default:
return state;
}
};

View file

@ -54,7 +54,7 @@ const initialState = ImmutableMap({
markNewForDelete: false, markNewForDelete: false,
}); });
const notificationToMap = (notification, markForDelete) => ImmutableMap({ export const notificationToMap = (notification, markForDelete = false) => ImmutableMap({
id: notification.id, id: notification.id,
type: notification.type, type: notification.type,
account: notification.account.id, account: notification.account.id,

View file

@ -26,7 +26,7 @@ $fluid-breakpoint: $maximum-width + 20px;
li { li {
position: relative; position: relative;
border-bottom: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid var(--background-border-color);
padding: 1em 1.75em; padding: 1em 1.75em;
padding-inline-start: 3em; padding-inline-start: 3em;
font-weight: 500; font-weight: 500;
@ -53,4 +53,10 @@ $fluid-breakpoint: $maximum-width + 20px;
border-bottom: 0; border-bottom: 0;
} }
} }
&__hint {
font-size: 14px;
font-weight: 400;
color: $darker-text-color;
}
} }

View file

@ -1399,8 +1399,8 @@ a.sparkline {
} }
.account-card { .account-card {
background: $ui-base-color;
border-radius: 4px; border-radius: 4px;
border: 1px solid lighten($ui-base-color, 8%);
&__permalink { &__permalink {
color: inherit; color: inherit;

View file

@ -8,7 +8,7 @@
body { body {
font-family: $font-sans-serif, sans-serif; font-family: $font-sans-serif, sans-serif;
background: darken($ui-base-color, 8%); background: var(--background-color);
font-size: 13px; font-size: 13px;
line-height: 18px; line-height: 18px;
font-weight: 400; font-weight: 400;

File diff suppressed because it is too large Load diff

View file

@ -1315,6 +1315,12 @@ code {
font-weight: 600; font-weight: 600;
} }
.hint {
display: block;
font-size: 14px;
color: $darker-text-color;
}
.recommended { .recommended {
position: absolute; position: absolute;
margin: 0 4px; margin: 0 4px;

View file

@ -21,25 +21,6 @@ html {
} }
// Change default background colors of columns // Change default background colors of columns
.column > .scrollable,
.getting-started,
.column-inline-form,
.regeneration-indicator {
background: $white;
border: 1px solid lighten($ui-base-color, 8%);
border-top: 0;
}
.error-column {
border: 1px solid lighten($ui-base-color, 8%);
}
.column > .scrollable.about {
border-top: 1px solid lighten($ui-base-color, 8%);
}
.about__meta,
.about__section__title,
.interaction-modal { .interaction-modal {
background: $white; background: $white;
border: 1px solid lighten($ui-base-color, 8%); border: 1px solid lighten($ui-base-color, 8%);
@ -53,37 +34,10 @@ html {
background: lighten($ui-base-color, 12%); background: lighten($ui-base-color, 12%);
} }
.filter-form {
background: $white;
border-bottom: 1px solid lighten($ui-base-color, 8%);
}
.column-back-button,
.column-header {
background: $white;
border: 1px solid lighten($ui-base-color, 8%);
@media screen and (max-width: $no-gap-breakpoint) {
border-top: 0;
}
&--slim-button {
top: -50px;
right: 0;
}
}
.column-header__back-button,
.column-header__button,
.column-header__button.active,
.account__header { .account__header {
background: $white; background: $white;
} }
.column-header {
border-bottom: 0;
}
.column-header__button.active { .column-header__button.active {
color: $ui-highlight-color; color: $ui-highlight-color;
@ -91,7 +45,6 @@ html {
&:active, &:active,
&:focus { &:focus {
color: $ui-highlight-color; color: $ui-highlight-color;
background: $white;
} }
} }
@ -117,25 +70,6 @@ html {
} }
} }
.column-subheading {
background: darken($ui-base-color, 4%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
}
.getting-started,
.scrollable {
.column-link {
background: $white;
border-bottom: 1px solid lighten($ui-base-color, 8%);
&:hover,
&:active,
&:focus {
background: $ui-base-color;
}
}
}
.getting-started .navigation-bar { .getting-started .navigation-bar {
border-top: 1px solid lighten($ui-base-color, 8%); border-top: 1px solid lighten($ui-base-color, 8%);
border-bottom: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%);
@ -168,23 +102,6 @@ html {
border-bottom: 0; border-bottom: 0;
} }
.notification__filter-bar {
border: 1px solid lighten($ui-base-color, 8%);
border-top: 0;
}
.drawer__header,
.drawer__inner {
background: $white;
border: 1px solid lighten($ui-base-color, 8%);
}
.drawer__inner__mastodon {
background: $white
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>')
no-repeat bottom / 100% auto;
}
.upload-progress__backdrop { .upload-progress__backdrop {
background: $ui-base-color; background: $ui-base-color;
} }
@ -194,11 +111,6 @@ html {
background: lighten($white, 4%); background: lighten($white, 4%);
} }
.detailed-status,
.detailed-status__action-bar {
background: $white;
}
// Change the background colors of status__content__spoiler-link // Change the background colors of status__content__spoiler-link
.reply-indicator__content .status__content__spoiler-link, .reply-indicator__content .status__content__spoiler-link,
.status__content .status__content__spoiler-link { .status__content .status__content__spoiler-link {
@ -351,11 +263,11 @@ html {
} }
.react-toggle-track { .react-toggle-track {
background: $ui-secondary-color; background: $ui-primary-color;
} }
.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
background: darken($ui-secondary-color, 10%); background: lighten($ui-primary-color, 10%);
} }
.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled)

View file

@ -59,4 +59,8 @@ $emojis-requiring-inversion: 'chains';
.theme-mastodon-light { .theme-mastodon-light {
--dropdown-border-color: #d9e1e8; --dropdown-border-color: #d9e1e8;
--dropdown-background-color: #fff; --dropdown-background-color: #fff;
--background-border-color: #d9e1e8;
--background-color: #fff;
--background-color-tint: rgba(255, 255, 255, 90%);
--background-filter: blur(10px);
} }

View file

@ -100,10 +100,15 @@ $ui-avatar-border-size: 8%;
$dismiss-overlay-width: 4rem; $dismiss-overlay-width: 4rem;
:root { :root {
--dropdown-border-color: #{lighten($ui-base-color, 12%)}; --dropdown-border-color: #{lighten($ui-base-color, 4%)};
--dropdown-background-color: #{lighten($ui-base-color, 4%)}; --dropdown-background-color: #{rgba(darken($ui-base-color, 8%), 0.9)};
--dropdown-shadow: 0 20px 25px -5px #{rgba($base-shadow-color, 0.25)}, --dropdown-shadow: 0 20px 25px -5px #{rgba($base-shadow-color, 0.25)},
0 8px 10px -6px #{rgba($base-shadow-color, 0.25)}; 0 8px 10px -6px #{rgba($base-shadow-color, 0.25)};
--modal-background-color: #{darken($ui-base-color, 4%)}; --modal-background-color: #{darken($ui-base-color, 4%)};
--modal-border-color: #{lighten($ui-base-color, 4%)}; --modal-border-color: #{lighten($ui-base-color, 4%)};
--background-border-color: #{lighten($ui-base-color, 4%)};
--background-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
--background-color: #{darken($ui-base-color, 8%)};
--background-color-tint: #{rgba(darken($ui-base-color, 8%), 0.9)};
--surface-background-color: #{darken($ui-base-color, 4%)};
} }

View file

@ -1,5 +1,3 @@
import Rails from '@rails/ujs';
import { signOutLink } from 'flavours/glitch/utils/backend_links'; import { signOutLink } from 'flavours/glitch/utils/backend_links';
export const logOut = () => { export const logOut = () => {
@ -11,13 +9,18 @@ export const logOut = () => {
methodInput.setAttribute('type', 'hidden'); methodInput.setAttribute('type', 'hidden');
form.appendChild(methodInput); form.appendChild(methodInput);
const csrfToken = Rails.csrfToken(); const csrfToken = document.querySelector<HTMLMetaElement>(
const csrfParam = Rails.csrfParam(); 'meta[name=csrf-token]',
);
const csrfParam = document.querySelector<HTMLMetaElement>(
'meta[name=csrf-param]',
);
if (csrfParam && csrfToken) { if (csrfParam && csrfToken) {
const csrfInput = document.createElement('input'); const csrfInput = document.createElement('input');
csrfInput.setAttribute('name', csrfParam); csrfInput.setAttribute('name', csrfParam.content);
csrfInput.setAttribute('value', csrfToken); csrfInput.setAttribute('value', csrfToken.content);
csrfInput.setAttribute('type', 'hidden'); csrfInput.setAttribute('type', 'hidden');
form.appendChild(csrfInput); form.appendChild(csrfInput);
} }

View file

@ -69,3 +69,11 @@ export function pluralReady(
export function roundTo10(num: number): number { export function roundTo10(num: number): number {
return Math.round(num * 0.1) / 0.1; return Math.round(num * 0.1) / 0.1;
} }
export function toCappedNumber(num: string): string {
if (parseInt(num) > 99) {
return '99+';
} else {
return num;
}
}

View file

@ -788,11 +788,12 @@ export function addPollOption(title) {
}; };
} }
export function changePollOption(index, title) { export function changePollOption(index, title, maxOptions) {
return { return {
type: COMPOSE_POLL_OPTION_CHANGE, type: COMPOSE_POLL_OPTION_CHANGE,
index, index,
title, title,
maxOptions,
}; };
} }

View file

@ -44,6 +44,38 @@ export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
export const NOTIFICATION_POLICY_FETCH_REQUEST = 'NOTIFICATION_POLICY_FETCH_REQUEST';
export const NOTIFICATION_POLICY_FETCH_SUCCESS = 'NOTIFICATION_POLICY_FETCH_SUCCESS';
export const NOTIFICATION_POLICY_FETCH_FAIL = 'NOTIFICATION_POLICY_FETCH_FAIL';
export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST';
export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS';
export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL';
export const NOTIFICATION_REQUESTS_EXPAND_REQUEST = 'NOTIFICATION_REQUESTS_EXPAND_REQUEST';
export const NOTIFICATION_REQUESTS_EXPAND_SUCCESS = 'NOTIFICATION_REQUESTS_EXPAND_SUCCESS';
export const NOTIFICATION_REQUESTS_EXPAND_FAIL = 'NOTIFICATION_REQUESTS_EXPAND_FAIL';
export const NOTIFICATION_REQUEST_FETCH_REQUEST = 'NOTIFICATION_REQUEST_FETCH_REQUEST';
export const NOTIFICATION_REQUEST_FETCH_SUCCESS = 'NOTIFICATION_REQUEST_FETCH_SUCCESS';
export const NOTIFICATION_REQUEST_FETCH_FAIL = 'NOTIFICATION_REQUEST_FETCH_FAIL';
export const NOTIFICATION_REQUEST_ACCEPT_REQUEST = 'NOTIFICATION_REQUEST_ACCEPT_REQUEST';
export const NOTIFICATION_REQUEST_ACCEPT_SUCCESS = 'NOTIFICATION_REQUEST_ACCEPT_SUCCESS';
export const NOTIFICATION_REQUEST_ACCEPT_FAIL = 'NOTIFICATION_REQUEST_ACCEPT_FAIL';
export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMISS_REQUEST';
export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS';
export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL';
defineMessages({ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
@ -313,3 +345,270 @@ export function setBrowserPermission (value) {
value, value,
}; };
} }
export const fetchNotificationPolicy = () => (dispatch, getState) => {
dispatch(fetchNotificationPolicyRequest());
api(getState).get('/api/v1/notifications/policy').then(({ data }) => {
dispatch(fetchNotificationPolicySuccess(data));
}).catch(err => {
dispatch(fetchNotificationPolicyFail(err));
});
};
export const fetchNotificationPolicyRequest = () => ({
type: NOTIFICATION_POLICY_FETCH_REQUEST,
});
export const fetchNotificationPolicySuccess = policy => ({
type: NOTIFICATION_POLICY_FETCH_SUCCESS,
policy,
});
export const fetchNotificationPolicyFail = error => ({
type: NOTIFICATION_POLICY_FETCH_FAIL,
error,
});
export const updateNotificationsPolicy = params => (dispatch, getState) => {
dispatch(fetchNotificationPolicyRequest());
api(getState).put('/api/v1/notifications/policy', params).then(({ data }) => {
dispatch(fetchNotificationPolicySuccess(data));
}).catch(err => {
dispatch(fetchNotificationPolicyFail(err));
});
};
export const fetchNotificationRequests = () => (dispatch, getState) => {
const params = {};
if (getState().getIn(['notificationRequests', 'isLoading'])) {
return;
}
if (getState().getIn(['notificationRequests', 'items'])?.size > 0) {
params.since_id = getState().getIn(['notificationRequests', 'items', 0, 'id']);
}
dispatch(fetchNotificationRequestsRequest());
api(getState).get('/api/v1/notifications/requests', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchNotificationRequestsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchNotificationRequestsFail(err));
});
};
export const fetchNotificationRequestsRequest = () => ({
type: NOTIFICATION_REQUESTS_FETCH_REQUEST,
});
export const fetchNotificationRequestsSuccess = (requests, next) => ({
type: NOTIFICATION_REQUESTS_FETCH_SUCCESS,
requests,
next,
});
export const fetchNotificationRequestsFail = error => ({
type: NOTIFICATION_REQUESTS_FETCH_FAIL,
error,
});
export const expandNotificationRequests = () => (dispatch, getState) => {
const url = getState().getIn(['notificationRequests', 'next']);
if (!url || getState().getIn(['notificationRequests', 'isLoading'])) {
return;
}
dispatch(expandNotificationRequestsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(expandNotificationRequestsSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(expandNotificationRequestsFail(err));
});
};
export const expandNotificationRequestsRequest = () => ({
type: NOTIFICATION_REQUESTS_EXPAND_REQUEST,
});
export const expandNotificationRequestsSuccess = (requests, next) => ({
type: NOTIFICATION_REQUESTS_EXPAND_SUCCESS,
requests,
next,
});
export const expandNotificationRequestsFail = error => ({
type: NOTIFICATION_REQUESTS_EXPAND_FAIL,
error,
});
export const fetchNotificationRequest = id => (dispatch, getState) => {
const current = getState().getIn(['notificationRequests', 'current']);
if (current.getIn(['item', 'id']) === id || current.get('isLoading')) {
return;
}
dispatch(fetchNotificationRequestRequest(id));
api(getState).get(`/api/v1/notifications/requests/${id}`).then(({ data }) => {
dispatch(fetchNotificationRequestSuccess(data));
}).catch(err => {
dispatch(fetchNotificationRequestFail(id, err));
});
};
export const fetchNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_FETCH_REQUEST,
id,
});
export const fetchNotificationRequestSuccess = request => ({
type: NOTIFICATION_REQUEST_FETCH_SUCCESS,
request,
});
export const fetchNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_FETCH_FAIL,
id,
error,
});
export const acceptNotificationRequest = id => (dispatch, getState) => {
dispatch(acceptNotificationRequestRequest(id));
api(getState).post(`/api/v1/notifications/requests/${id}/accept`).then(() => {
dispatch(acceptNotificationRequestSuccess(id));
}).catch(err => {
dispatch(acceptNotificationRequestFail(id, err));
});
};
export const acceptNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_ACCEPT_REQUEST,
id,
});
export const acceptNotificationRequestSuccess = id => ({
type: NOTIFICATION_REQUEST_ACCEPT_SUCCESS,
id,
});
export const acceptNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_ACCEPT_FAIL,
id,
error,
});
export const dismissNotificationRequest = id => (dispatch, getState) => {
dispatch(dismissNotificationRequestRequest(id));
api(getState).post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{
dispatch(dismissNotificationRequestSuccess(id));
}).catch(err => {
dispatch(dismissNotificationRequestFail(id, err));
});
};
export const dismissNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_DISMISS_REQUEST,
id,
});
export const dismissNotificationRequestSuccess = id => ({
type: NOTIFICATION_REQUEST_DISMISS_SUCCESS,
id,
});
export const dismissNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_DISMISS_FAIL,
id,
error,
});
export const fetchNotificationsForRequest = accountId => (dispatch, getState) => {
const current = getState().getIn(['notificationRequests', 'current']);
const params = { account_id: accountId };
if (current.getIn(['item', 'account']) === accountId) {
if (current.getIn(['notifications', 'isLoading'])) {
return;
}
if (current.getIn(['notifications', 'items'])?.size > 0) {
params.since_id = current.getIn(['notifications', 'items', 0, 'id']);
}
}
dispatch(fetchNotificationsForRequestRequest());
api(getState).get('/api/v1/notifications', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(fetchNotificationsForRequestSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(fetchNotificationsForRequestFail(err));
});
};
export const fetchNotificationsForRequestRequest = () => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
});
export const fetchNotificationsForRequestSuccess = (notifications, next) => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
notifications,
next,
});
export const fetchNotificationsForRequestFail = (error) => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
error,
});
export const expandNotificationsForRequest = () => (dispatch, getState) => {
const url = getState().getIn(['notificationRequests', 'current', 'notifications', 'next']);
if (!url || getState().getIn(['notificationRequests', 'current', 'notifications', 'isLoading'])) {
return;
}
dispatch(expandNotificationsForRequestRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(expandNotificationsForRequestSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(expandNotificationsForRequestFail(err));
});
};
export const expandNotificationsForRequestRequest = () => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST,
});
export const expandNotificationsForRequestSuccess = (notifications, next) => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS,
notifications,
next,
});
export const expandNotificationsForRequestFail = (error) => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL,
error,
});

View file

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent, useCallback } from 'react'; import { PureComponent, useCallback } from 'react';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
@ -11,7 +11,7 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import TuneIcon from '@/material-icons/400-24px/tune.svg?react'; import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar, useColumnsContext } from 'mastodon/features/ui/util/columns_context'; import { ButtonInTabsBar, useColumnsContext } from 'mastodon/features/ui/util/columns_context';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -23,10 +23,12 @@ const messages = defineMessages({
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
}); });
const BackButton = ({ pinned, show }) => { const BackButton = ({ pinned, show, onlyIcon }) => {
const history = useAppHistory(); const history = useAppHistory();
const intl = useIntl();
const { multiColumn } = useColumnsContext(); const { multiColumn } = useColumnsContext();
const handleBackClick = useCallback(() => { const handleBackClick = useCallback(() => {
@ -39,18 +41,20 @@ const BackButton = ({ pinned, show }) => {
const showButton = history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || show); const showButton = history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || show);
if(!showButton) return null; if (!showButton) return null;
return (<button onClick={handleBackClick} className='column-header__back-button'>
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>);
return (
<button onClick={handleBackClick} className={classNames('column-header__back-button', { 'compact': onlyIcon })} aria-label={intl.formatMessage(messages.back)}>
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
{!onlyIcon && <FormattedMessage id='column_back_button.label' defaultMessage='Back' />}
</button>
);
}; };
BackButton.propTypes = { BackButton.propTypes = {
pinned: PropTypes.bool, pinned: PropTypes.bool,
show: PropTypes.bool, show: PropTypes.bool,
onlyIcon: PropTypes.bool,
}; };
class ColumnHeader extends PureComponent { class ColumnHeader extends PureComponent {
@ -145,27 +149,31 @@ class ColumnHeader extends PureComponent {
} }
if (multiColumn && pinned) { if (multiColumn && pinned) {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
moveButtons = ( moveButtons = (
<div key='move-buttons' className='column-header__setting-arrows'> <div className='column-header__setting-arrows'>
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button> <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button> <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>
</div> </div>
); );
} else if (multiColumn && this.props.onPin) { } else if (multiColumn && this.props.onPin) {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
} }
backButton = <BackButton pinned={pinned} show={showBackButton} />; backButton = <BackButton pinned={pinned} show={showBackButton} onlyIcon={!!title} />;
const collapsedContent = [ const collapsedContent = [
extraContent, extraContent,
]; ];
if (multiColumn) { if (multiColumn) {
collapsedContent.push(pinButton); collapsedContent.push(
collapsedContent.push(moveButtons); <div key='buttons' className='column-header__advanced-buttons'>
{pinButton}
{moveButtons}
</div>
);
} }
if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) { if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
@ -177,7 +185,7 @@ class ColumnHeader extends PureComponent {
onClick={this.handleToggleClick} onClick={this.handleToggleClick}
> >
<i className='icon-with-badge'> <i className='icon-with-badge'>
<Icon id='sliders' icon={TuneIcon} /> <Icon id='sliders' icon={SettingsIcon} />
{collapseIssues && <i className='icon-with-badge__issue-badge' />} {collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i> </i>
</button> </button>
@ -190,16 +198,19 @@ class ColumnHeader extends PureComponent {
<div className={wrapperClassName}> <div className={wrapperClassName}>
<h1 className={buttonClassName}> <h1 className={buttonClassName}>
{hasTitle && ( {hasTitle && (
<button onClick={this.handleTitleClick}> <>
<Icon id={icon} icon={iconComponent} className='column-header__icon' /> {showBackButton && backButton}
{title}
</button> <button onClick={this.handleTitleClick} className='column-header__title'>
{!showBackButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
{title}
</button>
</>
)} )}
{!hasTitle && backButton} {!hasTitle && showBackButton && backButton}
<div className='column-header__buttons'> <div className='column-header__buttons'>
{hasTitle && backButton}
{extraButton} {extraButton}
{collapseButton} {collapseButton}
</div> </div>

View file

@ -9,7 +9,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { CircularProgress } from 'mastodon/components/circular_progress'; import { CircularProgress } from 'mastodon/components/circular_progress';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -298,7 +297,7 @@ class Dropdown extends PureComponent {
}) : ( }) : (
<IconButton <IconButton
icon={!open ? icon : 'close'} icon={!open ? icon : 'close'}
iconComponent={!open ? iconComponent : CloseIcon} iconComponent={iconComponent}
title={title} title={title}
active={open} active={open}
disabled={disabled} disabled={disabled}

View file

@ -5,9 +5,7 @@ import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { Icon } from 'mastodon/components/icon';
import InlineAccount from 'mastodon/components/inline_account'; import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
@ -67,7 +65,7 @@ class EditedTimestamp extends PureComponent {
return ( return (
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}> <DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
<button className='dropdown-menu__text-button'> <button className='dropdown-menu__text-button'>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(timestamp, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> <Icon id='caret-down' icon={ArrowDropDownIcon} /> <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <span className='animated-number'>{intl.formatDate(timestamp, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span> }} />
</button> </button>
</DropdownMenu> </DropdownMenu>
); );

View file

@ -22,6 +22,7 @@ import Card from '../features/status/components/card';
// to use the progress bar to show download progress // to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle'; import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { SensitiveMediaContext } from '../features/ui/util/sensitive_media_context';
import { displayMedia } from '../initial_state'; import { displayMedia } from '../initial_state';
import { Avatar } from './avatar'; import { Avatar } from './avatar';
@ -78,6 +79,8 @@ const messages = defineMessages({
class Status extends ImmutablePureComponent { class Status extends ImmutablePureComponent {
static contextType = SensitiveMediaContext;
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
account: ImmutablePropTypes.record, account: ImmutablePropTypes.record,
@ -133,19 +136,21 @@ class Status extends ImmutablePureComponent {
]; ];
state = { state = {
showMedia: defaultMediaVisibility(this.props.status), showMedia: defaultMediaVisibility(this.props.status) && !(this.context?.hideMediaByDefault),
statusId: undefined,
forceFilter: undefined, forceFilter: undefined,
}; };
static getDerivedStateFromProps(nextProps, prevState) { componentDidUpdate (prevProps) {
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { // This will potentially cause a wasteful redraw, but in most cases `Status` components are used
return { // with a `key` directly depending on their `id`, preventing re-use of the component across
showMedia: defaultMediaVisibility(nextProps.status), // different IDs.
statusId: nextProps.status.get('id'), // But just in case this does change, reset the state on status change.
};
} else { if (this.props.status?.get('id') !== prevProps.status?.get('id')) {
return null; this.setState({
showMedia: defaultMediaVisibility(this.props.status) && !(this.context?.hideMediaByDefault),
forceFilter: undefined,
});
} }
} }

View file

@ -170,7 +170,8 @@ class About extends PureComponent {
<ol className='rules-list'> <ol className='rules-list'>
{server.get('rules').map(rule => ( {server.get('rules').map(rule => (
<li key={rule.get('id')}> <li key={rule.get('id')}>
<span className='rules-list__text'>{rule.get('text')}</span> <div className='rules-list__text'>{rule.get('text')}</div>
{rule.get('hint').length > 0 && (<div className='rules-list__hint'>{rule.get('hint')}</div>)}
</li> </li>
))} ))}
</ol> </ol>
@ -188,18 +189,20 @@ class About extends PureComponent {
<> <>
<p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p> <p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
<div className='about__domain-blocks'> {domainBlocks.get('items').size > 0 && (
{domainBlocks.get('items').map(block => ( <div className='about__domain-blocks'>
<div className='about__domain-blocks__domain' key={block.get('domain')}> {domainBlocks.get('items').map(block => (
<div className='about__domain-blocks__domain__header'> <div className='about__domain-blocks__domain' key={block.get('domain')}>
<h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6> <div className='about__domain-blocks__domain__header'>
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span> <h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
</div> <span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span>
</div>
<p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p> <p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
</div> </div>
))} ))}
</div> </div>
)}
</> </>
) : ( ) : (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p> <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>

View file

@ -22,6 +22,7 @@ import { CopyIconButton } from 'mastodon/components/copy_icon_button';
import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters'; import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { ShortNumber } from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { autoPlayGif, me, domain } from 'mastodon/initial_state'; import { autoPlayGif, me, domain } from 'mastodon/initial_state';
@ -289,7 +290,7 @@ class Header extends ImmutablePureComponent {
if (me !== account.get('id')) { if (me !== account.get('id')) {
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = ''; actionBtn = <Button disabled><LoadingIndicator /></Button>;
} else if (account.getIn(['relationship', 'requested'])) { } else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) { } else if (!account.getIn(['relationship', 'blocking'])) {
@ -426,15 +427,10 @@ class Header extends ImmutablePureComponent {
</a> </a>
<div className='account__header__tabs__buttons'> <div className='account__header__tabs__buttons'>
{!hidden && ( {!hidden && bellBtn}
<> {!hidden && shareBtn}
{actionBtn}
{bellBtn}
{shareBtn}
</>
)}
<DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' iconComponent={MoreHorizIcon} size={24} direction='right' /> <DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' iconComponent={MoreHorizIcon} size={24} direction='right' />
{!hidden && actionBtn}
</div> </div>
</div> </div>

View file

@ -20,7 +20,7 @@ class ColumnSettings extends PureComponent {
const { settings, onChange } = this.props; const { settings, onChange } = this.props;
return ( return (
<div> <div className='column-settings'>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} /> <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
</div> </div>

View file

@ -58,10 +58,11 @@ const Option = ({ multipleChoice, index, title, autoFocus }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const suggestions = useSelector(state => state.getIn(['compose', 'suggestions'])); const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
const lang = useSelector(state => state.getIn(['compose', 'language'])); const lang = useSelector(state => state.getIn(['compose', 'language']));
const maxOptions = useSelector(state => state.getIn(['server', 'server', 'configuration', 'polls', 'max_options']));
const handleChange = useCallback(({ target: { value } }) => { const handleChange = useCallback(({ target: { value } }) => {
dispatch(changePollOption(index, value)); dispatch(changePollOption(index, value, maxOptions));
}, [dispatch, index]); }, [dispatch, index, maxOptions]);
const handleSuggestionsFetchRequested = useCallback(token => { const handleSuggestionsFetchRequested = useCallback(token => {
dispatch(fetchComposeSuggestions(token)); dispatch(fetchComposeSuggestions(token));

View file

@ -7,7 +7,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { LoadMore } from 'mastodon/components/load_more'; import { LoadMore } from 'mastodon/components/load_more';
@ -76,11 +75,6 @@ class SearchResults extends ImmutablePureComponent {
return ( return (
<div className='search-results'> <div className='search-results'>
<div className='search-results__header'>
<Icon id='search' icon={SearchIcon} />
<FormattedMessage id='explore.search_results' defaultMessage='Search results' />
</div>
{accounts} {accounts}
{hashtags} {hashtags}
{statuses} {statuses}

View file

@ -42,15 +42,17 @@ const ColumnSettings = () => {
); );
return ( return (
<div> <div className='column-settings'>
<div className='column-settings__row'> <section>
<SettingToggle <div className='column-settings__row'>
settings={settings} <SettingToggle
settingPath={['onlyMedia']} settings={settings}
onChange={onChange} settingPath={['onlyMedia']}
label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} onChange={onChange}
/> label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />}
</div> />
</div>
</section>
</div> </div>
); );
}; };

View file

@ -107,28 +107,28 @@ class ColumnSettings extends PureComponent {
const { settings, onChange } = this.props; const { settings, onChange } = this.props;
return ( return (
<div> <div className='column-settings'>
<div className='column-settings__row'> <section>
<div className='setting-toggle'> <div className='column-settings__row'>
<Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} /> <SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
<span className='setting-toggle__label'> <div className='setting-toggle'>
<FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' /> <Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} />
</span>
<span className='setting-toggle__label'>
<FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' />
</span>
</div>
</div> </div>
</div>
{this.state.open && ( {this.state.open && (
<div className='column-settings__hashtags'> <div className='column-settings__hashtags'>
{this.modeSelect('any')} {this.modeSelect('any')}
{this.modeSelect('all')} {this.modeSelect('all')}
{this.modeSelect('none')} {this.modeSelect('none')}
</div> </div>
)} )}
</section>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
</div>
</div> </div>
); );
} }

View file

@ -24,43 +24,36 @@ export const ColumnSettings: React.FC = () => {
); );
return ( return (
<div> <div className='column-settings'>
<span className='column-settings__section'> <section>
<FormattedMessage <div className='column-settings__row'>
id='home.column_settings.basic' <SettingToggle
defaultMessage='Basic' prefix='home_timeline'
/> settings={settings}
</span> settingPath={['shows', 'reblog']}
onChange={onChange}
label={
<FormattedMessage
id='home.column_settings.show_reblogs'
defaultMessage='Show boosts'
/>
}
/>
<div className='column-settings__row'> <SettingToggle
<SettingToggle prefix='home_timeline'
prefix='home_timeline' settings={settings}
settings={settings} settingPath={['shows', 'reply']}
settingPath={['shows', 'reblog']} onChange={onChange}
onChange={onChange} label={
label={ <FormattedMessage
<FormattedMessage id='home.column_settings.show_replies'
id='home.column_settings.show_reblogs' defaultMessage='Show replies'
defaultMessage='Show boosts' />
/> }
} />
/> </div>
</div> </section>
<div className='column-settings__row'>
<SettingToggle
prefix='home_timeline'
settings={settings}
settingPath={['shows', 'reply']}
onChange={onChange}
label={
<FormattedMessage
id='home.column_settings.show_replies'
defaultMessage='Show replies'
/>
}
/>
</div>
</div> </div>
); );
}; };

View file

@ -193,35 +193,38 @@ class ListTimeline extends PureComponent {
pinned={pinned} pinned={pinned}
multiColumn={multiColumn} multiColumn={multiColumn}
> >
<div className='column-settings__row column-header__links'> <div className='column-settings'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}> <section className='column-header__links'>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' /> <button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
</button> <Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}> <button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' /> <Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
</button> </button>
</div> </section>
<div className='setting-toggle'> <section>
<Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} /> <div className='setting-toggle'>
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'> <Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} />
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' /> <label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
</label> <FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
</div> </label>
{ replies_policy !== undefined && (
<div role='group' aria-labelledby={`list-${id}-replies-policy`}>
<span id={`list-${id}-replies-policy`} className='column-settings__section'>
<FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' />
</span>
<div className='column-settings__row'>
{ ['none', 'list', 'followed'].map(policy => (
<RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
))}
</div> </div>
</div> </section>
)}
{replies_policy !== undefined && (
<section aria-labelledby={`list-${id}-replies-policy`}>
<h3 id={`list-${id}-replies-policy`}><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></h3>
<div className='column-settings__row'>
{ ['none', 'list', 'followed'].map(policy => (
<RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
))}
</div>
</section>
)}
</div>
</ColumnHeader> </ColumnHeader>
<StatusListContainer <StatusListContainer

View file

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import Toggle from 'react-toggle';
export const CheckboxWithLabel = ({ checked, disabled, children, onChange }) => {
const handleChange = useCallback(({ target }) => {
onChange(target.checked);
}, [onChange]);
return (
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
{children}
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle checked={checked} onChange={handleChange} disabled={disabled} />
</div>
</div>
</label>
);
};
CheckboxWithLabel.propTypes = {
checked: PropTypes.bool,
disabled: PropTypes.bool,
children: PropTypes.children,
onChange: PropTypes.func,
};

Some files were not shown because too many files have changed in this diff Show more